From a5d75529921a14bee833912c6b3c1283aed1f21d Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 25 May 2023 09:35:35 +0200 Subject: [PATCH 001/330] [Fixes #11100] Bump to Geoserver 2.23.1 (#11104) * Bump to Geoserver 2.23.1 * fixed typo --- dev_config.yml | 4 ++-- docker-compose-geoserver-server.yml | 4 ++-- docker-compose-test.yml | 4 ++-- docker-compose.yml | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dev_config.yml b/dev_config.yml index 3fa02cd768e..b6c89644210 100644 --- a/dev_config.yml +++ b/dev_config.yml @@ -1,6 +1,6 @@ --- -GEOSERVER_URL: "https://artifacts.geonode.org/geoserver/2.23.0/geoserver.war" -DATA_DIR_URL: "https://artifacts.geonode.org/geoserver/2.23.0/geonode-geoserver-ext-web-app-data.zip" +GEOSERVER_URL: "https://artifacts.geonode.org/geoserver/2.23.1/geoserver.war" +DATA_DIR_URL: "https://artifacts.geonode.org/geoserver/2.23.1/geonode-geoserver-ext-web-app-data.zip" JETTY_RUNNER_URL: "https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-runner/9.4.31.v20200723/jetty-runner-9.4.31.v20200723.jar" WINDOWS: py2exe: "http://downloads.sourceforge.net/project/py2exe/py2exe/0.6.9/py2exe-0.6.9.win32-py2.7.exe" diff --git a/docker-compose-geoserver-server.yml b/docker-compose-geoserver-server.yml index 10785a5794a..8dfc08b06c0 100644 --- a/docker-compose-geoserver-server.yml +++ b/docker-compose-geoserver-server.yml @@ -2,7 +2,7 @@ version: '2.2' services: data-dir-conf: - image: geonode/geoserver_data:2.23.0 + image: geonode/geoserver_data:2.23.1 restart: on-failure container_name: gsconf4${COMPOSE_PROJECT_NAME} labels: @@ -13,7 +13,7 @@ services: - geoserver-data-dir:/geoserver_data/data geoserver: - image: geonode/geoserver:2.23.0 + image: geonode/geoserver:2.23.1 restart: unless-stopped container_name: geoserver4${COMPOSE_PROJECT_NAME} stdin_open: true diff --git a/docker-compose-test.yml b/docker-compose-test.yml index de398f099a7..9fed05cbb8d 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -89,7 +89,7 @@ services: # Geoserver backend geoserver: - image: geonode/geoserver:2.23.0 + image: geonode/geoserver:2.23.1 container_name: geoserver4${COMPOSE_PROJECT_NAME} healthcheck: test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8080/geoserver/ows" @@ -113,7 +113,7 @@ services: condition: service_healthy data-dir-conf: - image: geonode/geoserver_data:2.23.0 + image: geonode/geoserver_data:2.23.1 container_name: gsconf4${COMPOSE_PROJECT_NAME} entrypoint: sleep infinity volumes: diff --git a/docker-compose.yml b/docker-compose.yml index a4fc63d9b88..5bf831b1ff5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,7 +89,7 @@ services: # Geoserver backend geoserver: - image: geonode/geoserver:2.23.0 + image: geonode/geoserver:2.23.1 container_name: geoserver4${COMPOSE_PROJECT_NAME} healthcheck: test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8080/geoserver/ows" @@ -113,7 +113,7 @@ services: condition: service_healthy data-dir-conf: - image: geonode/geoserver_data:2.23.0 + image: geonode/geoserver_data:2.23.1 container_name: gsconf4${COMPOSE_PROJECT_NAME} entrypoint: sleep infinity volumes: From 82ca860929d209e690b781d0cdc0bdd6fb19e1a7 Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Thu, 25 May 2023 09:36:44 +0200 Subject: [PATCH 002/330] Add geotiff to supported raster type (#11106) --- geonode/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geonode/settings.py b/geonode/settings.py index 1854dc875ea..d408093575b 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -2226,7 +2226,7 @@ def get_geonode_catalogue_service(): "id": "tiff", "label": "GeoTIFF", "format": "raster", - "ext": ["tiff", "tif"], + "ext": ["tiff", "tif", "geotiff", "geotif"], "mimeType": ["image/tiff"], "optional": ["xml", "sld"], }, From 100a46e6a23a121edf54096c8c3c9a321c2c35b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 10:23:00 +0200 Subject: [PATCH 003/330] Bump requests from 2.30.0 to 2.31.0 (#11099) * Bump requests from 2.30.0 to 2.31.0 Bumps [requests](https://github.com/psf/requests) from 2.30.0 to 2.31.0. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.30.0...v2.31.0) --- updated-dependencies: - dependency-name: requests dependency-type: direct:production ... Signed-off-by: dependabot[bot] * [Dependencies] Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index f7098bfac67..c5aca3b8542 100644 --- a/requirements.txt +++ b/requirements.txt @@ -127,7 +127,7 @@ jdcal==1.4.1 mock<6.0.0 python-dateutil==2.8.2 pytz==2023.3 -requests==2.30.0 +requests==2.31.0 timeout-decorator==0.5.0 pylibmc==1.6.3 sherlock==0.4.1 diff --git a/setup.cfg b/setup.cfg index a4fb936d3fe..bccde3e9be0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -152,7 +152,7 @@ install_requires = mock<6.0.0 python-dateutil==2.8.2 pytz==2023.3 - requests==2.30.0 + requests==2.31.0 timeout-decorator==0.5.0 pylibmc==1.6.3 sherlock==0.4.1 From 76f14f8c4fb69247e3ac4665d64d0be7feada604 Mon Sep 17 00:00:00 2001 From: Snyk bot Date: Thu, 25 May 2023 09:23:34 +0100 Subject: [PATCH 004/330] [Snyk] Security upgrade requests from 2.30.0 to 2.31.0 (#11103) * fix: requirements.txt to reduce vulnerabilities The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-REQUESTS-5595532 * [Dependencies] Align setup.cfg to requirements.txt --------- Co-authored-by: afabiani From 83703e8f5b442802fa18091319d1601be6fdb64f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 10:25:11 +0200 Subject: [PATCH 005/330] Bump geoserver-restconfig from 2.0.8 to 2.0.9 (#11087) * Bump geoserver-restconfig from 2.0.8 to 2.0.9 Bumps [geoserver-restconfig](https://github.com/GeoNode/geoserver-restconfig) from 2.0.8 to 2.0.9. - [Release notes](https://github.com/GeoNode/geoserver-restconfig/releases) - [Changelog](https://github.com/GeoNode/geoserver-restconfig/blob/master/CHANGELOG.md) - [Commits](https://github.com/GeoNode/geoserver-restconfig/commits) --- updated-dependencies: - dependency-name: geoserver-restconfig dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * [Dependencies] Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index c5aca3b8542..d5536a6c91e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -97,7 +97,7 @@ geonode-user-messages==2.0.2 geonode-announcements==2.0.2 geonode-django-activity-stream==0.10.0 gn-arcrest==10.5.5 -geoserver-restconfig==2.0.8 +geoserver-restconfig==2.0.9 gn-gsimporter==2.0.4 gisdata==0.5.4 diff --git a/setup.cfg b/setup.cfg index bccde3e9be0..646f931d19c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -122,7 +122,7 @@ install_requires = geonode-announcements==2.0.2 geonode-django-activity-stream==0.10.0 gn-arcrest==10.5.5 - geoserver-restconfig==2.0.8 + geoserver-restconfig==2.0.9 gn-gsimporter==2.0.4 gisdata==0.5.4 From 65e580cfd877d9ccd810003e6fa221e1f667a2dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 10:25:42 +0200 Subject: [PATCH 006/330] Bump owslib from 0.29.1 to 0.29.2 (#11092) * Bump owslib from 0.29.1 to 0.29.2 Bumps [owslib](https://github.com/geopython/OWSLib) from 0.29.1 to 0.29.2. - [Release notes](https://github.com/geopython/OWSLib/releases) - [Commits](https://github.com/geopython/OWSLib/compare/0.29.1...0.29.2) --- updated-dependencies: - dependency-name: owslib dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * [Dependencies] Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index d5536a6c91e..d88f97b7f8f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -54,7 +54,7 @@ pyjwt==2.7.0 # geopython dependencies pyproj<3.6.0 -OWSLib==0.29.1 +OWSLib==0.29.2 pycsw==2.6.1 SQLAlchemy==2.0.13 # required by PyCSW Shapely==1.8.5.post1 diff --git a/setup.cfg b/setup.cfg index 646f931d19c..23a9956d24c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -80,7 +80,7 @@ install_requires = # geopython dependencies pyproj<3.6.0 - OWSLib==0.29.1 + OWSLib==0.29.2 pycsw==2.6.1 SQLAlchemy==2.0.13 # required by PyCSW Shapely==1.8.5.post1 From f3ddb105417c2f2d9fc5492a3f5fe63ce09ee183 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 10:34:42 +0200 Subject: [PATCH 007/330] Bump invoke from 2.1.1 to 2.1.2 (#11089) * Bump invoke from 2.1.1 to 2.1.2 Bumps [invoke](https://github.com/pyinvoke/invoke) from 2.1.1 to 2.1.2. - [Commits](https://github.com/pyinvoke/invoke/compare/2.1.1...2.1.2) --- updated-dependencies: - dependency-name: invoke dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * [Dependencies] Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index d88f97b7f8f..163298b53d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -147,7 +147,7 @@ uWSGI==2.0.21 gunicorn==20.1.0 ipython==8.13.2 docker==6.1.2 -invoke==2.1.1 +invoke==2.1.2 # tests coverage==7.2.5 diff --git a/setup.cfg b/setup.cfg index 23a9956d24c..ad6bc2d61f6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -172,7 +172,7 @@ install_requires = gunicorn==20.1.0 ipython==8.13.2 docker==6.1.2 - invoke==2.1.1 + invoke==2.1.2 # tests coverage==7.2.5 From 6d8fe4337ce78bcee53a0c39bf7e70fefb50e1e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 10:35:29 +0200 Subject: [PATCH 008/330] Update setuptools requirement from <67.8.0,>=59.1.1 to >=59.1.1,<67.9.0 (#11091) * Update setuptools requirement from <67.8.0,>=59.1.1 to >=59.1.1,<67.9.0 Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/CHANGES.rst) - [Commits](https://github.com/pypa/setuptools/compare/v65.5.1...v67.8.0) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] * [Dependencies] Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 163298b53d9..f6e9025df4d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -159,7 +159,7 @@ pytest-bdd==6.1.1 splinter==0.19.0 pytest-splinter==3.3.2 pytest-django==4.5.2 -setuptools>=59.1.1,<67.8.0 +setuptools>=59.1.1,<67.9.0 pip==23.1.2 Twisted==22.10.0 pixelmatch==0.3.0 diff --git a/setup.cfg b/setup.cfg index ad6bc2d61f6..f0c8c5aaef5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -184,7 +184,7 @@ install_requires = splinter==0.19.0 pytest-splinter==3.3.2 pytest-django==4.5.2 - setuptools>=59.1.1,<67.8.0 + setuptools>=59.1.1,<67.9.0 pip==23.1.2 Twisted==22.10.0 pixelmatch==0.3.0 From 32cda5beff8ce65716fa2be71a60e857f2a78ae6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 10:37:26 +0200 Subject: [PATCH 009/330] Bump wandb from 0.15.2 to 0.15.3 (#11090) * Bump wandb from 0.15.2 to 0.15.3 Bumps [wandb](https://github.com/wandb/wandb) from 0.15.2 to 0.15.3. - [Release notes](https://github.com/wandb/wandb/releases) - [Changelog](https://github.com/wandb/wandb/blob/main/CHANGELOG.md) - [Commits](https://github.com/wandb/wandb/compare/v0.15.2...v0.15.3) --- updated-dependencies: - dependency-name: wandb dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * [Dependencies] Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index f6e9025df4d..3f22f1e269c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -171,7 +171,7 @@ webdriver_manager==3.8.6 # Security and audit mistune==2.0.5 -wandb==0.15.2 +wandb==0.15.3 protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/setup.cfg b/setup.cfg index f0c8c5aaef5..bf8979de6be 100644 --- a/setup.cfg +++ b/setup.cfg @@ -196,7 +196,7 @@ install_requires = # Security and audit mistune==2.0.5 - wandb==0.15.2 + wandb==0.15.3 protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability From 1fa0e2ddde190f490e67ddb45c16f7bddac2a033 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 10:37:42 +0200 Subject: [PATCH 010/330] Bump boto3 from 1.26.133 to 1.26.137 (#11086) * Bump boto3 from 1.26.133 to 1.26.137 Bumps [boto3](https://github.com/boto/boto3) from 1.26.133 to 1.26.137. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.133...1.26.137) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * [Dependencies] Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3f22f1e269c..538b9b6d093 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.0 google-cloud-storage==2.9.0 google-cloud-core==2.3.2 -boto3==1.26.133 +boto3==1.26.137 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index bf8979de6be..24b27b2e9c2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.0 google-cloud-storage==2.9.0 google-cloud-core==2.3.2 - boto3==1.26.133 + boto3==1.26.137 # Django Caches python-memcached<=1.59 From 3fbdad0e340763b131123225d1a6d5e9c7cf8e68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 10:38:01 +0200 Subject: [PATCH 011/330] Bump sqlalchemy from 2.0.13 to 2.0.15 (#11088) * Bump sqlalchemy from 2.0.13 to 2.0.15 Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.13 to 2.0.15. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * [Dependencies] Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 538b9b6d093..a911b3db9e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -56,7 +56,7 @@ pyjwt==2.7.0 pyproj<3.6.0 OWSLib==0.29.2 pycsw==2.6.1 -SQLAlchemy==2.0.13 # required by PyCSW +SQLAlchemy==2.0.15 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 diff --git a/setup.cfg b/setup.cfg index 24b27b2e9c2..cbd06ed72ea 100644 --- a/setup.cfg +++ b/setup.cfg @@ -82,7 +82,7 @@ install_requires = pyproj<3.6.0 OWSLib==0.29.2 pycsw==2.6.1 - SQLAlchemy==2.0.13 # required by PyCSW + SQLAlchemy==2.0.15 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 From 927a302bb4494a3d7dce5c829d7c6a646247704c Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Thu, 25 May 2023 16:36:02 +0200 Subject: [PATCH 012/330] Remove doc_file and file_path from document serializer (#11117) --- geonode/documents/api/serializers.py | 7 +++++++ geonode/documents/api/tests.py | 15 ++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/geonode/documents/api/serializers.py b/geonode/documents/api/serializers.py index 7fdeb0ad990..dc11cb201f5 100644 --- a/geonode/documents/api/serializers.py +++ b/geonode/documents/api/serializers.py @@ -61,3 +61,10 @@ class Meta: "doc_url", "metadata", ) + + def to_representation(self, obj): + _doc = super(DocumentSerializer, self).to_representation(obj) + # better to hide internal server file path + _doc.pop("file_path") + _doc.pop("doc_file") + return _doc diff --git a/geonode/documents/api/tests.py b/geonode/documents/api/tests.py index e480cdfd3be..dbb3b13588b 100644 --- a/geonode/documents/api/tests.py +++ b/geonode/documents/api/tests.py @@ -16,7 +16,6 @@ # along with this program. If not, see . # ######################################################################### -import os import logging from django.contrib.auth import get_user_model @@ -139,14 +138,20 @@ def test_creation_should_create_the_doc(self): } actual = self.client.post(self.url, data=payload, format="json") self.assertEqual(201, actual.status_code) - cloned_path = actual.json().get("document", {}).get("file_path", "")[0] extension = actual.json().get("document", {}).get("extension", "") - self.assertTrue(os.path.exists(cloned_path)) self.assertEqual("xml", extension) self.assertTrue(Document.objects.filter(title="New document for testing").exists()) - if cloned_path: - os.remove(cloned_path) + def test_file_path_and_doc_path_are_not_returned(self): + """ + If file_path and doc_path should not be visible + from the GET payload + """ + actual = self.client.get(self.url) + self.assertEqual(200, actual.status_code) + _doc_payload = actual.json().get("document", {}) + self.assertFalse("file_path" in _doc_payload) + self.assertFalse("doc_path" in _doc_payload) def test_creation_from_url_should_create_the_doc(self): """ From 842310855ca8758d86eb00ca5cce9741f7e3ae86 Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Thu, 25 May 2023 16:36:51 +0200 Subject: [PATCH 013/330] change importer released version (#11076) * change importer released version * Update setup.cfg --------- Co-authored-by: Alessio Fabiani Co-authored-by: Giovanni Allegri --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index cbd06ed72ea..46cefd95511 100644 --- a/setup.cfg +++ b/setup.cfg @@ -115,7 +115,7 @@ install_requires = # GeoNode org maintained apps. django-geonode-mapstore-client>=4.0.5,<5.0.0 - geonode-importer>=1.0.0 + geonode-importer>=1.0.2 geonode-avatar==5.0.8 geonode-oauth-toolkit==2.2.2 geonode-user-messages==2.0.2 From a66bef9bb776f5b1a6e6ae6b9c7ff90af27c0d3a Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Fri, 26 May 2023 09:21:53 +0200 Subject: [PATCH 014/330] fix: scripts/docker/nginx/Dockerfile to reduce vulnerabilities (#11122) The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-ALPINE317-CURL-3320725 - https://snyk.io/vuln/SNYK-ALPINE317-CURL-3320725 - https://snyk.io/vuln/SNYK-ALPINE317-CURL-3364764 - https://snyk.io/vuln/SNYK-ALPINE317-CURL-3364764 - https://snyk.io/vuln/SNYK-ALPINE317-TIFF-3368766 Co-authored-by: snyk-bot --- scripts/docker/nginx/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/docker/nginx/Dockerfile b/scripts/docker/nginx/Dockerfile index 3f8c8027c20..92652f376c6 100644 --- a/scripts/docker/nginx/Dockerfile +++ b/scripts/docker/nginx/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:1.23.3-alpine +FROM nginx:1-alpine RUN apk add --no-cache openssl inotify-tools From 4bf0d8a699c2f2fceb35ae5ff67cd289d7aa1a30 Mon Sep 17 00:00:00 2001 From: Emanuele Tajariol Date: Tue, 30 May 2023 00:37:23 -0700 Subject: [PATCH 015/330] [Fixes #11129] GWC REST path incorrectly renamed to datasets (#11130) --- geonode/geoserver/management/commands/set_default_gridsets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/geonode/geoserver/management/commands/set_default_gridsets.py b/geonode/geoserver/management/commands/set_default_gridsets.py index 0fcfb51b499..0826b744e57 100644 --- a/geonode/geoserver/management/commands/set_default_gridsets.py +++ b/geonode/geoserver/management/commands/set_default_gridsets.py @@ -78,11 +78,11 @@ def handle(self, **options): """ curl -v -u admin:geoserver -XPOST \ -H "Content-type: text/xml" -d @poi.xml \ - "http://localhost:8080/geoserver/gwc/rest/datasets/tiger:poi.xml" + "http://localhost:8080/geoserver/gwc/rest/layers/tiger:poi.xml" """ headers = {'Content-type': 'text/xml'} payload = ET.tostring(tree) - r = requests.post(f'{url}gwc/rest/datasets/{layer.typename}.xml', + r = requests.post(f'{url}gwc/rest/layers/{layer.typename}.xml', headers=headers, data=payload, auth=HTTPBasicAuth(user, passwd)) From f90bc73734acc6e319c8c40a4d80d64906a5fa7c Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 1 Jun 2023 15:04:44 +0200 Subject: [PATCH 016/330] Revert "[Fixes #11100] Bump to Geoserver 2.23.1 (#11104) (#11107)" (#11135) This reverts commit 91e68714ecf5bfd3afe9262855337e7f959f752c. --- dev_config.yml | 4 ++-- docker-compose-geoserver-server.yml | 4 ++-- docker-compose-test.yml | 4 ++-- docker-compose.yml | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dev_config.yml b/dev_config.yml index b6c89644210..3fa02cd768e 100644 --- a/dev_config.yml +++ b/dev_config.yml @@ -1,6 +1,6 @@ --- -GEOSERVER_URL: "https://artifacts.geonode.org/geoserver/2.23.1/geoserver.war" -DATA_DIR_URL: "https://artifacts.geonode.org/geoserver/2.23.1/geonode-geoserver-ext-web-app-data.zip" +GEOSERVER_URL: "https://artifacts.geonode.org/geoserver/2.23.0/geoserver.war" +DATA_DIR_URL: "https://artifacts.geonode.org/geoserver/2.23.0/geonode-geoserver-ext-web-app-data.zip" JETTY_RUNNER_URL: "https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-runner/9.4.31.v20200723/jetty-runner-9.4.31.v20200723.jar" WINDOWS: py2exe: "http://downloads.sourceforge.net/project/py2exe/py2exe/0.6.9/py2exe-0.6.9.win32-py2.7.exe" diff --git a/docker-compose-geoserver-server.yml b/docker-compose-geoserver-server.yml index 8dfc08b06c0..10785a5794a 100644 --- a/docker-compose-geoserver-server.yml +++ b/docker-compose-geoserver-server.yml @@ -2,7 +2,7 @@ version: '2.2' services: data-dir-conf: - image: geonode/geoserver_data:2.23.1 + image: geonode/geoserver_data:2.23.0 restart: on-failure container_name: gsconf4${COMPOSE_PROJECT_NAME} labels: @@ -13,7 +13,7 @@ services: - geoserver-data-dir:/geoserver_data/data geoserver: - image: geonode/geoserver:2.23.1 + image: geonode/geoserver:2.23.0 restart: unless-stopped container_name: geoserver4${COMPOSE_PROJECT_NAME} stdin_open: true diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 9fed05cbb8d..de398f099a7 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -89,7 +89,7 @@ services: # Geoserver backend geoserver: - image: geonode/geoserver:2.23.1 + image: geonode/geoserver:2.23.0 container_name: geoserver4${COMPOSE_PROJECT_NAME} healthcheck: test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8080/geoserver/ows" @@ -113,7 +113,7 @@ services: condition: service_healthy data-dir-conf: - image: geonode/geoserver_data:2.23.1 + image: geonode/geoserver_data:2.23.0 container_name: gsconf4${COMPOSE_PROJECT_NAME} entrypoint: sleep infinity volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 5bf831b1ff5..a4fc63d9b88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,7 +89,7 @@ services: # Geoserver backend geoserver: - image: geonode/geoserver:2.23.1 + image: geonode/geoserver:2.23.0 container_name: geoserver4${COMPOSE_PROJECT_NAME} healthcheck: test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8080/geoserver/ows" @@ -113,7 +113,7 @@ services: condition: service_healthy data-dir-conf: - image: geonode/geoserver_data:2.23.1 + image: geonode/geoserver_data:2.23.0 container_name: gsconf4${COMPOSE_PROJECT_NAME} entrypoint: sleep infinity volumes: From 6887596c9672b2e5b6575e6e40c07b37951f4b8e Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Mon, 5 Jun 2023 15:08:40 +0200 Subject: [PATCH 017/330] [Fixes #11147] Strip port from visitor_ip_address (#11148) * strip port from visitor_ip_address * gix PEP --- geonode/base/auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/geonode/base/auth.py b/geonode/base/auth.py index cfef16bd820..e56d03edb01 100644 --- a/geonode/base/auth.py +++ b/geonode/base/auth.py @@ -259,6 +259,8 @@ def visitor_ip_address(request): ip = x_forwarded_for.split(",")[0] else: ip = request.META.get("REMOTE_ADDR") + if ip: + ip = re.match(r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})", ip)[0] return ip From 5ae4e1818d7544acc6cd9c899dc1e270cb979784 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 15 Jun 2023 17:23:48 +0200 Subject: [PATCH 018/330] Fix region intersection test (#11167) --- geonode/base/fixtures/regions.json | 17 ----------------- geonode/resource/utils.py | 2 +- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/geonode/base/fixtures/regions.json b/geonode/base/fixtures/regions.json index 9aee734f63f..98e93362e0f 100644 --- a/geonode/base/fixtures/regions.json +++ b/geonode/base/fixtures/regions.json @@ -4334,23 +4334,6 @@ "bbox_y1": 27.454559 } }, - { - "pk": 256, - "model": "base.region", - "fields": { - "rght": 515, - "code": "PAC", - "name": "Pacific", - "parent": null, - "level": 1, - "lft": 464, - "tree_id": 90, - "bbox_x0": 139.572356, - "bbox_x1": -74.531208, - "bbox_y0": -56.267241, - "bbox_y1": 62.0215969 - } - }, { "pk": 257, "model": "base.region", diff --git a/geonode/resource/utils.py b/geonode/resource/utils.py index 83dc56290de..70dda53f921 100644 --- a/geonode/resource/utils.py +++ b/geonode/resource/utils.py @@ -489,7 +489,7 @@ def metadata_post_save(instance, *args, **kwargs): poly2 = GEOSGeometry(wkt2, srid=int(srid2[0])) poly2.transform(4326) - if poly2.intersection(poly1): + if not poly2.intersection(poly1).empty: regions_to_add.append(region) if region.level == 0 and region.parent is None: global_regions.append(region) From 2c1e886c581466aaec9245e8ecb93fa713fdf1ef Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Tue, 20 Jun 2023 18:29:52 +0200 Subject: [PATCH 019/330] [Fixes #11171] Fix calculation of wkt for regions crossing the dateline (#11172) * Fix region wkt calculation when crossing dateline * simplification * fix PEP8 * fix failing test * fixed wrong local path for fixtures * restore test * Keep WKT for regions not across IDL return Polygon --- geonode/base/fixtures/regions.json | 17 ++++++++ geonode/tests/test_utils.py | 69 +++++++++++++++++++++++++++++- geonode/utils.py | 39 ++++++++++++++++- 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/geonode/base/fixtures/regions.json b/geonode/base/fixtures/regions.json index 98e93362e0f..0392627eb0e 100644 --- a/geonode/base/fixtures/regions.json +++ b/geonode/base/fixtures/regions.json @@ -4334,6 +4334,23 @@ "bbox_y1": 27.454559 } }, + { + "pk": 256, + "model": "base.region", + "fields": { + "rght": 515, + "code": "PAC", + "name": "Pacific", + "parent": null, + "level": 1, + "lft": 464, + "tree_id": 90, + "bbox_x0": 112.921112, + "bbox_x1": -108.87291, + "bbox_y0": -54.640301, + "bbox_y1": 20.41712 + } + }, { "pk": 257, "model": "base.region", diff --git a/geonode/tests/test_utils.py b/geonode/tests/test_utils.py index 5132195b821..15b7cf2ef88 100644 --- a/geonode/tests/test_utils.py +++ b/geonode/tests/test_utils.py @@ -28,7 +28,7 @@ from unittest.mock import patch from datetime import datetime, timedelta -from django.contrib.gis.geos import Polygon +from django.contrib.gis.geos import GEOSGeometry, Polygon from django.contrib.auth import get_user_model from django.core.management import call_command @@ -37,7 +37,7 @@ from geonode.geoserver.helpers import set_attributes from geonode.tests.base import GeoNodeBaseTestSupport from geonode.br.management.commands.utils.utils import ignore_time -from geonode.utils import copy_tree, fixup_shp_columnnames, get_supported_datasets_file_types, unzip_file +from geonode.utils import copy_tree, fixup_shp_columnnames, get_supported_datasets_file_types, unzip_file, bbox_to_wkt from geonode import settings @@ -286,3 +286,68 @@ def test_should_replace_the_type_id_if_already_exists(self): self.assertEqual(len(supported_keys), prev_count) shp_type = [t for t in supported_types if t["id"] == "shp"][0] self.assertEqual(shp_type["label"], "Replaced type") + + +class TestRegionsCrossingDateLine(TestCase): + def setUp(self): + self.test_region_coords = [ + [112.921111999999994, -108.872910000000005, -54.640301000000001, 20.417120000000001], # Pacific + [174.866196000000002, -178.203156000000007, -21.017119999999998, -12.466220000000000], # Fiji + [158.418335000000013, -150.208359000000002, -11.437030000000000, 4.719560000000000], # Kiribati + [165.883803999999998, -175.987198000000006, -52.618591000000002, -29.209969999999998], # New Zeland + ] + + self.region_across_idl = [ + 112.921111999999994, + -108.872910000000005, + -54.640301000000001, + 20.417120000000001, + ] # Pacific + self.region_not_across_idl = [ + -31.266000999999999, + 39.869301000000000, + 27.636310999999999, + 81.008797000000001, + ] # Europe + + self.dataset_crossing = GEOSGeometry( + "POLYGON ((-179.30100799101168718 -17.31310259828852693, -170.41740336774466869 -9.63481116511300328, -164.30155495876181249 -19.7237289784715415, \ + -177.91712988386967709 -30.43762400150689018, -179.30100799101168718 -17.31310259828852693))", + srid=4326, + ) + self.dataset_not_crossing = GEOSGeometry( + "POLYGON ((-41.86461347546176626 5.43160371103088835, 34.20404118809119609 4.3602142087273279, 9.56208263510924894 -48.8521310723496498, \ + -42.22174330956295307 -32.42415870369499942, -41.86461347546176626 5.43160371103088835))", + srid=4326, + ) + + def test_region_across_dateline_do_not_intersect_areas_outside(self): + for i, region_coords in enumerate(self.test_region_coords): + geographic_bounding_box = bbox_to_wkt( + region_coords[0], region_coords[1], region_coords[2], region_coords[3] + ) + _, wkt = geographic_bounding_box.split(";") + poly = GEOSGeometry(wkt, srid=4326) + + self.assertFalse( + poly.intersection(self.dataset_crossing).empty, f"True intersection not detected for region {i}" + ) + self.assertTrue(poly.intersection(self.dataset_not_crossing).empty, "False intersection detected") + + def test_region_wkt_multipolygon_if_across_idl(self): + bbox_across_idl = bbox_to_wkt( + self.region_not_across_idl[0], + self.region_not_across_idl[1], + self.region_not_across_idl[2], + self.region_not_across_idl[3], + ) + _, wkt = bbox_across_idl.split(";") + poly = GEOSGeometry(wkt, srid=4326) + self.assertEqual(poly.geom_type, "Polygon", f"Expexted 'Polygon' type but received {poly.geom_type}") + + bbox_across_idl = bbox_to_wkt( + self.region_across_idl[0], self.region_across_idl[1], self.region_across_idl[2], self.region_across_idl[3] + ) + _, wkt = bbox_across_idl.split(";") + poly = GEOSGeometry(wkt, srid=4326) + self.assertEqual(poly.geom_type, "MultiPolygon", f"Expexted 'MultiPolygon' type but received {poly.geom_type}") diff --git a/geonode/utils.py b/geonode/utils.py index b7f82fbeaa0..3c83c824db8 100755 --- a/geonode/utils.py +++ b/geonode/utils.py @@ -496,9 +496,44 @@ def bbox_to_wkt(x0, x1, y0, y1, srid="4326", include_srid=True): if srid and str(srid).startswith("EPSG:"): srid = srid[5:] if None not in {x0, x1, y0, y1}: - wkt = "POLYGON(({:f} {:f},{:f} {:f},{:f} {:f},{:f} {:f},{:f} {:f}))".format( - float(x0), float(y0), float(x0), float(y1), float(x1), float(y1), float(x1), float(y0), float(x0), float(y0) + polys = [] + + # We assume that if x1 is smaller then x0 we're crossing the date line + crossing_idl = x1 < x0 + if crossing_idl: + polys.append( + [ + (float(x0), float(y0)), + (float(x0), float(y1)), + (180.0, float(y1)), + (180.0, float(y0)), + (float(x0), float(y0)), + ] + ) + polys.append( + [ + (-180.0, float(y0)), + (-180.0, float(y1)), + (float(x1), float(y1)), + (float(x1), float(y0)), + (-180.0, float(y0)), + ] + ) + else: + polys.append( + [ + (float(x0), float(y0)), + (float(x0), float(y1)), + (float(x1), float(y1)), + (float(x1), float(y0)), + (float(x0), float(y0)), + ] + ) + + poly_wkts = ",".join( + ["(({}))".format(",".join(["{:f} {:f}".format(coords[0], coords[1]) for coords in poly])) for poly in polys] ) + wkt = f"MULTIPOLYGON({poly_wkts})" if len(polys) > 1 else f"POLYGON{poly_wkts}" if include_srid: wkt = f"SRID={srid};{wkt}" else: From c34e4924414c30c15b14ed6ca7627d457a72a5d6 Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Thu, 22 Jun 2023 17:03:50 +0200 Subject: [PATCH 020/330] [Fixes #11188] Make keywords optional when editing a resource with the Django Admin (#11189) --- geonode/base/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geonode/base/admin.py b/geonode/base/admin.py index 7b5cbaba581..0f6c4ee859c 100755 --- a/geonode/base/admin.py +++ b/geonode/base/admin.py @@ -338,7 +338,7 @@ def keyword_id(self, obj): class ResourceBaseAdminForm(autocomplete.FutureModelForm): - keywords = TagField(widget=TaggitSelect2Custom("autocomplete_hierachical_keyword")) + keywords = TagField(widget=TaggitSelect2Custom("autocomplete_hierachical_keyword"), required=False) def delete_queryset(self, request, queryset): """ From 661bd7da21e5a7b6fd7447eb6a92e7a831173978 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 12:01:31 +0200 Subject: [PATCH 021/330] Bump deprecated from 1.2.13 to 1.2.14 (#11128) * Bump deprecated from 1.2.13 to 1.2.14 Bumps [deprecated](https://github.com/tantale/deprecated) from 1.2.13 to 1.2.14. - [Release notes](https://github.com/tantale/deprecated/releases) - [Changelog](https://github.com/tantale/deprecated/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tantale/deprecated/compare/v1.2.13...v1.2.14) --- updated-dependencies: - dependency-name: deprecated dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index a911b3db9e2..6b27ee9f92d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ celery==5.2.7 kombu==5.2.4 vine==5.0.0 tqdm==4.65.0 -Deprecated==1.2.13 +Deprecated==1.2.14 wrapt==1.15.0 jsonschema==4.17.3 zipstream-new==1.1.8 diff --git a/setup.cfg b/setup.cfg index 46cefd95511..d8deda410a9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,7 +44,7 @@ install_requires = kombu==5.2.4 vine==5.0.0 tqdm==4.65.0 - Deprecated==1.2.13 + Deprecated==1.2.14 wrapt==1.15.0 jsonschema==4.17.3 zipstream-new==1.1.8 From cdfcbc960f26433339a6eeb56751e5ddd3a04c69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 12:01:57 +0200 Subject: [PATCH 022/330] Bump django-cors-headers from 4.0.0 to 4.1.0 (#11177) * Bump django-cors-headers from 4.0.0 to 4.1.0 Bumps [django-cors-headers](https://github.com/adamchainz/django-cors-headers) from 4.0.0 to 4.1.0. - [Changelog](https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst) - [Commits](https://github.com/adamchainz/django-cors-headers/compare/4.0.0...4.1.0) --- updated-dependencies: - dependency-name: django-cors-headers dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6b27ee9f92d..6d3fa4788d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -134,7 +134,7 @@ sherlock==0.4.1 # required by monitoring psutil==5.9.5 -django-cors-headers==4.0.0 +django-cors-headers==4.1.0 user-agents django-user-agents xmljson diff --git a/setup.cfg b/setup.cfg index d8deda410a9..7d989d43bf5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -159,7 +159,7 @@ install_requires = # required by monitoring psutil==5.9.5 - django-cors-headers==4.0.0 + django-cors-headers==4.1.0 user-agents django-user-agents xmljson From 89ba02a54237cc995ff40d02ada4fd2450a4bdc5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 12:02:41 +0200 Subject: [PATCH 023/330] Bump django-select2 from 8.1.1 to 8.1.2 (#11127) * Bump django-select2 from 8.1.1 to 8.1.2 Bumps [django-select2](https://github.com/codingjoe/django-select2) from 8.1.1 to 8.1.2. - [Release notes](https://github.com/codingjoe/django-select2/releases) - [Commits](https://github.com/codingjoe/django-select2/compare/8.1.1...8.1.2) --- updated-dependencies: - dependency-name: django-select2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6d3fa4788d7..46958d03b1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -67,7 +67,7 @@ numpy==1.24.* # Django Apps dj-database-url==2.0.0 dj-pagination==2.5.0 -django-select2==8.1.1 +django-select2==8.1.2 django-floppyforms<1.10.0 django-forms-bootstrap<=3.1.0 django-autocomplete-light==3.5.1 diff --git a/setup.cfg b/setup.cfg index 7d989d43bf5..4d72c0dc104 100644 --- a/setup.cfg +++ b/setup.cfg @@ -93,7 +93,7 @@ install_requires = # Django Apps dj-database-url==2.0.0 dj-pagination==2.5.0 - django-select2==8.1.1 + django-select2==8.1.2 django-floppyforms<1.10.0 django-forms-bootstrap<=3.1.0 django-autocomplete-light==3.5.1 From cfacef8a25d97ad3028538ff907e908eff548145 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 12:05:09 +0200 Subject: [PATCH 024/330] Bump drf-spectacular from 0.26.2 to 0.26.3 (#11196) * Bump drf-spectacular from 0.26.2 to 0.26.3 Bumps [drf-spectacular](https://github.com/tfranzel/drf-spectacular) from 0.26.2 to 0.26.3. - [Release notes](https://github.com/tfranzel/drf-spectacular/releases) - [Changelog](https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tfranzel/drf-spectacular/compare/0.26.2...0.26.3) --- updated-dependencies: - dependency-name: drf-spectacular dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 46958d03b1c..3221c3d01f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -80,7 +80,7 @@ djangorestframework-gis==1.0 djangorestframework-guardian==0.3.0 drf-extensions==0.7.1 drf-writable-nested==0.7.0 -drf-spectacular==0.26.2 +drf-spectacular==0.26.3 dynamic-rest==2.1.2 Markdown==3.4.3 diff --git a/setup.cfg b/setup.cfg index 4d72c0dc104..3eaab96ce80 100644 --- a/setup.cfg +++ b/setup.cfg @@ -106,7 +106,7 @@ install_requires = djangorestframework-guardian==0.3.0 drf-extensions==0.7.1 drf-writable-nested==0.7.0 - drf-spectacular==0.26.2 + drf-spectacular==0.26.3 dynamic-rest==2.1.2 Markdown==3.4.3 From 1a09fd032d5d5198000218cc9c70ae48ad1d6884 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 12:07:39 +0200 Subject: [PATCH 025/330] Bump coverage from 7.2.5 to 7.2.7 (#11144) * Bump coverage from 7.2.5 to 7.2.7 Bumps [coverage](https://github.com/nedbat/coveragepy) from 7.2.5 to 7.2.7. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.2.5...7.2.7) --- updated-dependencies: - dependency-name: coverage dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3221c3d01f2..57faa8cb5ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -150,7 +150,7 @@ docker==6.1.2 invoke==2.1.2 # tests -coverage==7.2.5 +coverage==7.2.7 requests-toolbelt==1.0.0 flake8==6.0.0 black==23.3.0 diff --git a/setup.cfg b/setup.cfg index 3eaab96ce80..4e676f6761e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -175,7 +175,7 @@ install_requires = invoke==2.1.2 # tests - coverage==7.2.5 + coverage==7.2.7 requests-toolbelt==1.0.0 flake8==6.0.0 black==23.3.0 From 6000205c41a631b6a85582b6f3797b99b58c021f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 12:08:05 +0200 Subject: [PATCH 026/330] Bump docker from 6.1.2 to 6.1.3 (#11142) * Bump docker from 6.1.2 to 6.1.3 Bumps [docker](https://github.com/docker/docker-py) from 6.1.2 to 6.1.3. - [Release notes](https://github.com/docker/docker-py/releases) - [Commits](https://github.com/docker/docker-py/compare/6.1.2...6.1.3) --- updated-dependencies: - dependency-name: docker dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 57faa8cb5ea..b2764ed97a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -146,7 +146,7 @@ pycountry uWSGI==2.0.21 gunicorn==20.1.0 ipython==8.13.2 -docker==6.1.2 +docker==6.1.3 invoke==2.1.2 # tests diff --git a/setup.cfg b/setup.cfg index 4e676f6761e..6479b690cb6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -171,7 +171,7 @@ install_requires = uWSGI==2.0.21 gunicorn==20.1.0 ipython==8.13.2 - docker==6.1.2 + docker==6.1.3 invoke==2.1.2 # tests From 0e19108f5d3428545431b16a8079658cf7caebfc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 12:29:14 +0200 Subject: [PATCH 027/330] Bump pathvalidate from 2.5.2 to 3.0.0 (#11125) * Bump pathvalidate from 2.5.2 to 3.0.0 Bumps [pathvalidate](https://github.com/thombashi/pathvalidate) from 2.5.2 to 3.0.0. - [Release notes](https://github.com/thombashi/pathvalidate/releases) - [Commits](https://github.com/thombashi/pathvalidate/compare/v2.5.2...v3.0.0) --- updated-dependencies: - dependency-name: pathvalidate dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index b2764ed97a4..cc517bee3d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,7 @@ schema==0.7.5 rdflib==6.3.2 smart_open==6.3.0 PyMuPDF==1.22.3 -pathvalidate==2.5.2 +pathvalidate==3.0.0 # Django Apps django-allauth==0.54.0 diff --git a/setup.cfg b/setup.cfg index 6479b690cb6..af6dbcd1714 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,7 +52,7 @@ install_requires = rdflib==6.3.2 smart_open==6.3.0 PyMuPDF==1.22.3 - pathvalidate==2.5.2 + pathvalidate==3.0.0 # Django Apps django-allauth==0.54.0 From d6069c2c9a46c19204660146d6edde971b134d06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 12:30:13 +0200 Subject: [PATCH 028/330] Bump mistune from 2.0.5 to 3.0.1 (#11158) * Bump mistune from 2.0.5 to 3.0.1 Bumps [mistune](https://github.com/lepture/mistune) from 2.0.5 to 3.0.1. - [Release notes](https://github.com/lepture/mistune/releases) - [Changelog](https://github.com/lepture/mistune/blob/master/docs/changes.rst) - [Commits](https://github.com/lepture/mistune/compare/v2.0.5...v3.0.1) --- updated-dependencies: - dependency-name: mistune dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index cc517bee3d1..e4b9200b14b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -170,7 +170,7 @@ selenium-requests==2.0.3 webdriver_manager==3.8.6 # Security and audit -mistune==2.0.5 +mistune==3.0.1 wandb==0.15.3 protobuf==3.20.3 mako==1.2.4 diff --git a/setup.cfg b/setup.cfg index af6dbcd1714..f372baa9f5d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -195,7 +195,7 @@ install_requires = webdriver_manager==3.8.6 # Security and audit - mistune==2.0.5 + mistune==3.0.1 wandb==0.15.3 protobuf==3.20.3 mako==1.2.4 From 1be6ce462905a8a01cde686369683c4dbeefc712 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 12:31:17 +0200 Subject: [PATCH 029/330] Bump pyopenssl from 23.1.1 to 23.2.0 (#11141) * Bump pyopenssl from 23.1.1 to 23.2.0 Bumps [pyopenssl](https://github.com/pyca/pyopenssl) from 23.1.1 to 23.2.0. - [Changelog](https://github.com/pyca/pyopenssl/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/pyopenssl/compare/23.1.1...23.2.0) --- updated-dependencies: - dependency-name: pyopenssl dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index e4b9200b14b..9f77d2541a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -49,7 +49,7 @@ django-uuid-upload-path==1.0.0 django-widget-tweaks==1.4.12 django-sequences==2.7 oauthlib==3.2.2 -pyopenssl==23.1.1 +pyopenssl==23.2.0 pyjwt==2.7.0 # geopython dependencies diff --git a/setup.cfg b/setup.cfg index f372baa9f5d..4bebe0a333e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -75,7 +75,7 @@ install_requires = django-widget-tweaks==1.4.12 django-sequences==2.7 oauthlib==3.2.2 - pyopenssl==23.1.1 + pyopenssl==23.2.0 pyjwt==2.7.0 # geopython dependencies From 46feab2272fa050e03573ca7a0b18f2abe654d82 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 12:32:01 +0200 Subject: [PATCH 030/330] Update numpy requirement from ==1.24.* to ==1.25.* (#11176) * Update numpy requirement from ==1.24.* to ==1.25.* Updates the requirements on [numpy](https://github.com/numpy/numpy) to permit the latest version. - [Release notes](https://github.com/numpy/numpy/releases) - [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst) - [Commits](https://github.com/numpy/numpy/compare/v1.24.0rc1...v1.25.0) --- updated-dependencies: - dependency-name: numpy dependency-type: direct:production ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9f77d2541a9..6254a0fd423 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,7 +60,7 @@ SQLAlchemy==2.0.15 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 -numpy==1.24.* +numpy==1.25.* # # Apps with packages provided in GeoNode's PPA on Launchpad. diff --git a/setup.cfg b/setup.cfg index 4bebe0a333e..d53cee5d1e4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -86,7 +86,7 @@ install_requires = Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 - numpy==1.24.* + numpy==1.25.* # # Apps with packages provided in GeoNode's PPA on Launchpad. From c271e5049d30f5b9ec703b0bc01a559d6f6cc920 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 12:36:21 +0200 Subject: [PATCH 031/330] Update pyproj requirement from <3.6.0 to <3.7.0 (#11179) * Update pyproj requirement from <3.6.0 to <3.7.0 Updates the requirements on [pyproj](https://github.com/pyproj4/pyproj) to permit the latest version. - [Release notes](https://github.com/pyproj4/pyproj/releases) - [Changelog](https://github.com/pyproj4/pyproj/blob/main/docs/history.rst) - [Commits](https://github.com/pyproj4/pyproj/compare/v1.9.4rel...3.6.0) --- updated-dependencies: - dependency-name: pyproj dependency-type: direct:production ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6254a0fd423..6aa39029b90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,7 +53,7 @@ pyopenssl==23.2.0 pyjwt==2.7.0 # geopython dependencies -pyproj<3.6.0 +pyproj<3.7.0 OWSLib==0.29.2 pycsw==2.6.1 SQLAlchemy==2.0.15 # required by PyCSW diff --git a/setup.cfg b/setup.cfg index d53cee5d1e4..d6d3874483b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -79,7 +79,7 @@ install_requires = pyjwt==2.7.0 # geopython dependencies - pyproj<3.6.0 + pyproj<3.7.0 OWSLib==0.29.2 pycsw==2.6.1 SQLAlchemy==2.0.15 # required by PyCSW From 4f55b1c2d201f070810f52e09ccaed1e51491a43 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 13:50:12 +0200 Subject: [PATCH 032/330] Bump ipython from 8.13.2 to 8.14.0 (#11146) * Bump ipython from 8.13.2 to 8.14.0 Bumps [ipython](https://github.com/ipython/ipython) from 8.13.2 to 8.14.0. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/compare/8.13.2...8.14.0) --- updated-dependencies: - dependency-name: ipython dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6aa39029b90..68249b4fedc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -145,7 +145,7 @@ pycountry # production uWSGI==2.0.21 gunicorn==20.1.0 -ipython==8.13.2 +ipython==8.14.0 docker==6.1.3 invoke==2.1.2 diff --git a/setup.cfg b/setup.cfg index d6d3874483b..cfa7855b47f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -170,7 +170,7 @@ install_requires = # production uWSGI==2.0.21 gunicorn==20.1.0 - ipython==8.13.2 + ipython==8.14.0 docker==6.1.3 invoke==2.1.2 From 0e56e76c0afbba0e607f4fe1ff841e120670ccca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 13:51:33 +0200 Subject: [PATCH 033/330] Bump pytest from 7.3.1 to 7.4.0 (#11195) * Bump pytest from 7.3.1 to 7.4.0 Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.3.1 to 7.4.0. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/7.3.1...7.4.0) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 68249b4fedc..50856965940 100644 --- a/requirements.txt +++ b/requirements.txt @@ -154,7 +154,7 @@ coverage==7.2.7 requests-toolbelt==1.0.0 flake8==6.0.0 black==23.3.0 -pytest==7.3.1 +pytest==7.4.0 pytest-bdd==6.1.1 splinter==0.19.0 pytest-splinter==3.3.2 diff --git a/setup.cfg b/setup.cfg index cfa7855b47f..132df60ac06 100644 --- a/setup.cfg +++ b/setup.cfg @@ -179,7 +179,7 @@ install_requires = requests-toolbelt==1.0.0 flake8==6.0.0 black==23.3.0 - pytest==7.3.1 + pytest==7.4.0 pytest-bdd==6.1.1 splinter==0.19.0 pytest-splinter==3.3.2 From 1327ca71fd8e20e7ac493e1beddadd6efdfe0bcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 13:51:59 +0200 Subject: [PATCH 034/330] Bump whitenoise from 6.4.0 to 6.5.0 (#11181) * Bump whitenoise from 6.4.0 to 6.5.0 Bumps [whitenoise](https://github.com/evansd/whitenoise) from 6.4.0 to 6.5.0. - [Changelog](https://github.com/evansd/whitenoise/blob/main/docs/changelog.rst) - [Commits](https://github.com/evansd/whitenoise/compare/6.4.0...6.5.0) --- updated-dependencies: - dependency-name: whitenoise dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 50856965940..d5f84d2dfe3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -117,7 +117,7 @@ boto3==1.26.137 # Django Caches python-memcached<=1.59 -whitenoise==6.4.0 +whitenoise==6.5.0 Brotli==1.0.9 # Contribs diff --git a/setup.cfg b/setup.cfg index 132df60ac06..7ef6cdcd078 100644 --- a/setup.cfg +++ b/setup.cfg @@ -142,7 +142,7 @@ install_requires = # Django Caches python-memcached<=1.59 - whitenoise==6.4.0 + whitenoise==6.5.0 Brotli==1.0.9 # Contribs From 73130a5e2dc623cb9f1bdb5042bed1dccc0fe8ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 13:52:19 +0200 Subject: [PATCH 035/330] Bump sqlalchemy from 2.0.15 to 2.0.17 (#11193) * Bump sqlalchemy from 2.0.15 to 2.0.17 Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.15 to 2.0.17. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index d5f84d2dfe3..f875b3b64ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -56,7 +56,7 @@ pyjwt==2.7.0 pyproj<3.7.0 OWSLib==0.29.2 pycsw==2.6.1 -SQLAlchemy==2.0.15 # required by PyCSW +SQLAlchemy==2.0.17 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 diff --git a/setup.cfg b/setup.cfg index 7ef6cdcd078..535c416998e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -82,7 +82,7 @@ install_requires = pyproj<3.7.0 OWSLib==0.29.2 pycsw==2.6.1 - SQLAlchemy==2.0.15 # required by PyCSW + SQLAlchemy==2.0.17 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 From 0808fbfa74c35cee3fc54b9fa142fec40e2b7992 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 13:53:51 +0200 Subject: [PATCH 036/330] Bump pymupdf from 1.22.3 to 1.22.5 (#11197) * Bump pymupdf from 1.22.3 to 1.22.5 Bumps [pymupdf](https://github.com/pymupdf/pymupdf) from 1.22.3 to 1.22.5. - [Release notes](https://github.com/pymupdf/pymupdf/releases) - [Changelog](https://github.com/pymupdf/PyMuPDF/blob/main/changes.txt) - [Commits](https://github.com/pymupdf/pymupdf/compare/1.22.3...1.22.5) --- updated-dependencies: - dependency-name: pymupdf dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index f875b3b64ca..b6c77a2f555 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 smart_open==6.3.0 -PyMuPDF==1.22.3 +PyMuPDF==1.22.5 pathvalidate==3.0.0 # Django Apps diff --git a/setup.cfg b/setup.cfg index 535c416998e..38835e416d2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -51,7 +51,7 @@ install_requires = schema==0.7.5 rdflib==6.3.2 smart_open==6.3.0 - PyMuPDF==1.22.3 + PyMuPDF==1.22.5 pathvalidate==3.0.0 # Django Apps From a12ce8f8a308591b2c6d1f89ea7a30aca8697080 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 13:59:39 +0200 Subject: [PATCH 037/330] Update setuptools requirement from <67.9.0,>=59.1.1 to >=59.1.1,<68.1.0 (#11192) * Update setuptools requirement from <67.9.0,>=59.1.1 to >=59.1.1,<68.1.0 Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v65.5.1...v68.0.0) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index b6c77a2f555..f40e35e6621 100644 --- a/requirements.txt +++ b/requirements.txt @@ -159,7 +159,7 @@ pytest-bdd==6.1.1 splinter==0.19.0 pytest-splinter==3.3.2 pytest-django==4.5.2 -setuptools>=59.1.1,<67.9.0 +setuptools>=59.1.1,<68.1.0 pip==23.1.2 Twisted==22.10.0 pixelmatch==0.3.0 diff --git a/setup.cfg b/setup.cfg index 38835e416d2..044abd669fa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -184,7 +184,7 @@ install_requires = splinter==0.19.0 pytest-splinter==3.3.2 pytest-django==4.5.2 - setuptools>=59.1.1,<67.9.0 + setuptools>=59.1.1,<68.1.0 pip==23.1.2 Twisted==22.10.0 pixelmatch==0.3.0 From 200d62edfa7790a00cdabf3b07d72d3d7e6169bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 14:00:00 +0200 Subject: [PATCH 038/330] Bump dropbox from 11.36.0 to 11.36.2 (#11178) * Bump dropbox from 11.36.0 to 11.36.2 Bumps [dropbox](https://github.com/dropbox/dropbox-sdk-python) from 11.36.0 to 11.36.2. - [Release notes](https://github.com/dropbox/dropbox-sdk-python/releases) - [Commits](https://github.com/dropbox/dropbox-sdk-python/compare/v11.36.0...v11.36.2) --- updated-dependencies: - dependency-name: dropbox dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index f40e35e6621..21065525d4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -110,7 +110,7 @@ django-bootstrap3-datetimepicker-2==2.8.3 # storage manager dependencies django-storages==1.13.2 -dropbox==11.36.0 +dropbox==11.36.2 google-cloud-storage==2.9.0 google-cloud-core==2.3.2 boto3==1.26.137 diff --git a/setup.cfg b/setup.cfg index 044abd669fa..b775835b8cc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -135,7 +135,7 @@ install_requires = # storage manager dependencies django-storages==1.13.2 - dropbox==11.36.0 + dropbox==11.36.2 google-cloud-storage==2.9.0 google-cloud-core==2.3.2 boto3==1.26.137 From 57525248d4e8949d30be28c7ccf5020ea7c85f50 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 14:00:27 +0200 Subject: [PATCH 039/330] Bump boto3 from 1.26.137 to 1.26.160 (#11194) * Bump boto3 from 1.26.137 to 1.26.160 Bumps [boto3](https://github.com/boto/boto3) from 1.26.137 to 1.26.160. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.137...1.26.160) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 21065525d4f..a4a51cce085 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.9.0 google-cloud-core==2.3.2 -boto3==1.26.137 +boto3==1.26.160 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index b775835b8cc..c9385198996 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.9.0 google-cloud-core==2.3.2 - boto3==1.26.137 + boto3==1.26.160 # Django Caches python-memcached<=1.59 From d5334dfedd0b17bcaedc31e534173f4756978e87 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 14:48:30 +0200 Subject: [PATCH 040/330] Bump celery from 5.2.7 to 5.3.1 (#11180) * Bump celery from 5.2.7 to 5.3.1 Bumps [celery](https://github.com/celery/celery) from 5.2.7 to 5.3.1. - [Release notes](https://github.com/celery/celery/releases) - [Changelog](https://github.com/celery/celery/blob/main/Changelog.rst) - [Commits](https://github.com/celery/celery/compare/v5.2.7...v5.3.1) --- updated-dependencies: - dependency-name: celery dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" * - Align "setup.cfg" to "requirements.txt" * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 4 ++-- setup.cfg | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index a4a51cce085..a35d6e10545 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,8 +14,8 @@ urllib3==1.26.15 Paver==1.3.4 python-slugify==8.0.1 decorator==5.1.1 -celery==5.2.7 -kombu==5.2.4 +celery==5.3.1 +kombu==5.3.1 vine==5.0.0 tqdm==4.65.0 Deprecated==1.2.14 diff --git a/setup.cfg b/setup.cfg index c9385198996..682ab135d92 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,8 +40,8 @@ install_requires = Paver==1.3.4 python-slugify==8.0.1 decorator==5.1.1 - celery==5.2.7 - kombu==5.2.4 + celery==5.3.1 + kombu==5.3.1 vine==5.0.0 tqdm==4.65.0 Deprecated==1.2.14 From ea7fd7cbd4418111a7ccfab0656c404ad385a36c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 14:48:50 +0200 Subject: [PATCH 041/330] Bump invoke from 2.1.2 to 2.1.3 (#11174) * Bump invoke from 2.1.2 to 2.1.3 Bumps [invoke](https://github.com/pyinvoke/invoke) from 2.1.2 to 2.1.3. - [Commits](https://github.com/pyinvoke/invoke/compare/2.1.2...2.1.3) --- updated-dependencies: - dependency-name: invoke dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index a35d6e10545..179ad47a106 100644 --- a/requirements.txt +++ b/requirements.txt @@ -147,7 +147,7 @@ uWSGI==2.0.21 gunicorn==20.1.0 ipython==8.14.0 docker==6.1.3 -invoke==2.1.2 +invoke==2.1.3 # tests coverage==7.2.7 diff --git a/setup.cfg b/setup.cfg index 682ab135d92..647745361fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -172,7 +172,7 @@ install_requires = gunicorn==20.1.0 ipython==8.14.0 docker==6.1.3 - invoke==2.1.2 + invoke==2.1.3 # tests coverage==7.2.7 From 7954bf13f11d84cd4c2a6c5b11d57b97d37c850e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 14:49:09 +0200 Subject: [PATCH 042/330] Bump wandb from 0.15.3 to 0.15.4 (#11163) * Bump wandb from 0.15.3 to 0.15.4 Bumps [wandb](https://github.com/wandb/wandb) from 0.15.3 to 0.15.4. - [Release notes](https://github.com/wandb/wandb/releases) - [Changelog](https://github.com/wandb/wandb/blob/v0.15.4/CHANGELOG.md) - [Commits](https://github.com/wandb/wandb/compare/v0.15.3...v0.15.4) --- updated-dependencies: - dependency-name: wandb dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 179ad47a106..9f96ca8f1c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -171,7 +171,7 @@ webdriver_manager==3.8.6 # Security and audit mistune==3.0.1 -wandb==0.15.3 +wandb==0.15.4 protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/setup.cfg b/setup.cfg index 647745361fd..c140c4bc986 100644 --- a/setup.cfg +++ b/setup.cfg @@ -196,7 +196,7 @@ install_requires = # Security and audit mistune==3.0.1 - wandb==0.15.3 + wandb==0.15.4 protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability From 156d58bab6d9bd66b66796cb6f3e0b5a01969d6f Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Mon, 26 Jun 2023 14:50:17 +0200 Subject: [PATCH 043/330] fix: requirements.txt to reduce vulnerabilities (#11138) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-5663682 Co-authored-by: snyk-bot --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 9f96ca8f1c8..cd162f8c08a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -176,3 +176,4 @@ protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability jwcrypto>=1.4 # not directly required, pinned by Snyk to avoid a vulnerability +cryptography>=41.0.0 # not directly required, pinned by Snyk to avoid a vulnerability From e103d06a391f250218cfbdc35bf6311a2b6e5204 Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Mon, 26 Jun 2023 15:22:12 +0200 Subject: [PATCH 044/330] Direct download on preview on dataset list is not working #11156 (#11165) * FIx download_url for WFS url * FIx download_url for WFS url * FIx download_url for WFS url, change settings * FIx download_url for WFS url, change settings * Fix tests * [Fixes #11156] Approach change and code rollback * [Fixes #11156] Approach change and code rollback * [Fixes #11156] Approach change and code rollback * [Fixes #11156] Change delete query for original links --------- Co-authored-by: Giovanni Allegri --- geonode/layers/tests.py | 1 - geonode/utils.py | 14 +++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/geonode/layers/tests.py b/geonode/layers/tests.py index a24fb52ee01..fbe14c340ba 100644 --- a/geonode/layers/tests.py +++ b/geonode/layers/tests.py @@ -373,7 +373,6 @@ def test_dataset_links(self): links = Link.objects.filter(resource=lyr.resourcebase_ptr, link_type="image") self.assertIsNotNone(links) - # get and update original link to external Link.objects.filter(resource=lyr.resourcebase_ptr, link_type="original").update( url="http://google.com/test" ) diff --git a/geonode/utils.py b/geonode/utils.py index 3c83c824db8..6a2cee2708b 100755 --- a/geonode/utils.py +++ b/geonode/utils.py @@ -1412,6 +1412,8 @@ def set_resource_default_links(instance, layer, prune=False, **kwargs): from geonode.base.models import Link from django.urls import reverse from django.utils.translation import ugettext + from geonode.layers.models import Dataset + from geonode.documents.models import Document # Prune old links if prune: @@ -1481,9 +1483,15 @@ def set_resource_default_links(instance, layer, prune=False, **kwargs): # Create Raw Data download link if settings.DISPLAY_ORIGINAL_DATASET_LINK: logger.debug(" -- Resource Links[Create Raw Data download link]...") - download_url = urljoin(settings.SITEURL, reverse("download", args=[instance.id])) - while Link.objects.filter(resource=instance.resourcebase_ptr, url=download_url).count() > 1: - Link.objects.filter(resource=instance.resourcebase_ptr, url=download_url).first().delete() + if isinstance(instance, Dataset): + download_url = build_absolute_uri(reverse("dataset_download", args=(instance.alternate,))) + elif isinstance(instance, Document): + download_url = build_absolute_uri(reverse("document_download", args=(instance.id,))) + else: + download_url = None + + while Link.objects.filter(resource=instance.resourcebase_ptr, link_type="original").exists(): + Link.objects.filter(resource=instance.resourcebase_ptr, link_type="original").delete() Link.objects.update_or_create( resource=instance.resourcebase_ptr, url=download_url, From 5811a92469d9347b5f89ed77bbb855a5fca74f12 Mon Sep 17 00:00:00 2001 From: etj Date: Wed, 24 May 2023 16:48:58 +0200 Subject: [PATCH 045/330] #10995 Faceting - fixes --- geonode/facets/providers/category.py | 6 ++++-- geonode/facets/providers/users.py | 1 - geonode/facets/tests.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/geonode/facets/providers/category.py b/geonode/facets/providers/category.py index 3db78eb4d03..aac8b1c7cfe 100644 --- a/geonode/facets/providers/category.py +++ b/geonode/facets/providers/category.py @@ -38,7 +38,7 @@ def name(self) -> str: def get_info(self, lang="en") -> dict: return { "name": self.name, - "key": "filter{category__identifier}", + "key": "filter{category.identifier}", "label": "Category", "type": FACET_TYPE_CATEGORY, "hierarchical": False, @@ -55,7 +55,9 @@ def get_facet_items( ) -> (int, list): logger.debug("Retrieving facets for %s", self.name) - q = queryset.values("category__identifier", "category__gn_description", "category__fa_class") + q = queryset.values("category__identifier", "category__gn_description", "category__fa_class").filter( + category__isnull=False + ) if topic_contains: q = q.filter(category__gn_description=topic_contains) q = q.annotate(count=Count("owner")).order_by("-count") diff --git a/geonode/facets/providers/users.py b/geonode/facets/providers/users.py index 8a1e5c3effe..4358a3292a1 100644 --- a/geonode/facets/providers/users.py +++ b/geonode/facets/providers/users.py @@ -70,7 +70,6 @@ def get_facet_items( { "key": r["owner"], "label": r["owner__username"], - "localized_label": r["owner__username"], "count": r["count"], } for r in q[start:end] diff --git a/geonode/facets/tests.py b/geonode/facets/tests.py index 67fcf27e11e..6c7fb45780e 100644 --- a/geonode/facets/tests.py +++ b/geonode/facets/tests.py @@ -193,7 +193,7 @@ def test_facets_rich(self): { "name": "category", "topics": { - "total": 1, + "total": 0, }, }, { From b33f8697d6e73b18e86fcd1a2b5d251c197f9a1a Mon Sep 17 00:00:00 2001 From: etj Date: Thu, 25 May 2023 13:50:56 +0200 Subject: [PATCH 046/330] #10995 Faceting - fix thesauri localization --- geonode/facets/providers/thesaurus.py | 18 ++++++++++++------ geonode/facets/tests.py | 24 +++++++++++++++++++++--- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/geonode/facets/providers/thesaurus.py b/geonode/facets/providers/thesaurus.py index 5674635b6bd..8961aef5467 100644 --- a/geonode/facets/providers/thesaurus.py +++ b/geonode/facets/providers/thesaurus.py @@ -19,8 +19,9 @@ import logging -from django.db.models import Count +from django.db.models import Count, OuterRef, Subquery +from geonode.base.models import ThesaurusKeywordLabel from geonode.facets.models import FacetProvider, DEFAULT_FACET_PAGE_SIZE, FACET_TYPE_THESAURUS logger = logging.getLogger(__name__) @@ -65,7 +66,6 @@ def get_facet_items( filter = { "tkeywords__thesaurus__identifier": self._name, - "tkeywords__keyword__lang": lang, } if topic_contains: @@ -73,22 +73,28 @@ def get_facet_items( q = ( queryset.filter(**filter) - .values("tkeywords", "tkeywords__keyword__label", "tkeywords__alt_label") + .values("tkeywords", "tkeywords__alt_label") .annotate(count=Count("tkeywords")) + .annotate( + localized_label=Subquery( + ThesaurusKeywordLabel.objects.filter(keyword=OuterRef("tkeywords"), lang=lang).values("label") + ) + ) .order_by("-count") ) + logger.debug(" ---> %s\n\n", q.query) + cnt = q.count() logger.info("Found %d facets for %s", cnt, self._name) - logger.debug(" ---> %s\n\n", q.query) logger.debug(" ---> %r\n\n", q.all()) topics = [ { "key": r["tkeywords"], - "label": r["tkeywords__keyword__label"] or r["tkeywords__alt_label"], - "is_localized": r["tkeywords__keyword__label"] is not None, + "label": r["localized_label"] or r["tkeywords__alt_label"], + "is_localized": r["localized_label"] is not None, "count": r["count"], } for r in q[start:end].all() diff --git a/geonode/facets/tests.py b/geonode/facets/tests.py index 6c7fb45780e..49f199d2af7 100644 --- a/geonode/facets/tests.py +++ b/geonode/facets/tests.py @@ -76,7 +76,7 @@ def _create_thesauri(cls): ThesaurusLabel.objects.create(thesaurus=t, lang=tl, label=f"TLabel {tn} {tl}") for tkn in range(10): - tk = ThesaurusKeyword.objects.create(thesaurus=t, alt_label=f"alt_tkn{tkn}_t{tn}") + tk = ThesaurusKeyword.objects.create(thesaurus=t, alt_label=f"T{tn}_K{tkn}_ALT") cls.thesauri_k[f"{tn}_{tkn}"] = tk for tkl in ( "en", @@ -261,8 +261,26 @@ def test_facets_rich(self): def test_bad_lang(self): # for thesauri, make sure that by requesting a non-existent language the faceting is still working, # using the default labels - # TODO impl+test - pass + + # run the request with a valid language + req = self.rf.get(reverse("get_facet", args=["t_0"]), data={"lang": "en"}) + res: JsonResponse = views.get_facet(req, "t_0") + obj = json.loads(res.content) + + self.assertEqual(2, obj["topics"]["total"]) + self.assertEqual(10, obj["topics"]["items"][0]["count"]) + self.assertEqual("T0_K0_en", obj["topics"]["items"][0]["label"]) + self.assertTrue(obj["topics"]["items"][0]["is_localized"]) + + # run the request with an INVALID language + req = self.rf.get(reverse("get_facet", args=["t_0"]), data={"lang": "ZZ"}) + res: JsonResponse = views.get_facet(req, "t_0") + obj = json.loads(res.content) + + self.assertEqual(2, obj["topics"]["total"]) + self.assertEqual(10, obj["topics"]["items"][0]["count"]) # make sure the count is still there + self.assertEqual("T0_K0_ALT", obj["topics"]["items"][0]["label"]) # check for the alternate label + self.assertFalse(obj["topics"]["items"][0]["is_localized"]) # check for the localization flag def test_user_auth(self): # make sure the user authorization pre-filters the visible resources From 8838b8e9c69302f2c1b9df12a34cebbed29eae5f Mon Sep 17 00:00:00 2001 From: etj Date: Fri, 23 Jun 2023 19:10:46 +0200 Subject: [PATCH 047/330] [Fixes #11105] Facet topics --- geonode/facets/models.py | 15 +++++++++++++++ geonode/facets/providers/category.py | 16 ++++++++++++++++ geonode/facets/providers/region.py | 15 +++++++++++++++ geonode/facets/providers/thesaurus.py | 25 ++++++++++++++++++++++++- geonode/facets/providers/users.py | 15 +++++++++++++++ geonode/facets/tests.py | 12 ++++++++++++ geonode/facets/urls.py | 1 + geonode/facets/views.py | 21 ++++++++++++++++++++- 8 files changed, 118 insertions(+), 2 deletions(-) diff --git a/geonode/facets/models.py b/geonode/facets/models.py index d271c188c9d..07b1b4c7643 100644 --- a/geonode/facets/models.py +++ b/geonode/facets/models.py @@ -28,6 +28,7 @@ FACET_TYPE_USER = "user" FACET_TYPE_THESAURUS = "thesaurus" FACET_TYPE_CATEGORY = "category" +FACET_TYPE_RESOURCETYPE = "resourcetype" logger = logging.getLogger(__name__) @@ -86,6 +87,20 @@ def get_facet_items( """ pass + def get_topics(self, keys: list, lang="en", **kwargs) -> list: + """ + Return the topics with the requested ids as a list + - list, topic records. A topic record is a dict having these keys: + - key: the key of the items that should be used for filtering + - label: a generic label for the item; the client should try and localize it whenever possible + - localized_label: a localized label for the item + - other facet specific keys + :param keys: the list of the keys of the topics, as returned by the get_facet_items() method + :param lang: the preferred language for the labels + :return: list of items + """ + pass + @classmethod def register(cls, registry, **kwargs) -> None: """ diff --git a/geonode/facets/providers/category.py b/geonode/facets/providers/category.py index aac8b1c7cfe..e0f24e29044 100644 --- a/geonode/facets/providers/category.py +++ b/geonode/facets/providers/category.py @@ -21,6 +21,7 @@ from django.db.models import Count +from geonode.base.models import TopicCategory from geonode.facets.models import FacetProvider, DEFAULT_FACET_PAGE_SIZE, FACET_TYPE_CATEGORY logger = logging.getLogger(__name__) @@ -80,6 +81,21 @@ def get_facet_items( return cnt, topics + def get_topics(self, keys: list, lang="en", **kwargs) -> list: + q = TopicCategory.objects.filter(identifier__in=keys) + + logger.debug(" ---> %s\n\n", q.query) + logger.debug(" ---> %r\n\n", q.all()) + + return [ + { + "key": r.identifier, + "label": r.gn_description, + "fa_class": r.fa_class, + } + for r in q.all() + ] + @classmethod def register(cls, registry, **kwargs) -> None: registry.register_facet_provider(CategoryFacetProvider()) diff --git a/geonode/facets/providers/region.py b/geonode/facets/providers/region.py index b0cd5d45ce8..a0df25b2ea6 100644 --- a/geonode/facets/providers/region.py +++ b/geonode/facets/providers/region.py @@ -21,6 +21,7 @@ from django.db.models import Count +from geonode.base.models import Region from geonode.facets.models import FacetProvider, DEFAULT_FACET_PAGE_SIZE, FACET_TYPE_PLACE logger = logging.getLogger(__name__) @@ -77,6 +78,20 @@ def get_facet_items( return cnt, topics + def get_topics(self, keys: list, lang="en", **kwargs) -> list: + q = Region.objects.filter(code__in=keys).values("code", "name") + + logger.debug(" ---> %s\n\n", q.query) + logger.debug(" ---> %r\n\n", q.all()) + + return [ + { + "key": r["code"], + "label": r["name"], + } + for r in q.all() + ] + @classmethod def register(cls, registry, **kwargs) -> None: registry.register_facet_provider(RegionFacetProvider()) diff --git a/geonode/facets/providers/thesaurus.py b/geonode/facets/providers/thesaurus.py index 8961aef5467..d9897f6139d 100644 --- a/geonode/facets/providers/thesaurus.py +++ b/geonode/facets/providers/thesaurus.py @@ -21,7 +21,7 @@ from django.db.models import Count, OuterRef, Subquery -from geonode.base.models import ThesaurusKeywordLabel +from geonode.base.models import ThesaurusKeyword, ThesaurusKeywordLabel from geonode.facets.models import FacetProvider, DEFAULT_FACET_PAGE_SIZE, FACET_TYPE_THESAURUS logger = logging.getLogger(__name__) @@ -102,6 +102,29 @@ def get_facet_items( return cnt, topics + def get_topics(self, keys: list, lang="en", **kwargs) -> list: + q = ( + ThesaurusKeyword.objects.filter(id__in=keys) + .values("id", "alt_label") + .annotate( + localized_label=Subquery( + ThesaurusKeywordLabel.objects.filter(keyword=OuterRef("id"), lang=lang).values("label") + ) + ) + ) + + logger.debug(" ---> %s\n\n", q.query) + logger.debug(" ---> %r\n\n", q.all()) + + return [ + { + "key": r["id"], + "label": r["localized_label"] or r["alt_label"], + "is_localized": r["localized_label"] is not None, + } + for r in q.all() + ] + @classmethod def register(cls, registry, **kwargs) -> None: # registry.register_facet_provider(CategoryFacetProvider()) diff --git a/geonode/facets/providers/users.py b/geonode/facets/providers/users.py index 4358a3292a1..aedb2fc06f2 100644 --- a/geonode/facets/providers/users.py +++ b/geonode/facets/providers/users.py @@ -19,6 +19,7 @@ import logging +from django.contrib.auth import get_user_model from django.db.models import Count from geonode.facets.models import FacetProvider, DEFAULT_FACET_PAGE_SIZE, FACET_TYPE_USER @@ -77,6 +78,20 @@ def get_facet_items( return cnt, topics + def get_topics(self, keys: list, lang="en", **kwargs) -> list: + q = get_user_model().objects.filter(id__in=keys).values("id", "username") + + logger.debug(" ---> %s\n\n", q.query) + logger.debug(" ---> %r\n\n", q.all()) + + return [ + { + "key": r["id"], + "label": r["username"], + } + for r in q.all() + ] + @classmethod def register(cls, registry, **kwargs) -> None: registry.register_facet_provider(OwnerFacetProvider()) diff --git a/geonode/facets/tests.py b/geonode/facets/tests.py index 49f199d2af7..d582f4e2041 100644 --- a/geonode/facets/tests.py +++ b/geonode/facets/tests.py @@ -282,6 +282,18 @@ def test_bad_lang(self): self.assertEqual("T0_K0_ALT", obj["topics"]["items"][0]["label"]) # check for the alternate label self.assertFalse(obj["topics"]["items"][0]["is_localized"]) # check for the localization flag + def test_topics(self): + for facet, keys, exp in ( + ("t_0", [self.thesauri_k["0_0"].id, self.thesauri_k["0_1"].id, -999], 2), + ("category", ["C1", "C2", "nomatch"], 0), + ("owner", [self.user.id, -100], 1), + ("region", ["R0", "R1", "nomatch"], 2), + ): + req = self.rf.get(reverse("get_facet_topics", args=[facet]), data={"lang": "en", "key": keys}) + res: JsonResponse = views.get_facet_topics(req, facet) + obj = json.loads(res.content) + self.assertEqual(exp, len(obj["topics"]["items"]), f"Unexpected topic count {exp} for facet {facet}") + def test_user_auth(self): # make sure the user authorization pre-filters the visible resources # TODO test diff --git a/geonode/facets/urls.py b/geonode/facets/urls.py index 82aef71e751..42bf08de85b 100644 --- a/geonode/facets/urls.py +++ b/geonode/facets/urls.py @@ -23,4 +23,5 @@ urlpatterns = [ path("facets", views.list_facets, name="list_facets"), path("facets/", views.get_facet, name="get_facet"), + path("facets//topics", views.get_facet_topics, name="get_facet_topics"), ] diff --git a/geonode/facets/views.py b/geonode/facets/views.py index 31961bb3b6d..8aa18439f83 100644 --- a/geonode/facets/views.py +++ b/geonode/facets/views.py @@ -23,7 +23,7 @@ from rest_framework.authentication import SessionAuthentication, BasicAuthentication from rest_framework.decorators import api_view, authentication_classes -from django.http import HttpResponseNotFound, JsonResponse +from django.http import HttpResponseNotFound, JsonResponse, HttpResponseBadRequest from django.urls import reverse from django.conf import settings @@ -116,6 +116,25 @@ def get_facet(request, facet): return JsonResponse(info) +@api_view(["GET"]) +def get_facet_topics(request, facet): + logger.debug("get_facet_topics -> %r", facet) + + # retrieve provider for the requested facet + provider: FacetProvider = facet_registry.get_provider(facet) + if not provider: + return HttpResponseNotFound("Facet not found") + + # parse some query params + lang, lang_requested = _resolve_language(request) + keys = request.query_params.getlist("key") + if not keys: + return HttpResponseBadRequest("Missing key parameter") + + ret = {"topics": {"items": provider.get_topics(keys, lang=lang)}} + return JsonResponse(ret) + + def _get_topics( provider, queryset, From f3a490a2c2b38975351a887af720e7b7282b385e Mon Sep 17 00:00:00 2001 From: Emanuele Tajariol Date: Thu, 29 Jun 2023 16:29:28 +0200 Subject: [PATCH 048/330] [Fixes #11102] Faceting: prefiltering (#11205) --- geonode/base/api/filters.py | 15 ++++++++++----- geonode/facets/tests.py | 23 +++++++++++++++++++++++ geonode/facets/views.py | 13 +++++++++++-- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/geonode/base/api/filters.py b/geonode/base/api/filters.py index f8691e0178d..a2428bfdc65 100644 --- a/geonode/base/api/filters.py +++ b/geonode/base/api/filters.py @@ -56,11 +56,16 @@ class TKeywordsFilter(BaseFilterBackend): """ def filter_queryset(self, request, queryset, view): - return ( - self.filter_queryset_GROUP(request, queryset, view) - if "force_and" not in request.GET - else self.filter_queryset_AND(request, queryset, view) - ) + # we must make the GET mutable since in the filters, some queryparams are popped + request.GET._mutable = True + try: + return ( + self.filter_queryset_GROUP(request, queryset, view) + if "force_and" not in request.GET + else self.filter_queryset_AND(request, queryset, view) + ) + finally: + request.GET._mutable = False def filter_queryset_AND(self, request, queryset, view): """ diff --git a/geonode/facets/tests.py b/geonode/facets/tests.py index d582f4e2041..b5ae7f7541f 100644 --- a/geonode/facets/tests.py +++ b/geonode/facets/tests.py @@ -28,6 +28,8 @@ from django.urls import reverse from geonode.base.models import Thesaurus, ThesaurusLabel, ThesaurusKeyword, ThesaurusKeywordLabel, ResourceBase, Region +from geonode.facets.models import facet_registry +from geonode.facets.providers.region import RegionFacetProvider from geonode.tests.base import GeoNodeBaseTestSupport import geonode.facets.views as views @@ -294,6 +296,27 @@ def test_topics(self): obj = json.loads(res.content) self.assertEqual(exp, len(obj["topics"]["items"]), f"Unexpected topic count {exp} for facet {facet}") + def test_prefiltering(self): + reginfo = RegionFacetProvider().get_info() + t0info = facet_registry.get_provider("t_0").get_info() + t1info = facet_registry.get_provider("t_1").get_info() + + for facet, filters, totals, count0 in ( + ("t_0", {}, 2, 10), + ("t_0", {reginfo["key"]: "R0"}, 1, 1), + ("t_1", {}, 2, 10), + ("t_1", {reginfo["key"]: "R0"}, 1, 2), + ("t_1", {reginfo["key"]: "R1"}, 2, 3), + (reginfo["name"], {}, 2, 4), + (reginfo["name"], {t0info["key"]: self.thesauri_k["0_0"].id}, 2, 1), + (reginfo["name"], {t1info["key"]: self.thesauri_k["1_0"].id}, 2, 3), + ): + req = self.rf.get(reverse("get_facet", args=[facet]), data=filters) + res: JsonResponse = views.get_facet(req, facet) + obj = json.loads(res.content) + self.assertEqual(totals, obj["topics"]["total"], f"Bad totals for facet '{facet} and filter {filters}") + self.assertEqual(count0, obj["topics"]["items"][0]["count"], f"Bad count0 for facet '{facet}") + def test_user_auth(self): # make sure the user authorization pre-filters the visible resources # TODO test diff --git a/geonode/facets/views.py b/geonode/facets/views.py index 8aa18439f83..717eabfc57a 100644 --- a/geonode/facets/views.py +++ b/geonode/facets/views.py @@ -27,6 +27,8 @@ from django.urls import reverse from django.conf import settings + +from geonode.base.api.views import ResourceBaseViewSet from geonode.base.models import ResourceBase from geonode.facets.models import FacetProvider, DEFAULT_FACET_PAGE_SIZE, facet_registry from geonode.security.utils import get_visible_resources @@ -160,8 +162,15 @@ def _prefilter_topics(request): :return: a QuerySet on ResourceBase """ logger.debug("Filtering by user '%s'", request.user) - # return ResourceBase.objects - return get_visible_resources(ResourceBase.objects, request.user) + filters = {k: vlist for k, vlist in request.query_params.lists() if k.startswith("filter{")} + + if filters: + viewset = ResourceBaseViewSet(request=request, format_kwarg={}, kwargs=filters) + viewset.initial(request) + return get_visible_resources(queryset=viewset.filter_queryset(viewset.get_queryset()), user=request.user) + else: + # return ResourceBase.objects + return get_visible_resources(ResourceBase.objects, request.user) def _resolve_language(request) -> (str, bool): From ad68f2ed7c697345fd0fda644d0e77f49af158f0 Mon Sep 17 00:00:00 2001 From: Francisco Vicent Date: Mon, 3 Jul 2023 05:35:48 -0300 Subject: [PATCH 049/330] Fix #11154 - Wrong login URL in notification (#11155) * Fix #11154 - Wrong login URL in notification * fix single quote --- geonode/people/models.py | 1 + .../notifications/account_active/account_active_message.txt | 2 +- geonode/templates/pinax/notifications/account_active/full.txt | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/geonode/people/models.py b/geonode/people/models.py index 37d4c21631c..c07b6f5e81f 100644 --- a/geonode/people/models.py +++ b/geonode/people/models.py @@ -246,6 +246,7 @@ def _notify_account_activated(self): "site_name": current_site.name, "email": self.email, "inviter": self, + "LOGIN_URL": settings.LOGIN_URL, } email_template = "pinax/notifications/account_active/account_active" diff --git a/geonode/templates/pinax/notifications/account_active/account_active_message.txt b/geonode/templates/pinax/notifications/account_active/account_active_message.txt index 02237b540e6..f9ddbc0c091 100644 --- a/geonode/templates/pinax/notifications/account_active/account_active_message.txt +++ b/geonode/templates/pinax/notifications/account_active/account_active_message.txt @@ -1,3 +1,3 @@ {% load i18n %} {% trans "Your account has been approved and is now active." %} ({{ username }})
-{% trans "You can use the login form at" %}: http://{{ current_site.name }} +{% trans "You can use the login form at" %}: {{ LOGIN_URL }} diff --git a/geonode/templates/pinax/notifications/account_active/full.txt b/geonode/templates/pinax/notifications/account_active/full.txt index aac4b9cc507..8344ed13d4b 100644 --- a/geonode/templates/pinax/notifications/account_active/full.txt +++ b/geonode/templates/pinax/notifications/account_active/full.txt @@ -1,3 +1,3 @@ {% load i18n %} {% trans "Your account has been approved and is now active." %}
-{% trans "You can use the login form at" %}: http://{{ current_site }} +{% trans "You can use the login form at" %}: {{ LOGIN_URL }} From ec3868ac044e24959032a32dc3f2d53e0ff44fae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jul 2023 11:45:55 +0200 Subject: [PATCH 050/330] Bump google-cloud-storage from 2.9.0 to 2.10.0 (#11212) * Bump google-cloud-storage from 2.9.0 to 2.10.0 Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 2.9.0 to 2.10.0. - [Release notes](https://github.com/googleapis/python-storage/releases) - [Changelog](https://github.com/googleapis/python-storage/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/python-storage/compare/v2.9.0...v2.10.0) --- updated-dependencies: - dependency-name: google-cloud-storage dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index cd162f8c08a..b99ca9afde2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -111,7 +111,7 @@ django-bootstrap3-datetimepicker-2==2.8.3 # storage manager dependencies django-storages==1.13.2 dropbox==11.36.2 -google-cloud-storage==2.9.0 +google-cloud-storage==2.10.0 google-cloud-core==2.3.2 boto3==1.26.160 diff --git a/setup.cfg b/setup.cfg index c140c4bc986..295dc72d6b2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -136,7 +136,7 @@ install_requires = # storage manager dependencies django-storages==1.13.2 dropbox==11.36.2 - google-cloud-storage==2.9.0 + google-cloud-storage==2.10.0 google-cloud-core==2.3.2 boto3==1.26.160 From c75073f8b653c63b5dec21b0e4de07b85a668fd3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jul 2023 11:46:13 +0200 Subject: [PATCH 051/330] Bump boto3 from 1.26.160 to 1.26.165 (#11210) * Bump boto3 from 1.26.160 to 1.26.165 Bumps [boto3](https://github.com/boto/boto3) from 1.26.160 to 1.26.165. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.160...1.26.165) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index b99ca9afde2..4df657af224 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.2 -boto3==1.26.160 +boto3==1.26.165 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index 295dc72d6b2..1806bbc4910 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.2 - boto3==1.26.160 + boto3==1.26.165 # Django Caches python-memcached<=1.59 From dcc119ab9e6ae400bf8c662a07a8d770aa28a235 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jul 2023 14:19:22 +0200 Subject: [PATCH 052/330] Bump pillow from 9.5.0 to 10.0.0 (#11211) * Bump pillow from 9.5.0 to 10.0.0 Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.5.0 to 10.0.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/9.5.0...10.0.0) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" * - Bump geonode-avatar 5.0.8 to django-avatar==7.1.1 * - Bump geonode-avatar 5.0.8 to django-avatar==7.1.1 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 4 ++-- setup.cfg | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4df657af224..f44b6c465b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # native dependencies -Pillow==9.5.0 +Pillow==10.0.0 lxml==4.9.2 psycopg2==2.9.6 Django==3.2.19 @@ -91,7 +91,7 @@ pinax-ratings==4.0.0 # django-geonode-mapstore-client==4.0.5 -e git+https://github.com/GeoNode/geonode-mapstore-client.git@master#egg=django_geonode_mapstore_client -e git+https://github.com/GeoNode/geonode-importer.git@master#egg=geonode-importer -geonode-avatar==5.0.8 +django-avatar==7.1.1 geonode-oauth-toolkit==2.2.2 geonode-user-messages==2.0.2 geonode-announcements==2.0.2 diff --git a/setup.cfg b/setup.cfg index 1806bbc4910..fdc1c61ab21 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,7 +25,7 @@ setup_requires = setuptools install_requires = # native dependencies - Pillow==9.5.0 + Pillow==10.0.0 lxml==4.9.2 psycopg2==2.9.6 Django==3.2.19 @@ -116,7 +116,7 @@ install_requires = # GeoNode org maintained apps. django-geonode-mapstore-client>=4.0.5,<5.0.0 geonode-importer>=1.0.2 - geonode-avatar==5.0.8 + django-avatar==7.1.1 geonode-oauth-toolkit==2.2.2 geonode-user-messages==2.0.2 geonode-announcements==2.0.2 @@ -201,6 +201,7 @@ install_requires = mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability jwcrypto>=1.4 # not directly required, pinned by Snyk to avoid a vulnerability + cryptography>=41.0.0 # not directly required, pinned by Snyk to avoid a vulnerability [options.packages.find] exclude = tests From de59bced4d56479eae55febb2c527132a7a4e2c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jul 2023 16:06:46 +0200 Subject: [PATCH 053/330] Bump django from 3.2.19 to 3.2.20 (#11216) * Bump django from 3.2.19 to 3.2.20 Bumps [django](https://github.com/django/django) from 3.2.19 to 3.2.20. - [Commits](https://github.com/django/django/compare/3.2.19...3.2.20) --- updated-dependencies: - dependency-name: django dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index f44b6c465b9..022221a3a3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ Pillow==10.0.0 lxml==4.9.2 psycopg2==2.9.6 -Django==3.2.19 +Django==3.2.20 # Other amqp==5.1.1 diff --git a/setup.cfg b/setup.cfg index fdc1c61ab21..d30ae2d8c54 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,7 +28,7 @@ install_requires = Pillow==10.0.0 lxml==4.9.2 psycopg2==2.9.6 - Django==3.2.19 + Django==3.2.20 # Other amqp==5.1.1 From 543d751d8c5d98184008b175d06d97b4b8131db2 Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Wed, 5 Jul 2023 09:55:46 +0200 Subject: [PATCH 054/330] fix: Dockerfile to reduce vulnerabilities (#11223) The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-UBUNTU2204-BASH-3098342 - https://snyk.io/vuln/SNYK-UBUNTU2204-COREUTILS-2801226 - https://snyk.io/vuln/SNYK-UBUNTU2204-GLIBC-2801292 - https://snyk.io/vuln/SNYK-UBUNTU2204-LIBCAP2-5538282 - https://snyk.io/vuln/SNYK-UBUNTU2204-LIBCAP2-5538296 Co-authored-by: snyk-bot --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 6c62736737b..5d76bb6f25d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:22.04 +FROM ubuntu:22.10 LABEL GeoNode development team RUN mkdir -p /usr/src/geonode From f839d394af4f313b66482a2b2544b556969a1f7b Mon Sep 17 00:00:00 2001 From: etj Date: Tue, 4 Jul 2023 18:27:36 +0200 Subject: [PATCH 055/330] [Fixes #11097] Faceting: resource type --- geonode/facets/models.py | 2 +- geonode/facets/providers/baseinfo.py | 147 +++++++++++++++++++++++++++ geonode/facets/tests.py | 60 +++++++---- geonode/settings.py | 2 + 4 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 geonode/facets/providers/baseinfo.py diff --git a/geonode/facets/models.py b/geonode/facets/models.py index 07b1b4c7643..c7f9fbd0813 100644 --- a/geonode/facets/models.py +++ b/geonode/facets/models.py @@ -28,7 +28,7 @@ FACET_TYPE_USER = "user" FACET_TYPE_THESAURUS = "thesaurus" FACET_TYPE_CATEGORY = "category" -FACET_TYPE_RESOURCETYPE = "resourcetype" +FACET_TYPE_BASE = "base" logger = logging.getLogger(__name__) diff --git a/geonode/facets/providers/baseinfo.py b/geonode/facets/providers/baseinfo.py new file mode 100644 index 00000000000..b1f55129be3 --- /dev/null +++ b/geonode/facets/providers/baseinfo.py @@ -0,0 +1,147 @@ +######################################################################### +# +# Copyright (C) 2023 Open Source Geospatial Foundation - all rights reserved +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### + +import logging + +from django.db.models import Count + +from geonode.facets.models import FacetProvider, DEFAULT_FACET_PAGE_SIZE, FACET_TYPE_BASE + +logger = logging.getLogger(__name__) + + +class ResourceTypeFacetProvider(FacetProvider): + """ + Implements faceting for resources' type and subtype + """ + + @property + def name(self) -> str: + return "resourcetype" + + def get_info(self, lang="en") -> dict: + return { + "name": self.name, + "key": "filter{resource_type.in}", + "label": "Resource type", + "type": FACET_TYPE_BASE, + "hierarchical": True, + "order": 0, + } + + def get_facet_items( + self, + queryset=None, + start: int = 0, + end: int = DEFAULT_FACET_PAGE_SIZE, + lang="en", + topic_contains: str = None, + ) -> (int, list): + logger.debug("Retrieving facets for %s", self.name) + + if topic_contains: + logger.warning(f"Facet {self.name} does not support topic_contains filtering") + + q = queryset.values("resource_type", "subtype") + q = q.annotate(ctype=Count("resource_type"), csub=Count("subtype")) + q = q.order_by() + + # aggregate subtypes into rtypes + tree = {} + for r in q.all(): + res_type = r["resource_type"] + t = tree.get(res_type, {"cnt": 0, "sub": {}}) + t["cnt"] += r["ctype"] + if sub := r["subtype"]: + t["sub"][sub] = {"cnt": r["ctype"]} + tree[res_type] = t + + logger.info("Found %d main facets for %s", len(tree), self.name) + logger.debug(" ---> %s\n\n", q.query) + logger.debug(" ---> %r\n\n", q.all()) + + topics = [] + for rtype, info in tree.items(): + t = {"key": rtype, "label": rtype, "count": info["cnt"]} + if sub := info["sub"]: + children = [] + for stype, sinfo in sub.items(): + children.append({"key": stype, "label": stype, "count": sinfo["cnt"]}) + t["filter"] = "filter{subtype.in}" + t["items"] = sorted(children, reverse=True, key=lambda x: x["count"]) + topics.append(t) + + return len(topics), sorted(topics, reverse=True, key=lambda x: x["count"]) + + @classmethod + def register(cls, registry, **kwargs) -> None: + registry.register_facet_provider(ResourceTypeFacetProvider()) + + +class FeaturedFacetProvider(FacetProvider): + """ + Implements faceting for resources flagged as featured + """ + + @property + def name(self) -> str: + return "featured" + + def get_info(self, lang="en") -> dict: + return { + "name": self.name, + "key": "filter{featured}", + "label": "Featured", + "type": FACET_TYPE_BASE, + "hierarchical": False, + "order": 0, + } + + def get_facet_items( + self, + queryset=None, + start: int = 0, + end: int = DEFAULT_FACET_PAGE_SIZE, + lang="en", + topic_contains: str = None, + ) -> (int, list): + logger.debug("Retrieving facets for %s", self.name) + + if topic_contains: + logger.warning(f"Facet {self.name} does not support topic_contains filtering") + + q = queryset.values("featured").annotate(cnt=Count("featured")).order_by() + + logger.debug(" ---> %s\n\n", q.query) + logger.debug(" ---> %r\n\n", q.all()) + + topics = [ + { + "key": r["featured"], + "label": str(r["featured"]), + "count": r["cnt"], + } + for r in q[start:end] + ] + + return 2, topics + + @classmethod + def register(cls, registry, **kwargs) -> None: + registry.register_facet_provider(FeaturedFacetProvider()) diff --git a/geonode/facets/tests.py b/geonode/facets/tests.py index b5ae7f7541f..156c4908e50 100644 --- a/geonode/facets/tests.py +++ b/geonode/facets/tests.py @@ -114,49 +114,46 @@ def _create_resources(self): # These are the assigned keywords to the Resources - # RB00 -> T1K0 R0,R1 - # RB01 -> T0K0 T1K0 R0 - # RB02 -> T1K0 R1 + # RB00 -> T1K0 R0,R1 FEAT + # RB01 -> T0K0 T1K0 R0 FEAT + # RB02 -> T1K0 R1 FEAT # RB03 -> T0K0 T1K0 # RB04 -> T1K0 # RB05 -> T0K0 T1K0 - # RB06 -> T1K0 - # RB07 -> T0K0 T1K0 - # RB08 -> T1K0 T1K1 R1 + # RB06 -> T1K0 FEAT + # RB07 -> T0K0 T1K0 FEAT + # RB08 -> T1K0 T1K1 R1 FEAT # RB09 -> T0K0 T1K0 T1K1 # RB10 -> T1K1 # RB11 -> T0K0 T0K1 T1K1 - # RB12 -> T1K1 - # RB13 -> T0K0 T0K1 R1 - # RB14 -> + # RB12 -> T1K1 FEAT + # RB13 -> T0K0 T0K1 R1 FEAT + # RB14 -> FEAT # RB15 -> T0K0 T0K1 # RB16 -> # RB17 -> T0K0 T0K1 - # RB18 -> - # RB19 -> T0K0 T0K1 + # RB18 -> FEAT + # RB19 -> T0K0 T0K1 FEAT if x % 2 == 1: print(f"ADDING KEYWORDS {self.thesauri_k['0_0']} to RB {d}") d.tkeywords.add(self.thesauri_k["0_0"]) - d.save() if x % 2 == 1 and x > 10: print(f"ADDING KEYWORDS {self.thesauri_k['0_1']} to RB {d}") d.tkeywords.add(self.thesauri_k["0_1"]) - d.save() if x < 10: print(f"ADDING KEYWORDS {self.thesauri_k['1_0']} to RB {d}") d.tkeywords.add(self.thesauri_k["1_0"]) - d.save() if 7 < x < 13: d.tkeywords.add(self.thesauri_k["1_1"]) - d.save() if x in (0, 1): d.regions.add(self.regions["R0"]) - d.save() if x in (0, 2, 8, 13): d.regions.add(self.regions["R1"]) - d.save() + if (x % 6) in (0, 1, 2): + d.featured = True + d.save() d.set_permissions(public_perm_spec) @staticmethod @@ -169,9 +166,9 @@ def test_facets_base(self): obj = json.loads(res.content) self.assertIn("facets", obj) facets_list = obj["facets"] - self.assertEqual(5, len(facets_list)) + self.assertEqual(7, len(facets_list)) fmap = self._facets_to_map(facets_list) - for name in ("category", "owner", "t_0", "t_1"): + for name in ("category", "owner", "t_0", "t_1", "featured", "resourcetype"): self.assertIn(name, fmap) def test_facets_rich(self): @@ -189,7 +186,7 @@ def test_facets_rich(self): obj = json.loads(res.content) facets_list = obj["facets"] - self.assertEqual(5, len(facets_list)) + self.assertEqual(7, len(facets_list)) fmap = self._facets_to_map(facets_list) for expected in ( { @@ -233,6 +230,25 @@ def test_facets_rich(self): ], }, }, + { + "name": "featured", + "topics": { + "total": 2, + "items": [ + {"label": "True", "key": True, "count": 11}, + {"label": "False", "key": False, "count": 9}, + ], + }, + }, + { + "name": "resourcetype", + "topics": { + "total": 1, + "items": [ + {"label": "resourcebase", "key": "resourcebase", "count": 20}, + ], + }, + }, ): name = expected["name"] self.assertIn(name, fmap) @@ -254,7 +270,9 @@ def test_facets_rich(self): found = item break - self.assertIsNotNone(item, f"topic not found '{exp_label}'") + self.assertIsNotNone( + found, f"topic not found '{exp_label}' for facet '{name}' -- found items {items}" + ) for exp_field in exp_item: self.assertEqual( exp_item[exp_field], found[exp_field], f"Mismatch item key:{exp_field} facet:{name}" diff --git a/geonode/settings.py b/geonode/settings.py index d408093575b..9d6a270f138 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -2320,6 +2320,8 @@ def get_geonode_catalogue_service(): GEONODE_APPS += ("geonode.facets",) FACET_PROVIDERS = ( + "geonode.facets.providers.baseinfo.ResourceTypeFacetProvider", + "geonode.facets.providers.baseinfo.FeaturedFacetProvider", "geonode.facets.providers.category.CategoryFacetProvider", "geonode.facets.providers.users.OwnerFacetProvider", "geonode.facets.providers.thesaurus.ThesaurusFacetProvider", From 9c7ba9abd6f41cafc26217f37033b1c25435f7f7 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 6 Jul 2023 18:11:12 +0200 Subject: [PATCH 056/330] [Fixes #11228] Return supported formats grouped and ordered (#11229) * Return supported formats grouped and ordered * a more compct solution * dropped redundant variable * fix typo * dropped redundant variable --------- Co-authored-by: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> --- geonode/utils.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/geonode/utils.py b/geonode/utils.py index 6a2cee2708b..fc27b9b5647 100755 --- a/geonode/utils.py +++ b/geonode/utils.py @@ -1963,7 +1963,24 @@ def get_supported_datasets_file_types(): supported_types[default_types_id.index(_type.get("id"))] = _type else: supported_types.extend([_type]) - return supported_types + + # Order the formats (to support their visualization) + formats_order = [("vector", 0), ("raster", 1), ("archive", 2)] + ordered_payload = ( + (weight[1], resource_type) + for resource_type in supported_types + for weight in formats_order + if resource_type.get("format") in weight[0] + ) + + # Flatten the list + ordered_resource_types = [x[1] for x in sorted(ordered_payload, key=lambda x: x[0])] + other_resource_types = [ + resource_type + for resource_type in supported_types + if resource_type.get("format") is None or resource_type.get("format") not in [f[0] for f in formats_order] + ] + return ordered_resource_types + other_resource_types def get_allowed_extensions(): From 4a1255fb6a1537838f8d2c626f5c5196d0be32dd Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Fri, 7 Jul 2023 11:49:58 +0200 Subject: [PATCH 057/330] [Fixes #11230] Split and tag base Docker image (#11232) * Aplti and tag base Docker image * renamed image from master to latest --- Dockerfile | 51 ++++----------------------- scripts/docker/base/ubuntu/Dockerfile | 40 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 45 deletions(-) create mode 100644 scripts/docker/base/ubuntu/Dockerfile diff --git a/Dockerfile b/Dockerfile index 5d76bb6f25d..c09545fcf3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,51 +1,15 @@ -FROM ubuntu:22.10 +FROM geonode/geonode-base:latest-ubuntu-22.10 LABEL GeoNode development team -RUN mkdir -p /usr/src/geonode - -## Enable postgresql-client-13 -RUN apt-get update -y && apt-get install curl wget unzip gnupg2 -y -RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - -# will install python3.10 -RUN apt-get install lsb-core -y -RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" |tee /etc/apt/sources.list.d/pgdg.list - -# Prepraing dependencies -RUN apt-get install -y \ - libgdal-dev libpq-dev libxml2-dev \ - libxml2 libxslt1-dev zlib1g-dev libjpeg-dev \ - libmemcached-dev libldap2-dev libsasl2-dev libffi-dev - -RUN apt-get update -y && apt-get install -y --no-install-recommends \ - gcc zip gettext geoip-bin cron \ - postgresql-client-13 \ - python3-all-dev python3-dev \ - python3-gdal python3-psycopg2 python3-ldap \ - python3-pip python3-pil python3-lxml \ - uwsgi uwsgi-plugin-python3 python3-gdbm python-is-python3 gdal-bin - -RUN apt-get install -y devscripts build-essential debhelper pkg-kde-tools sharutils -# RUN git clone https://salsa.debian.org/debian-gis-team/proj.git /tmp/proj -# RUN cd /tmp/proj && debuild -i -us -uc -b && dpkg -i ../*.deb - -# Install pip packages -RUN pip3 install uwsgi \ - && pip install pip --upgrade \ - && pip install pygdal==$(gdal-config --version).* flower==0.9.4 - -# Activate "memcached" -RUN apt-get install -y memcached -RUN pip install sherlock - # add bower and grunt command COPY . /usr/src/geonode/ WORKDIR /usr/src/geonode -COPY monitoring-cron /etc/cron.d/monitoring-cron -RUN chmod 0644 /etc/cron.d/monitoring-cron -RUN crontab /etc/cron.d/monitoring-cron -RUN touch /var/log/cron.log -RUN service cron start +#COPY monitoring-cron /etc/cron.d/monitoring-cron +#RUN chmod 0644 /etc/cron.d/monitoring-cron +#RUN crontab /etc/cron.d/monitoring-cron +#RUN touch /var/log/cron.log +#RUN service cron start COPY wait-for-databases.sh /usr/bin/wait-for-databases RUN chmod +x /usr/bin/wait-for-databases @@ -67,9 +31,6 @@ RUN chmod +x /usr/bin/celery-cmd RUN pip install --upgrade --no-cache-dir --src /usr/src -r requirements.txt RUN pip install --upgrade -e . -# Cleanup apt update lists -RUN rm -rf /var/lib/apt/lists/* - # Export ports EXPOSE 8000 diff --git a/scripts/docker/base/ubuntu/Dockerfile b/scripts/docker/base/ubuntu/Dockerfile new file mode 100644 index 00000000000..3b36cb6236f --- /dev/null +++ b/scripts/docker/base/ubuntu/Dockerfile @@ -0,0 +1,40 @@ +FROM ubuntu:22.10 + +RUN mkdir -p /usr/src/geonode + +## Enable postgresql-client-13 +RUN apt-get update -y && apt-get install curl wget unzip gnupg2 -y +RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - +# will install python3.10 +RUN apt-get install lsb-core -y +RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" |tee /etc/apt/sources.list.d/pgdg.list + +# Prepraing dependencies +RUN apt-get install -y \ + libgdal-dev libpq-dev libxml2-dev \ + libxml2 libxslt1-dev zlib1g-dev libjpeg-dev \ + libmemcached-dev libldap2-dev libsasl2-dev libffi-dev + +RUN apt-get update -y && apt-get install -y --no-install-recommends \ + gcc vim zip gettext geoip-bin cron \ + postgresql-client-13 \ + python3-all-dev python3-dev \ + python3-gdal python3-psycopg2 python3-ldap \ + python3-pip python3-pil python3-lxml \ + uwsgi uwsgi-plugin-python3 python3-gdbm python-is-python3 gdal-bin + +RUN apt-get install -y devscripts build-essential debhelper pkg-kde-tools sharutils +# RUN git clone https://salsa.debian.org/debian-gis-team/proj.git /tmp/proj +# RUN cd /tmp/proj && debuild -i -us -uc -b && dpkg -i ../*.deb + +# Install pip packages +RUN pip3 install uwsgi \ + && pip install pip --upgrade \ + && pip install pygdal==$(gdal-config --version).* flower==0.9.4 + +# Activate "memcached" +RUN apt-get install -y memcached +RUN pip install sherlock + +# Cleanup apt update lists +RUN rm -rf /var/lib/apt/lists/* From 0bb04fec4b26ac0b45ddc84d1e65d20489d19a57 Mon Sep 17 00:00:00 2001 From: etj Date: Thu, 6 Jul 2023 17:49:15 +0200 Subject: [PATCH 058/330] [Fixes #11101] Faceting: keywords --- geonode/facets/models.py | 1 + geonode/facets/providers/keyword.py | 96 +++++++++++++++++++++++++++++ geonode/facets/tests.py | 6 +- geonode/settings.py | 1 + 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 geonode/facets/providers/keyword.py diff --git a/geonode/facets/models.py b/geonode/facets/models.py index c7f9fbd0813..4beffbb1ac6 100644 --- a/geonode/facets/models.py +++ b/geonode/facets/models.py @@ -29,6 +29,7 @@ FACET_TYPE_THESAURUS = "thesaurus" FACET_TYPE_CATEGORY = "category" FACET_TYPE_BASE = "base" +FACET_TYPE_KEYWORD = "keyword" logger = logging.getLogger(__name__) diff --git a/geonode/facets/providers/keyword.py b/geonode/facets/providers/keyword.py new file mode 100644 index 00000000000..4a82e9a3dbc --- /dev/null +++ b/geonode/facets/providers/keyword.py @@ -0,0 +1,96 @@ +######################################################################### +# +# Copyright (C) 2023 Open Source Geospatial Foundation - all rights reserved +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### + +import logging + +from django.db.models import Count + +from geonode.base.models import HierarchicalKeyword +from geonode.facets.models import FacetProvider, DEFAULT_FACET_PAGE_SIZE, FACET_TYPE_KEYWORD + +logger = logging.getLogger(__name__) + + +class KeywordFacetProvider(FacetProvider): + """ + Implements faceting for resource's keywords + """ + + @property + def name(self) -> str: + return "keyword" + + def get_info(self, lang="en") -> dict: + return { + "name": self.name, + "key": "filter{keywords.slug.in}", + "label": "Keyword", + "type": FACET_TYPE_KEYWORD, + "order": 2, + } + + def get_facet_items( + self, + queryset=None, + start: int = 0, + end: int = DEFAULT_FACET_PAGE_SIZE, + lang="en", + topic_contains: str = None, + ) -> (int, list): + logger.debug("Retrieving facets for %s", self.name) + + q = queryset.values("keywords__slug", "keywords__name").filter(keywords__isnull=False) + if topic_contains: + q = q.filter(keywords__name=topic_contains) + q = q.annotate(count=Count("keywords__slug")).order_by("-count") + + cnt = q.count() + + logger.info("Found %d facets for %s", cnt, self.name) + logger.debug(" ---> %s\n\n", q.query) + logger.debug(" ---> %r\n\n", q.all()) + + topics = [ + { + "key": r["keywords__slug"], + "label": r["keywords__name"], + "count": r["count"], + } + for r in q[start:end].all() + ] + + return cnt, topics + + def get_topics(self, keys: list, lang="en", **kwargs) -> list: + q = HierarchicalKeyword.objects.filter(slug__in=keys).values("slug", "name") + + logger.debug(" ---> %s\n\n", q.query) + logger.debug(" ---> %r\n\n", q.all()) + + return [ + { + "key": r["slug"], + "label": r["name"], + } + for r in q.all() + ] + + @classmethod + def register(cls, registry, **kwargs) -> None: + registry.register_facet_provider(KeywordFacetProvider()) diff --git a/geonode/facets/tests.py b/geonode/facets/tests.py index 156c4908e50..cc6d9a8e0ec 100644 --- a/geonode/facets/tests.py +++ b/geonode/facets/tests.py @@ -166,9 +166,9 @@ def test_facets_base(self): obj = json.loads(res.content) self.assertIn("facets", obj) facets_list = obj["facets"] - self.assertEqual(7, len(facets_list)) + self.assertEqual(8, len(facets_list)) fmap = self._facets_to_map(facets_list) - for name in ("category", "owner", "t_0", "t_1", "featured", "resourcetype"): + for name in ("category", "owner", "t_0", "t_1", "featured", "resourcetype", "keyword"): self.assertIn(name, fmap) def test_facets_rich(self): @@ -186,7 +186,7 @@ def test_facets_rich(self): obj = json.loads(res.content) facets_list = obj["facets"] - self.assertEqual(7, len(facets_list)) + self.assertEqual(8, len(facets_list)) fmap = self._facets_to_map(facets_list) for expected in ( { diff --git a/geonode/settings.py b/geonode/settings.py index 9d6a270f138..8afedc72c17 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -2323,6 +2323,7 @@ def get_geonode_catalogue_service(): "geonode.facets.providers.baseinfo.ResourceTypeFacetProvider", "geonode.facets.providers.baseinfo.FeaturedFacetProvider", "geonode.facets.providers.category.CategoryFacetProvider", + "geonode.facets.providers.keyword.KeywordFacetProvider", "geonode.facets.providers.users.OwnerFacetProvider", "geonode.facets.providers.thesaurus.ThesaurusFacetProvider", "geonode.facets.providers.region.RegionFacetProvider", From 7ba0ed203c89f0baa092f96f464e1df902fc86f5 Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Mon, 10 Jul 2023 11:57:37 +0200 Subject: [PATCH 059/330] GNIP 94: Generic and pluggable OIDC SocialAccount Provider for GeoNode (#11218) Documentation available here: https://github.com/GeoNode/documentation/pull/277 --- .circleci/config.yml | 2 +- .env_test | 3 + geonode/groups/models.py | 40 ++- geonode/messaging/notifications.py | 15 +- geonode/people/adapters.py | 75 ++++++ geonode/people/profileextractors.py | 14 +- geonode/people/socialaccount/__init__.py | 18 ++ .../socialaccount/providers/__init__.py | 18 ++ .../geonode_openid_connect/__init__.py | 18 ++ .../geonode_openid_connect/provider.py | 94 +++++++ .../providers/geonode_openid_connect/tests.py | 230 ++++++++++++++++++ .../providers/geonode_openid_connect/urls.py | 24 ++ .../providers/geonode_openid_connect/views.py | 30 +++ geonode/settings.py | 102 ++++---- geonode/utils.py | 19 +- pavement.py | 4 +- 16 files changed, 639 insertions(+), 67 deletions(-) create mode 100644 geonode/people/socialaccount/__init__.py create mode 100644 geonode/people/socialaccount/providers/__init__.py create mode 100644 geonode/people/socialaccount/providers/geonode_openid_connect/__init__.py create mode 100644 geonode/people/socialaccount/providers/geonode_openid_connect/provider.py create mode 100644 geonode/people/socialaccount/providers/geonode_openid_connect/tests.py create mode 100644 geonode/people/socialaccount/providers/geonode_openid_connect/urls.py create mode 100644 geonode/people/socialaccount/providers/geonode_openid_connect/views.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 79ebd63c91b..60cfd1c921e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -108,7 +108,7 @@ workflows: name: geonode_test_suite load_docker_cache: false save_docker_cache: false - test_suite: ./test.sh $(python -c "import sys;from geonode import settings;sys.stdout.write('\'' '\''.join([a+'\''.tests'\'' for a in settings.GEONODE_APPS if '\''security'\'' not in a and '\''geoserver'\'' not in a]))") geonode.thumbs.tests geonode.people.tests + test_suite: ./test.sh $(python -c "import sys;from geonode import settings;sys.stdout.write('\'' '\''.join([a+'\''.tests'\'' for a in settings.GEONODE_APPS if '\''security'\'' not in a and '\''geoserver'\'' not in a]))") geonode.thumbs.tests geonode.people.tests geonode.people.socialaccount.providers.geonode_openid_connect.tests - build: name: geonode_test_security load_docker_cache: false diff --git a/.env_test b/.env_test index 78eebee94cb..a728f8fd4d1 100644 --- a/.env_test +++ b/.env_test @@ -157,6 +157,9 @@ OAUTH2_API_KEY= OAUTH2_CLIENT_ID=Jrchz2oPY3akmzndmgUTYrs9gczlgoV20YPSvqaV OAUTH2_CLIENT_SECRET=rCnp5txobUo83EpQEblM8fVj3QT5zb5qRfxNsuPzCqZaiRyIoxM4jdgMiZKFfePBHYXCLd7B8NlkfDBY9HKeIQPcy5Cp08KQNpRHQbjpLItDHv12GvkSeXp6OxaUETv3 +SOCIALACCOUNT_OIDC_PROVIDER_ENABLED=True +SOCIALACCOUNT_PROVIDER=google + # GeoNode APIs API_LOCKDOWN=False TASTYPIE_APIKEY= diff --git a/geonode/groups/models.py b/geonode/groups/models.py index 373d683d40c..59ae6f61392 100644 --- a/geonode/groups/models.py +++ b/geonode/groups/models.py @@ -32,6 +32,7 @@ from django.contrib.auth.models import Group from django.templatetags.static import static from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import ugettext_lazy as _ from geonode.utils import build_absolute_uri @@ -190,26 +191,43 @@ def can_view(self, user): else: return True - def join(self, user, **kwargs): + def validate_user(self, user): if not user or user.is_anonymous or user == user.get_anonymous(): raise ValueError("The invited user cannot be anonymous") - _members = GroupMember.objects.filter(group=self, user=user) - if not _members.count(): - GroupMember.objects.get_or_create(group=self, user=user, defaults=kwargs) - else: + + def join(self, user, **kwargs): + self.validate_user(user) + try: + GroupMember.objects.get(group=self, user=user) logger.warning(f'The invited user "{user.username}" is already a member') + except ObjectDoesNotExist: + GroupMember.objects.create(group=self, user=user, **kwargs) def leave(self, user, **kwargs): - if not user or user.is_anonymous or user == user.get_anonymous(): - raise ValueError("The invited user cannot be anonymous") + self.validate_user(user) _members = GroupMember.objects.filter(group=self, user=user) - if _members.count(): - for _member in _members: - _member.delete() - user.groups.remove(self.group) + if _members.exists(): + _members.delete() + user.groups.remove(self.group) else: logger.warning(f'The invited user "{user.username}" is not a member') + def promote(self, user, **kwargs): + self.validate_user(user) + try: + _member = GroupMember.objects.get(group=self, user=user) + _member.promote() + except ObjectDoesNotExist: + logger.warning(f'The invited user "{user.username}" is not a member') + + def demote(self, user, **kwargs): + self.validate_user(user) + try: + _member = GroupMember.objects.get(group=self, user=user) + _member.demote() + except ObjectDoesNotExist: + logger.warning(f'The invited user "{user.username}" is not a member') + def get_absolute_url(self): return reverse( "group_detail", diff --git a/geonode/messaging/notifications.py b/geonode/messaging/notifications.py index 763cf05371e..00d3135c1a1 100644 --- a/geonode/messaging/notifications.py +++ b/geonode/messaging/notifications.py @@ -38,13 +38,14 @@ def message_received_notification(**kwargs): recipients = _get_user_to_notify(message) # Enable email notifications for reciepients - for user in recipients: - notifications.models.NoticeSetting.objects.get_or_create( - notice_type=notifications.models.NoticeType.objects.get(label=notice_type_label), - send=True, - user=user, - medium=0, - ) + if notifications: + for user in recipients: + notifications.models.NoticeSetting.objects.get_or_create( + notice_type=notifications.models.NoticeType.objects.get(label=notice_type_label), + send=True, + user=user, + medium=0, + ) ctx = { "message": message.content, diff --git a/geonode/people/adapters.py b/geonode/people/adapters.py index bb290e11feb..a322562d19a 100644 --- a/geonode/people/adapters.py +++ b/geonode/people/adapters.py @@ -25,12 +25,15 @@ """ import logging +import jwt +import requests from allauth.account.adapter import DefaultAccountAdapter from allauth.account.utils import user_field from allauth.account.utils import user_email from allauth.account.utils import user_username from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2Error from invitations.adapters import BaseInvitationsAdapter @@ -222,3 +225,75 @@ def _site_allows_signup(django_request): def _respond_inactive_user(user): return HttpResponseRedirect(reverse("moderator_contacted", kwargs={"inactive_user": user.id})) + + +PROVIDER_ID = getattr(settings, "SOCIALACCOUNT_OIDC_PROVIDER", "geonode_openid_connect") + +ACCESS_TOKEN_URL = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("ACCESS_TOKEN_URL", "") + +AUTHORIZE_URL = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("AUTHORIZE_URL", "") + +PROFILE_URL = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("PROFILE_URL", "") + +ID_TOKEN_ISSUER = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("ID_TOKEN_ISSUER", "") + + +class GenericOpenIDConnectAdapter(OAuth2Adapter, SocialAccountAdapter): + provider_id = PROVIDER_ID + access_token_url = ACCESS_TOKEN_URL + authorize_url = AUTHORIZE_URL + profile_url = PROFILE_URL + id_token_issuer = ID_TOKEN_ISSUER + + def complete_login(self, request, app, token, response, **kwargs): + extra_data = {} + if self.profile_url: + headers = {"Authorization": "Bearer {0}".format(token.token)} + resp = requests.get(self.profile_url, headers=headers) + profile_data = resp.json() + extra_data.update(profile_data) + elif "id_token" in response: + try: + extra_data = jwt.decode( + response["id_token"], + # Since the token was received by direct communication + # protected by TLS between this library and Google, we + # are allowed to skip checking the token signature + # according to the OpenID Connect Core 1.0 + # specification. + # https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation + options={ + "verify_signature": False, + "verify_iss": True, + "verify_aud": True, + "verify_exp": True, + }, + issuer=self.id_token_issuer, + audience=app.client_id, + ) + except jwt.PyJWTError as e: + raise OAuth2Error("Invalid id_token") from e + login = self.get_provider().sociallogin_from_response(request, extra_data) + return login + + def save_user(self, request, sociallogin, form=None): + user = super(SocialAccountAdapter, self).save_user(request, sociallogin, form=form) + extractor = get_data_extractor(sociallogin.account.provider) + try: + groups = extractor.extract_groups(sociallogin.account.extra_data) or extractor.extract_roles( + sociallogin.account.extra_data + ) + is_manager = extractor.extract_is_manager(sociallogin.account.extra_data) + + # check here if user is member already of other groups and remove it form the ones that are not declared here... + for groupprofile in user.group_list_all(): + groupprofile.leave(user) + for group_name in groups: + groupprofile = GroupProfile.objects.filter(slug=group_name).first() + if groupprofile: + groupprofile.join(user) + if is_manager: + groupprofile.promote() + except (AttributeError, NotImplementedError): + pass # extractor doesn't define a method for extracting field + return user diff --git a/geonode/people/profileextractors.py b/geonode/people/profileextractors.py index e6411c3d267..2bf7fa12e51 100644 --- a/geonode/people/profileextractors.py +++ b/geonode/people/profileextractors.py @@ -127,6 +127,13 @@ def _extract_field(self, name, data): return result +PROVIDER_ID = getattr(settings, "SOCIALACCOUNT_OIDC_PROVIDER", "geonode_openid_connect") + +IS_MANAGER_FIELD = ( + getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("IS_MANAGER_FIELD", "is_manager") +) + + class OpenIDExtractor(BaseExtractor): def extract_email(self, data): return data.get("email", "") @@ -182,14 +189,17 @@ def extract_organization(self, data): def extract_voice(self, data): return data.get("phone", "") + def extract_keywords(self, data): + return data.get("keywords", "") + def extract_groups(self, data): return data.get("groups", "") def extract_roles(self, data): return data.get("roles", "") - def extract_keywords(self, data): - return data.get("keywords", "") + def extract_is_manager(self, data): + return data.get(IS_MANAGER_FIELD, "") def _get_latest_position(data): diff --git a/geonode/people/socialaccount/__init__.py b/geonode/people/socialaccount/__init__.py new file mode 100644 index 00000000000..da86ef5219a --- /dev/null +++ b/geonode/people/socialaccount/__init__.py @@ -0,0 +1,18 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### diff --git a/geonode/people/socialaccount/providers/__init__.py b/geonode/people/socialaccount/providers/__init__.py new file mode 100644 index 00000000000..da86ef5219a --- /dev/null +++ b/geonode/people/socialaccount/providers/__init__.py @@ -0,0 +1,18 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### diff --git a/geonode/people/socialaccount/providers/geonode_openid_connect/__init__.py b/geonode/people/socialaccount/providers/geonode_openid_connect/__init__.py new file mode 100644 index 00000000000..da86ef5219a --- /dev/null +++ b/geonode/people/socialaccount/providers/geonode_openid_connect/__init__.py @@ -0,0 +1,18 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### diff --git a/geonode/people/socialaccount/providers/geonode_openid_connect/provider.py b/geonode/people/socialaccount/providers/geonode_openid_connect/provider.py new file mode 100644 index 00000000000..329ba3c4bf3 --- /dev/null +++ b/geonode/people/socialaccount/providers/geonode_openid_connect/provider.py @@ -0,0 +1,94 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### + +"""Custom account providers for django-allauth. + +These are used in order to extend the default authorization provided by +django-allauth. + +""" +from django.conf import settings + +from geonode.utils import import_class_module + +from allauth.account.models import EmailAddress +from allauth.socialaccount.providers.base import AuthAction, ProviderAccount +from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider + +PROVIDER_ID = getattr(settings, "SOCIALACCOUNT_OIDC_PROVIDER", "geonode_openid_connect") + + +class GenericOpenIDConnectProviderAccount(ProviderAccount): + def to_str(self): + dflt = super(GenericOpenIDConnectProviderAccount, self).to_str() + return self.account.extra_data.get("name", dflt) + + +class GenericOpenIDConnectProvider(OAuth2Provider): + id = "geonode_openid_connect" + name = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("NAME", "GeoNode OpenIDConnect") + account_class = import_class_module( + getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}) + .get(PROVIDER_ID, {}) + .get( + "ACCOUNT_CLASS", + "geonode.people.socialaccount.providers.geonode_openid_connect.provider.GenericOpenIDConnectProviderAccount", + ) + ) + + def get_default_scope(self): + scope = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("SCOPE", "") + return scope + + def get_auth_params(self, request, action): + ret = super(GenericOpenIDConnectProvider, self).get_auth_params(request, action) + if action == AuthAction.REAUTHENTICATE: + ret["prompt"] = ( + getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}) + .get(PROVIDER_ID, {}) + .get("AUTH_PARAMS", {}) + .get("prompt", "") + ) + return ret + + def extract_uid(self, data): + return data.get("sub", data.get("id")) + + def extract_common_fields(self, data): + _common_fields = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("COMMON_FIELDS", {}) + __common_fields_data = {} + for _common_field in _common_fields: + __common_fields_data[_common_field] = data.get(_common_fields.get(_common_field), "") + return __common_fields_data + + def extract_email_addresses(self, data): + addresses = [] + email = data.get("email") + if email: + addresses.append( + EmailAddress( + email=email, + verified=data.get("email_verified", False), + primary=True, + ) + ) + return addresses + + +provider_classes = [GenericOpenIDConnectProvider] diff --git a/geonode/people/socialaccount/providers/geonode_openid_connect/tests.py b/geonode/people/socialaccount/providers/geonode_openid_connect/tests.py new file mode 100644 index 00000000000..29373c2fd93 --- /dev/null +++ b/geonode/people/socialaccount/providers/geonode_openid_connect/tests.py @@ -0,0 +1,230 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from __future__ import absolute_import, unicode_literals + +import json +from datetime import datetime, timedelta +from importlib import import_module + +from django.conf import settings +from django.core import mail +from django.test.client import RequestFactory +from django.test.utils import override_settings +from django.urls import reverse +from django.contrib.auth import get_user_model + +from allauth.account import app_settings as account_settings +from allauth.account.adapter import get_adapter +from allauth.account.models import EmailAddress, EmailConfirmation +from allauth.account.signals import user_signed_up + +# from allauth.socialaccount.models import SocialAccount +from allauth.socialaccount.providers.apple.client import jwt_encode +from allauth.socialaccount.tests import OAuth2TestsMixin +from allauth.tests import TestCase + + +@override_settings( + SOCIALACCOUNT_OIDC_PROVIDER_ENABLED=True, + SOCIALACCOUNT_AUTO_SIGNUP=True, + ACCOUNT_SIGNUP_FORM_CLASS=None, + ACCOUNT_EMAIL_VERIFICATION=account_settings.EmailVerificationMethod.MANDATORY, +) +class GoogleTests(OAuth2TestsMixin, TestCase): + provider_id = "geonode_openid_connect" + + def setUp(self): + super().setUp() + self.email = "raymond.penners@example.com" + self.identity_overwrites = {} + + def get_google_id_token_payload(self): + now = datetime.utcnow() + client_id = "app123id" # Matches `setup_app` + payload = { + "iss": "https://accounts.google.com", + "azp": client_id, + "aud": client_id, + "sub": "108204268033311374519", + "hd": "example.com", + "email": self.email, + "email_verified": True, + "at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q", + "name": "Raymond Penners", + "picture": "https://lh5.googleusercontent.com/photo.jpg", + "given_name": "Raymond", + "family_name": "Penners", + "locale": "en", + "iat": now, + "exp": now + timedelta(hours=1), + } + payload.update(self.identity_overwrites) + return payload + + def get_login_response_json(self, with_refresh_token=True): + data = { + "access_token": "testac", + "expires_in": 3600, + "scope": "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid", + "token_type": "Bearer", + "id_token": jwt_encode(self.get_google_id_token_payload(), "secret"), + } + return json.dumps(data) + + @override_settings(SOCIALACCOUNT_AUTO_SIGNUP=False) + def test_login(self): + resp = self.login(resp_mock=None) + self.assertRedirects(resp, reverse("socialaccount_signup")) + + def test_wrong_id_token_claim_values(self): + wrong_claim_values = { + "iss": "not-google", + "exp": datetime.utcnow() - timedelta(seconds=1), + "aud": "foo", + } + for key, value in wrong_claim_values.items(): + with self.subTest(key): + self.identity_overwrites = {key: value} + resp = self.login(resp_mock=None) + self.assertTemplateUsed( + resp, + "socialaccount/authentication_error.%s" % getattr(settings, "ACCOUNT_TEMPLATE_EXTENSION", "html"), + ) + + def test_username_based_on_email(self): + self.identity_overwrites = {"given_name": "明", "family_name": "小"} + self.login(resp_mock=None) + user = get_user_model().objects.get(email=self.email) + self.assertEqual(user.username, "raymond.penners") + + def test_email_verified(self): + self.identity_overwrites = {"email_verified": True} + self.login(resp_mock=None) + email_address = EmailAddress.objects.get(email=self.email, verified=True) + self.assertFalse(EmailConfirmation.objects.filter(email_address__email=self.email).exists()) + account = email_address.user.socialaccount_set.all()[0] + self.assertEqual(account.extra_data["given_name"], "Raymond") + + def test_user_signed_up_signal(self): + sent_signals = [] + + def on_signed_up(sender, request, user, **kwargs): + sociallogin = kwargs["sociallogin"] + self.assertEqual(sociallogin.account.provider, "geonode_openid_connect") + self.assertEqual(sociallogin.account.user, user) + sent_signals.append(sender) + + user_signed_up.connect(on_signed_up) + self.login(resp_mock=None) + self.assertTrue(len(sent_signals) > 0) + + @override_settings(ACCOUNT_EMAIL_CONFIRMATION_HMAC=False) + def test_email_unverified(self): + self.identity_overwrites = {"email_verified": False} + resp = self.login(resp_mock=None) + email_address = EmailAddress.objects.get(email=self.email) + self.assertFalse(email_address.verified) + self.assertTrue(EmailConfirmation.objects.filter(email_address__email=self.email).exists()) + self.assertTemplateUsed(resp, "account/email/email_confirmation_signup_subject.txt") + + def test_email_verified_stashed(self): + # http://slacy.com/blog/2012/01/how-to-set-session-variables-in-django-unit-tests/ + engine = import_module(settings.SESSION_ENGINE) + store = engine.SessionStore() + store.save() + self.client.cookies[settings.SESSION_COOKIE_NAME] = store.session_key + request = RequestFactory().get("/") + request.session = self.client.session + adapter = get_adapter(request) + adapter.stash_verified_email(request, self.email) + request.session.save() + + self.identity_overwrites = {"email_verified": False} + self.login(resp_mock=None) + email_address = EmailAddress.objects.get(email=self.email) + self.assertTrue(email_address.verified) + self.assertFalse(EmailConfirmation.objects.filter(email_address__email=self.email).exists()) + + def test_account_connect(self): + email = "user@example.com" + user = get_user_model().objects.create(username="user", is_active=True, email=email) + user.set_password("test") + user.save() + EmailAddress.objects.create(user=user, email=email, primary=True, verified=True) + self.client.login(username=user.username, password="test") + self.identity_overwrites = {"email": email, "email_verified": True} + self.login(resp_mock=None, process="connect") + # Check if we connected... + # self.assertTrue(SocialAccount.objects.filter(user=user, provider="geonode_openid_connect").exists()) + # For now, we do not pick up any new e-mail addresses on connect + self.assertEqual(EmailAddress.objects.filter(user=user).count(), 1) + self.assertEqual(EmailAddress.objects.filter(user=user, email=email).count(), 1) + + @override_settings( + ACCOUNT_EMAIL_VERIFICATION=account_settings.EmailVerificationMethod.MANDATORY, + SOCIALACCOUNT_EMAIL_VERIFICATION=account_settings.EmailVerificationMethod.NONE, + ) + def test_social_email_verification_skipped(self): + self.identity_overwrites = {"email_verified": False} + self.login(resp_mock=None) + email_address = EmailAddress.objects.get(email=self.email) + self.assertFalse(email_address.verified) + self.assertFalse(EmailConfirmation.objects.filter(email_address__email=self.email).exists()) + + @override_settings( + ACCOUNT_EMAIL_VERIFICATION=account_settings.EmailVerificationMethod.OPTIONAL, + SOCIALACCOUNT_EMAIL_VERIFICATION=account_settings.EmailVerificationMethod.OPTIONAL, + ) + def test_social_email_verification_optional(self): + self.identity_overwrites = {"email_verified": False} + self.login(resp_mock=None) + self.assertEqual(len(mail.outbox), 1) + self.login(resp_mock=None) + self.assertEqual(len(mail.outbox), 1) + + +@override_settings( + SOCIALACCOUNT_OIDC_PROVIDER_ENABLED=True, + SOCIALACCOUNT_PROVIDERS={ + "geonode_openid_connect": { + "NAME": "Google", + "SCOPE": [ + "profile", + "email", + ], + "AUTH_PARAMS": { + "access_type": "online", + "prompt": "select_account consent", + }, + "COMMON_FIELDS": {"email": "email", "last_name": "family_name", "first_name": "given_name"}, + "IS_MANAGER_FIELD": "is_manager", + "ACCOUNT_CLASS": "allauth.socialaccount.providers.google.provider.GoogleAccount", + "ACCESS_TOKEN_URL": "https://oauth2.googleapis.com/token", + "AUTHORIZE_URL": "https://accounts.google.com/o/oauth2/v2/auth", + "ID_TOKEN_ISSUER": "https://accounts.google.com", + "OAUTH_PKCE_ENABLED": True, + } + }, +) +class AppInSettingsTests(GoogleTests): + """ + Run the same set of tests but without having a SocialApp entry. + """ + + pass diff --git a/geonode/people/socialaccount/providers/geonode_openid_connect/urls.py b/geonode/people/socialaccount/providers/geonode_openid_connect/urls.py new file mode 100644 index 00000000000..daa5ff7d752 --- /dev/null +++ b/geonode/people/socialaccount/providers/geonode_openid_connect/urls.py @@ -0,0 +1,24 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns + +from geonode.people.socialaccount.providers.geonode_openid_connect.provider import GenericOpenIDConnectProvider + + +urlpatterns = default_urlpatterns(GenericOpenIDConnectProvider) diff --git a/geonode/people/socialaccount/providers/geonode_openid_connect/views.py b/geonode/people/socialaccount/providers/geonode_openid_connect/views.py new file mode 100644 index 00000000000..c009c52b95c --- /dev/null +++ b/geonode/people/socialaccount/providers/geonode_openid_connect/views.py @@ -0,0 +1,30 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from django.conf import settings + +from geonode.utils import import_class_module + +from allauth.socialaccount.providers.oauth2.views import ( + OAuth2CallbackView, + OAuth2LoginView, +) + + +oauth2_login = OAuth2LoginView.adapter_view(import_class_module(settings.SOCIALACCOUNT_ADAPTER)) +oauth2_callback = OAuth2CallbackView.adapter_view(import_class_module(settings.SOCIALACCOUNT_ADAPTER)) diff --git a/geonode/settings.py b/geonode/settings.py index 8afedc72c17..14b58fec600 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -26,7 +26,6 @@ import dj_database_url from schema import Optional from datetime import timedelta -from distutils.util import strtobool # noqa from urllib.parse import urlparse, urljoin # @@ -1956,56 +1955,73 @@ def get_geonode_catalogue_service(): ACCOUNT_LOGIN_ATTEMPTS_LIMIT = int(os.getenv("ACCOUNT_LOGIN_ATTEMPTS_LIMIT", "3")) ACCOUNT_MAX_EMAIL_ADDRESSES = int(os.getenv("ACCOUNT_MAX_EMAIL_ADDRESSES", "2")) -SOCIALACCOUNT_ADAPTER = "geonode.people.adapters.SocialAccountAdapter" SOCIALACCOUNT_AUTO_SIGNUP = ast.literal_eval(os.environ.get("SOCIALACCOUNT_AUTO_SIGNUP", "True")) +SOCIALACCOUNT_LOGIN_ON_GET = ast.literal_eval(os.environ.get("SOCIALACCOUNT_LOGIN_ON_GET", "True")) # This will hide or show local registration form in allauth view. True will show form -SOCIALACCOUNT_WITH_GEONODE_LOCAL_SINGUP = strtobool(os.environ.get("SOCIALACCOUNT_WITH_GEONODE_LOCAL_SINGUP", "True")) +SOCIALACCOUNT_WITH_GEONODE_LOCAL_SINGUP = ast.literal_eval( + os.environ.get("SOCIALACCOUNT_WITH_GEONODE_LOCAL_SINGUP", "True") +) -# Uncomment this to enable Linkedin and Facebook login -# INSTALLED_APPS += ( -# 'allauth.socialaccount.providers.linkedin_oauth2', -# 'allauth.socialaccount.providers.facebook', -# ) +# GeoNode Default Generic OIDC Provider -SOCIALACCOUNT_PROVIDERS = { - "linkedin_oauth2": { - "SCOPE": [ - "r_emailaddress", - "r_liteprofile", - ], - "PROFILE_FIELDS": [ - "id", - "email-address", - "first-name", - "last-name", - "picture-url", - "public-profile-url", - ], +SOCIALACCOUNT_OIDC_PROVIDER = os.environ.get("SOCIALACCOUNT_OIDC_PROVIDER", "geonode_openid_connect") +SOCIALACCOUNT_OIDC_PROVIDER_ENABLED = ast.literal_eval(os.environ.get("SOCIALACCOUNT_OIDC_PROVIDER_ENABLED", "False")) +SOCIALACCOUNT_ADAPTER = os.environ.get("SOCIALACCOUNT_ADAPTER", "geonode.people.adapters.GenericOpenIDConnectAdapter") + +# Enable this in order to enable the OIDC SocialAccount Provider +if SOCIALACCOUNT_OIDC_PROVIDER_ENABLED: + INSTALLED_APPS += ("geonode.people.socialaccount.providers.geonode_openid_connect",) + +_AZURE_TENANT_ID = os.getenv("MICROSOFT_TENANT_ID", "") +_AZURE_SOCIALACCOUNT_PROVIDER = { + "NAME": "Microsoft Azure", + "SCOPE": [ + "User.Read", + "openid", + ], + "AUTH_PARAMS": { + "access_type": "online", + "prompt": "select_account", }, - "facebook": { - "METHOD": "oauth2", - "SCOPE": [ - "email", - "public_profile", - ], - "FIELDS": [ - "id", - "email", - "name", - "first_name", - "last_name", - "verified", - "locale", - "timezone", - "link", - "gender", - ], + "COMMON_FIELDS": {"email": "mail", "last_name": "surname", "first_name": "givenName"}, + "IS_MANAGER_FIELD": "is_manager", + "ACCOUNT_CLASS": "allauth.socialaccount.providers.azure.provider.AzureAccount", + "ACCESS_TOKEN_URL": f"https://login.microsoftonline.com/{_AZURE_TENANT_ID}/oauth2/v2.0/token", + "AUTHORIZE_URL": f"https://login.microsoftonline.com/{_AZURE_TENANT_ID}/oauth2/v2.0/authorize", + "PROFILE_URL": "https://graph.microsoft.com/v1.0/me", +} + +_GOOGLE_SOCIALACCOUNT_PROVIDER = { + "NAME": "Google", + "SCOPE": [ + "profile", + "email", + ], + "AUTH_PARAMS": { + "access_type": "online", + "prompt": "select_account consent", }, + "COMMON_FIELDS": {"email": "email", "last_name": "family_name", "first_name": "given_name"}, + "IS_MANAGER_FIELD": "is_manager", + "ACCOUNT_CLASS": "allauth.socialaccount.providers.google.provider.GoogleAccount", + "ACCESS_TOKEN_URL": "https://oauth2.googleapis.com/token", + "AUTHORIZE_URL": "https://accounts.google.com/o/oauth2/v2/auth", + "ID_TOKEN_ISSUER": "https://accounts.google.com", + "OAUTH_PKCE_ENABLED": True, +} + +SOCIALACCOUNT_PROVIDERS_DEFS = {"azure": _AZURE_SOCIALACCOUNT_PROVIDER, "google": _GOOGLE_SOCIALACCOUNT_PROVIDER} + +_SOCIALACCOUNT_PROVIDER = os.environ.get("SOCIALACCOUNT_PROVIDER", "google") +SOCIALACCOUNT_PROVIDERS = { + SOCIALACCOUNT_OIDC_PROVIDER: SOCIALACCOUNT_PROVIDERS_DEFS.get(_SOCIALACCOUNT_PROVIDER), } +_SOCIALACCOUNT_PROFILE_EXTRACTOR = os.environ.get( + "SOCIALACCOUNT_PROFILE_EXTRACTOR", "geonode.people.profileextractors.OpenIDExtractor" +) SOCIALACCOUNT_PROFILE_EXTRACTORS = { - "facebook": "geonode.people.profileextractors.FacebookExtractor", - "linkedin_oauth2": "geonode.people.profileextractors.LinkedInExtractor", + SOCIALACCOUNT_OIDC_PROVIDER: _SOCIALACCOUNT_PROFILE_EXTRACTOR, } INVITATIONS_ADAPTER = ACCOUNT_ADAPTER @@ -2117,7 +2133,7 @@ def get_geonode_catalogue_service(): GEOIP_PATH = os.getenv("GEOIP_PATH", os.path.join(PROJECT_ROOT, "GeoIPCities.dat")) # This controls if tastypie search on resourches is performed only with titles -SEARCH_RESOURCES_EXTENDED = strtobool(os.getenv("SEARCH_RESOURCES_EXTENDED", "True")) +SEARCH_RESOURCES_EXTENDED = ast.literal_eval(os.getenv("SEARCH_RESOURCES_EXTENDED", "True")) # -- END Settings for MONITORING plugin CATALOG_METADATA_TEMPLATE = os.getenv("CATALOG_METADATA_TEMPLATE", "catalogue/full_metadata.xml") diff --git a/geonode/utils.py b/geonode/utils.py index fc27b9b5647..6d541926ba1 100755 --- a/geonode/utils.py +++ b/geonode/utils.py @@ -17,7 +17,6 @@ # ######################################################################### -import itertools import os import gc import re @@ -34,6 +33,8 @@ import datetime import requests import tempfile +import importlib +import itertools import traceback import subprocess @@ -2005,3 +2006,19 @@ def safe_path_leaf(path): f"The provided path '{path}' is not safe. The file is outside the MEDIA_ROOT '{base_path}' base path!" ) return fullpath + + +def import_class_module(full_class_string): + """ + Dynamically load a class from a string + + >>> klass = load_class("module.submodule.ClassName") + >>> klass2 = load_class("myfile.Class2") + """ + try: + module_path, class_name = full_class_string.rsplit(".", 1) + module = importlib.import_module(module_path) + class_obj = getattr(module, class_name) + return class_obj + except Exception: + return None diff --git a/pavement.py b/pavement.py index 8ef01a09492..f27d9d3c5de 100644 --- a/pavement.py +++ b/pavement.py @@ -521,7 +521,7 @@ def start_django(options): bind = options.get("bind", "0.0.0.0:8000") port = bind.split(":")[1] foreground = "" if options.get("foreground", False) else "&" - sh(f"{settings} python -W ignore manage.py runserver --nostatic {bind} {foreground}") + sh(f"{settings} python -W ignore manage.py runserver {bind} {foreground}") if ASYNC_SIGNALS: sh( @@ -784,7 +784,7 @@ def test_integration(options): foreground = "" if options.get("foreground", False) else "&" sh(f"DJANGO_SETTINGS_MODULE={settings} python -W ignore manage.py runmessaging {foreground}") sh( - f"DJANGO_SETTINGS_MODULE={settings} python -W ignore manage.py runserver --nostatic {bind} {foreground}" + f"DJANGO_SETTINGS_MODULE={settings} python -W ignore manage.py runserver {bind} {foreground}" ) sh("sleep 30") settings = f"REUSE_DB=1 DJANGO_SETTINGS_MODULE={settings}" From 892f4f4467200e0e119894bad5ce539bb8e05e50 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Mon, 10 Jul 2023 12:27:53 +0200 Subject: [PATCH 060/330] Docker compose uses the published image (#11238) --- docker-compose-dev.yml | 171 +++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 6 +- 2 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 docker-compose-dev.yml diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 00000000000..1ba2fec619f --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,171 @@ +version: '3.9' + +# Common Django template for GeoNode and Celery services below +x-common-django: + &default-common-django + image: geonode:local + restart: on-failure + env_file: + - .env + volumes: + # - '.:/usr/src/geonode' + - statics:/mnt/volumes/statics + - geoserver-data-dir:/geoserver_data/data + - backup-restore:/backup_restore + - data:/data + - tmp:/tmp + depends_on: + db: + condition: service_healthy + geoserver: + condition: service_healthy + +services: + + # Our custom django application. It includes Geonode. + django: + << : *default-common-django + build: + context: ./ + dockerfile: Dockerfile + container_name: django4${COMPOSE_PROJECT_NAME} + healthcheck: + test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8001/" + start_period: 60s + interval: 60s + timeout: 10s + retries: 10 + environment: + - IS_CELERY=False + entrypoint: ["/usr/src/geonode/entrypoint.sh"] + command: "uwsgi --ini /usr/src/geonode/uwsgi.ini" + + # Celery worker that executes celery tasks created by Django. + celery: + << : *default-common-django + image: geonode:local + container_name: celery4${COMPOSE_PROJECT_NAME} + depends_on: + - django + environment: + - IS_CELERY=True + entrypoint: ["/usr/src/geonode/entrypoint.sh"] + command: "celery-cmd" + + # Nginx is serving django static and media files and proxies to django and geonode + geonode: + image: geonode/nginx:4.0 + build: ./scripts/docker/nginx/ + container_name: nginx4${COMPOSE_PROJECT_NAME} + environment: + - HTTPS_HOST=${HTTPS_HOST} + - HTTP_HOST=${HTTP_HOST} + - HTTPS_PORT=${HTTPS_PORT} + - HTTP_PORT=${HTTP_PORT} + - LETSENCRYPT_MODE=${LETSENCRYPT_MODE} + - RESOLVER=127.0.0.11 + ports: + - "${HTTP_PORT}:80" + - "${HTTPS_PORT}:443" + volumes: + - nginx-confd:/etc/nginx + - nginx-certificates:/geonode-certificates + - statics:/mnt/volumes/statics + restart: on-failure + + # Gets and installs letsencrypt certificates + letsencrypt: + image: geonode/letsencrypt:4.0 + build: ./scripts/docker/letsencrypt/ + container_name: letsencrypt4${COMPOSE_PROJECT_NAME} + environment: + - HTTPS_HOST=${HTTPS_HOST} + - HTTP_HOST=${HTTP_HOST} + - ADMIN_EMAIL=${ADMIN_EMAIL} + - LETSENCRYPT_MODE=${LETSENCRYPT_MODE} + volumes: + - nginx-certificates:/geonode-certificates + restart: on-failure + + # Geoserver backend + geoserver: + image: geonode/geoserver:2.23.0 + container_name: geoserver4${COMPOSE_PROJECT_NAME} + healthcheck: + test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8080/geoserver/ows" + start_period: 60s + interval: 60s + timeout: 10s + retries: 10 + env_file: + - .env + volumes: + - statics:/mnt/volumes/statics + - geoserver-data-dir:/geoserver_data/data + - backup-restore:/backup_restore + - data:/data + - tmp:/tmp + restart: on-failure + depends_on: + db: + condition: service_healthy + data-dir-conf: + condition: service_healthy + + data-dir-conf: + image: geonode/geoserver_data:2.23.0 + container_name: gsconf4${COMPOSE_PROJECT_NAME} + entrypoint: sleep infinity + volumes: + - geoserver-data-dir:/geoserver_data/data + restart: on-failure + healthcheck: + test: "ls -A '/geoserver_data/data' | wc -l" + + # PostGIS database. + db: + # use geonode official postgis 13 image + image: geonode/postgis:13 + command: postgres -c "max_connections=${POSTGRESQL_MAX_CONNECTIONS}" + container_name: db4${COMPOSE_PROJECT_NAME} + env_file: + - .env + volumes: + - dbdata:/var/lib/postgresql/data + - dbbackups:/pg_backups + restart: on-failure + healthcheck: + test: "pg_isready -d postgres -U postgres" + # uncomment to enable remote connections to postgres + #ports: + # - "5432:5432" + + # Vanilla RabbitMQ service. This is needed by celery + rabbitmq: + image: rabbitmq:3.7-alpine + container_name: rabbitmq4${COMPOSE_PROJECT_NAME} + volumes: + - rabbitmq:/var/lib/rabbitmq + restart: on-failure + +volumes: + statics: + name: ${COMPOSE_PROJECT_NAME}-statics + nginx-confd: + name: ${COMPOSE_PROJECT_NAME}-nginxconfd + nginx-certificates: + name: ${COMPOSE_PROJECT_NAME}-nginxcerts + geoserver-data-dir: + name: ${COMPOSE_PROJECT_NAME}-gsdatadir + dbdata: + name: ${COMPOSE_PROJECT_NAME}-dbdata + dbbackups: + name: ${COMPOSE_PROJECT_NAME}-dbbackups + backup-restore: + name: ${COMPOSE_PROJECT_NAME}-backup-restore + data: + name: ${COMPOSE_PROJECT_NAME}-data + tmp: + name: ${COMPOSE_PROJECT_NAME}-tmp + rabbitmq: + name: ${COMPOSE_PROJECT_NAME}-rabbitmq diff --git a/docker-compose.yml b/docker-compose.yml index a4fc63d9b88..3af97ca2ebc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ version: '3.9' # Common Django template for GeoNode and Celery services below x-common-django: &default-common-django - image: geonode/geonode:4.0 + image: geonode/geonode:latest-ubuntu-22.10 restart: on-failure env_file: - .env @@ -25,9 +25,6 @@ services: # Our custom django application. It includes Geonode. django: << : *default-common-django - build: - context: ./ - dockerfile: Dockerfile container_name: django4${COMPOSE_PROJECT_NAME} healthcheck: test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8001/" @@ -43,7 +40,6 @@ services: # Celery worker that executes celery tasks created by Django. celery: << : *default-common-django - image: geonode/geonode:4.0 container_name: celery4${COMPOSE_PROJECT_NAME} depends_on: - django From cb2b4c001441fef3418c63417ecb44ca9dcdb688 Mon Sep 17 00:00:00 2001 From: etj Date: Fri, 7 Jul 2023 12:26:30 +0200 Subject: [PATCH 061/330] [#11226] Faceting: improvement: key to filter - step 1 --- geonode/facets/providers/baseinfo.py | 3 ++- geonode/facets/providers/category.py | 3 ++- geonode/facets/providers/keyword.py | 3 ++- geonode/facets/providers/region.py | 3 ++- geonode/facets/providers/thesaurus.py | 3 ++- geonode/facets/providers/users.py | 3 ++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/geonode/facets/providers/baseinfo.py b/geonode/facets/providers/baseinfo.py index b1f55129be3..c1178ed69ec 100644 --- a/geonode/facets/providers/baseinfo.py +++ b/geonode/facets/providers/baseinfo.py @@ -38,7 +38,8 @@ def name(self) -> str: def get_info(self, lang="en") -> dict: return { "name": self.name, - "key": "filter{resource_type.in}", + "key": "filter{resource_type.in}", # deprecated + "filter": "filter{resource_type.in}", "label": "Resource type", "type": FACET_TYPE_BASE, "hierarchical": True, diff --git a/geonode/facets/providers/category.py b/geonode/facets/providers/category.py index e0f24e29044..ef6f3a27ab9 100644 --- a/geonode/facets/providers/category.py +++ b/geonode/facets/providers/category.py @@ -39,7 +39,8 @@ def name(self) -> str: def get_info(self, lang="en") -> dict: return { "name": self.name, - "key": "filter{category.identifier}", + "key": "filter{category.identifier}", # deprecated + "filter": "filter{category.identifier}", "label": "Category", "type": FACET_TYPE_CATEGORY, "hierarchical": False, diff --git a/geonode/facets/providers/keyword.py b/geonode/facets/providers/keyword.py index 4a82e9a3dbc..c63e19ce1ce 100644 --- a/geonode/facets/providers/keyword.py +++ b/geonode/facets/providers/keyword.py @@ -39,7 +39,8 @@ def name(self) -> str: def get_info(self, lang="en") -> dict: return { "name": self.name, - "key": "filter{keywords.slug.in}", + "key": "filter{keywords.slug.in}", # deprecated + "filter": "filter{keywords.slug.in}", "label": "Keyword", "type": FACET_TYPE_KEYWORD, "order": 2, diff --git a/geonode/facets/providers/region.py b/geonode/facets/providers/region.py index a0df25b2ea6..d46e6cd0b4d 100644 --- a/geonode/facets/providers/region.py +++ b/geonode/facets/providers/region.py @@ -39,7 +39,8 @@ def name(self) -> str: def get_info(self, lang="en") -> dict: return { "name": self.name, - "key": "filter{regions.code.in}", + "key": "filter{regions.code.in}", # deprecated + "filter": "filter{regions.code.in}", "label": "Region", "type": FACET_TYPE_PLACE, "hierarchical": False, # source data is hierarchical, but this implementation is flat diff --git a/geonode/facets/providers/thesaurus.py b/geonode/facets/providers/thesaurus.py index d9897f6139d..5b4bb92f387 100644 --- a/geonode/facets/providers/thesaurus.py +++ b/geonode/facets/providers/thesaurus.py @@ -45,7 +45,8 @@ def name(self) -> str: def get_info(self, lang="en") -> dict: return { "name": self._name, - "key": "filter{tkeywords}", + "key": "filter{tkeywords}", # deprecated + "filter": "filter{tkeywords}", "label": self.labels.get(lang, self.label), "is_localized": self.labels.get(lang, None) is not None, "type": FACET_TYPE_THESAURUS, diff --git a/geonode/facets/providers/users.py b/geonode/facets/providers/users.py index aedb2fc06f2..4845cade297 100644 --- a/geonode/facets/providers/users.py +++ b/geonode/facets/providers/users.py @@ -39,7 +39,8 @@ def name(self) -> str: def get_info(self, lang="en") -> dict: return { "name": "owner", - "key": "owner", + "key": "owner", # deprecated + "filter": "owner", "label": "Owner", "type": FACET_TYPE_USER, "hierarchical": False, From 92320381e2ee594f5ca4c6a0698c7ca34ffef7e3 Mon Sep 17 00:00:00 2001 From: etj Date: Fri, 7 Jul 2023 18:03:18 +0200 Subject: [PATCH 062/330] [Fixes #11233] Faceting: improvement: facet configuration --- geonode/facets/models.py | 13 ++++++++----- geonode/facets/providers/baseinfo.py | 9 ++++----- geonode/facets/providers/category.py | 6 ++---- geonode/facets/providers/keyword.py | 5 ++--- geonode/facets/providers/region.py | 6 ++---- geonode/facets/providers/thesaurus.py | 13 +++++++------ geonode/facets/providers/users.py | 6 ++---- geonode/facets/tests.py | 26 +++++++++++++++++++++++++- geonode/facets/views.py | 10 ++++++++++ geonode/settings.py | 18 +++++++++--------- 10 files changed, 71 insertions(+), 41 deletions(-) diff --git a/geonode/facets/models.py b/geonode/facets/models.py index 4beffbb1ac6..1b7f9964f7b 100644 --- a/geonode/facets/models.py +++ b/geonode/facets/models.py @@ -39,6 +39,9 @@ class FacetProvider: Provides access to the facet information and the related topics """ + def __init__(self, **kwargs): + self.config = kwargs.get("config", {}).copy() + def __str__(self): return f"{self.__class__.__name__}[{self.name}]" @@ -51,7 +54,7 @@ def name(self) -> str: """ self.get_info()["name"] - def get_info(self, lang="en") -> dict: + def get_info(self, lang="en", **kwargs) -> dict: """ Get the basic info for this provider, as a dict with these keys: - 'name': the name of the provider (the one returned by name()) @@ -127,10 +130,10 @@ def _load_facets_configuration(self) -> None: logger.info("Initializing Facets") - if providers := getattr(settings, "FACET_PROVIDERS", []): - _providers = [import_string(module_path) for module_path in providers] - for provider in _providers: - provider.register(self) + for providerconf in getattr(settings, "FACET_PROVIDERS", []): + clz = providerconf["class"] + provider = import_string(clz) + provider.register(self, config=providerconf.get("config", {})) def register_facet_provider(self, provider: FacetProvider): logger.info(f"Registering {provider}") diff --git a/geonode/facets/providers/baseinfo.py b/geonode/facets/providers/baseinfo.py index c1178ed69ec..7a3ee9b1ffb 100644 --- a/geonode/facets/providers/baseinfo.py +++ b/geonode/facets/providers/baseinfo.py @@ -35,7 +35,7 @@ class ResourceTypeFacetProvider(FacetProvider): def name(self) -> str: return "resourcetype" - def get_info(self, lang="en") -> dict: + def get_info(self, lang="en", **kwargs) -> dict: return { "name": self.name, "key": "filter{resource_type.in}", # deprecated @@ -43,7 +43,6 @@ def get_info(self, lang="en") -> dict: "label": "Resource type", "type": FACET_TYPE_BASE, "hierarchical": True, - "order": 0, } def get_facet_items( @@ -92,7 +91,7 @@ def get_facet_items( @classmethod def register(cls, registry, **kwargs) -> None: - registry.register_facet_provider(ResourceTypeFacetProvider()) + registry.register_facet_provider(ResourceTypeFacetProvider(**kwargs)) class FeaturedFacetProvider(FacetProvider): @@ -104,7 +103,7 @@ class FeaturedFacetProvider(FacetProvider): def name(self) -> str: return "featured" - def get_info(self, lang="en") -> dict: + def get_info(self, lang="en", **kwargs) -> dict: return { "name": self.name, "key": "filter{featured}", @@ -145,4 +144,4 @@ def get_facet_items( @classmethod def register(cls, registry, **kwargs) -> None: - registry.register_facet_provider(FeaturedFacetProvider()) + registry.register_facet_provider(FeaturedFacetProvider(**kwargs)) diff --git a/geonode/facets/providers/category.py b/geonode/facets/providers/category.py index ef6f3a27ab9..9e027dec1e9 100644 --- a/geonode/facets/providers/category.py +++ b/geonode/facets/providers/category.py @@ -36,15 +36,13 @@ class CategoryFacetProvider(FacetProvider): def name(self) -> str: return "category" - def get_info(self, lang="en") -> dict: + def get_info(self, lang="en", **kwargs) -> dict: return { "name": self.name, "key": "filter{category.identifier}", # deprecated "filter": "filter{category.identifier}", "label": "Category", "type": FACET_TYPE_CATEGORY, - "hierarchical": False, - "order": 2, } def get_facet_items( @@ -99,4 +97,4 @@ def get_topics(self, keys: list, lang="en", **kwargs) -> list: @classmethod def register(cls, registry, **kwargs) -> None: - registry.register_facet_provider(CategoryFacetProvider()) + registry.register_facet_provider(CategoryFacetProvider(**kwargs)) diff --git a/geonode/facets/providers/keyword.py b/geonode/facets/providers/keyword.py index c63e19ce1ce..570f2e04cbb 100644 --- a/geonode/facets/providers/keyword.py +++ b/geonode/facets/providers/keyword.py @@ -36,14 +36,13 @@ class KeywordFacetProvider(FacetProvider): def name(self) -> str: return "keyword" - def get_info(self, lang="en") -> dict: + def get_info(self, lang="en", **kwargs) -> dict: return { "name": self.name, "key": "filter{keywords.slug.in}", # deprecated "filter": "filter{keywords.slug.in}", "label": "Keyword", "type": FACET_TYPE_KEYWORD, - "order": 2, } def get_facet_items( @@ -94,4 +93,4 @@ def get_topics(self, keys: list, lang="en", **kwargs) -> list: @classmethod def register(cls, registry, **kwargs) -> None: - registry.register_facet_provider(KeywordFacetProvider()) + registry.register_facet_provider(KeywordFacetProvider(**kwargs)) diff --git a/geonode/facets/providers/region.py b/geonode/facets/providers/region.py index d46e6cd0b4d..a4935d35468 100644 --- a/geonode/facets/providers/region.py +++ b/geonode/facets/providers/region.py @@ -36,15 +36,13 @@ class RegionFacetProvider(FacetProvider): def name(self) -> str: return "region" - def get_info(self, lang="en") -> dict: + def get_info(self, lang="en", **kwargs) -> dict: return { "name": self.name, "key": "filter{regions.code.in}", # deprecated "filter": "filter{regions.code.in}", "label": "Region", "type": FACET_TYPE_PLACE, - "hierarchical": False, # source data is hierarchical, but this implementation is flat - "order": 2, } def get_facet_items( @@ -95,4 +93,4 @@ def get_topics(self, keys: list, lang="en", **kwargs) -> list: @classmethod def register(cls, registry, **kwargs) -> None: - registry.register_facet_provider(RegionFacetProvider()) + registry.register_facet_provider(RegionFacetProvider(**kwargs)) diff --git a/geonode/facets/providers/thesaurus.py b/geonode/facets/providers/thesaurus.py index 5b4bb92f387..91823f00ac4 100644 --- a/geonode/facets/providers/thesaurus.py +++ b/geonode/facets/providers/thesaurus.py @@ -32,17 +32,20 @@ class ThesaurusFacetProvider(FacetProvider): Implements faceting for a given Thesaurus """ - def __init__(self, identifier, title, order, labels: dict): + def __init__(self, identifier, title, order, labels: dict, **kwargs): + super().__init__(**kwargs) + self._name = identifier self.label = title - self.order = order self.labels = labels + self.config["order"] = order + @property def name(self) -> str: return self._name - def get_info(self, lang="en") -> dict: + def get_info(self, lang="en", **kwargs) -> dict: return { "name": self._name, "key": "filter{tkeywords}", # deprecated @@ -50,8 +53,6 @@ def get_info(self, lang="en") -> dict: "label": self.labels.get(lang, self.label), "is_localized": self.labels.get(lang, None) is not None, "type": FACET_TYPE_THESAURUS, - "hierarchical": False, - "order": self.order, } def get_facet_items( @@ -153,5 +154,5 @@ def register(cls, registry, **kwargs) -> None: logger.info("Creating providers for %r", ret) for t in ret.values(): registry.register_facet_provider( - ThesaurusFacetProvider(t["identifier"], t["title"], t["order"], t["labels"]) + ThesaurusFacetProvider(t["identifier"], t["title"], t["order"], t["labels"], **kwargs) ) diff --git a/geonode/facets/providers/users.py b/geonode/facets/providers/users.py index 4845cade297..a6399952868 100644 --- a/geonode/facets/providers/users.py +++ b/geonode/facets/providers/users.py @@ -36,15 +36,13 @@ class OwnerFacetProvider(FacetProvider): def name(self) -> str: return "owner" - def get_info(self, lang="en") -> dict: + def get_info(self, lang="en", **kwargs) -> dict: return { "name": "owner", "key": "owner", # deprecated "filter": "owner", "label": "Owner", "type": FACET_TYPE_USER, - "hierarchical": False, - "order": 5, } def get_facet_items( @@ -95,4 +93,4 @@ def get_topics(self, keys: list, lang="en", **kwargs) -> list: @classmethod def register(cls, registry, **kwargs) -> None: - registry.register_facet_provider(OwnerFacetProvider()) + registry.register_facet_provider(OwnerFacetProvider(**kwargs)) diff --git a/geonode/facets/tests.py b/geonode/facets/tests.py index cc6d9a8e0ec..50a7cef842c 100644 --- a/geonode/facets/tests.py +++ b/geonode/facets/tests.py @@ -69,7 +69,7 @@ def _create_thesauri(cls): cls.thesauri_k = {} for tn in range(2): - t = Thesaurus.objects.create(identifier=f"t_{tn}", title=f"Thesaurus {tn}") + t = Thesaurus.objects.create(identifier=f"t_{tn}", title=f"Thesaurus {tn}", order=100 + tn * 10) cls.thesauri[tn] = t for tl in ( "en", @@ -335,6 +335,30 @@ def test_prefiltering(self): self.assertEqual(totals, obj["topics"]["total"], f"Bad totals for facet '{facet} and filter {filters}") self.assertEqual(count0, obj["topics"]["items"][0]["count"], f"Bad count0 for facet '{facet}") + def test_config(self): + for facet, type, order in ( + ("resourcetype", None, None), + ("t_0", "select", 100), + ("category", "select", 5), + ("region", "select", 7), + ("owner", "select", 8), + ): + req = self.rf.get(reverse("get_facet", args=[facet]), data={"include_config": True}) + res: JsonResponse = views.get_facet(req, facet) + obj = json.loads(res.content) + self.assertIn("config", obj, "Config info not found in payload") + conf = obj["config"] + + if type is None: + self.assertNotIn("type", conf) + else: + self.assertEqual(type, conf["type"], "Unexpected type") + + if order is None: + self.assertNotIn("order", conf) + else: + self.assertEqual(order, conf["order"], "Unexpected order") + def test_user_auth(self): # make sure the user authorization pre-filters the visible resources # TODO test diff --git a/geonode/facets/views.py b/geonode/facets/views.py index 717eabfc57a..e3a8185ae4c 100644 --- a/geonode/facets/views.py +++ b/geonode/facets/views.py @@ -38,6 +38,7 @@ PARAM_LANG = "lang" PARAM_ADD_LINKS = "add_links" PARAM_INCLUDE_TOPICS = "include_topics" +PARAM_INCLUDE_CONFIG = "include_config" PARAM_TOPIC_CONTAINS = "topic_contains" logger = logging.getLogger(__name__) @@ -49,12 +50,17 @@ def list_facets(request, **kwargs): lang, lang_requested = _resolve_language(request) add_links = _resolve_boolean(request, PARAM_ADD_LINKS, False) include_topics = _resolve_boolean(request, PARAM_INCLUDE_TOPICS, False) + include_config = _resolve_boolean(request, PARAM_INCLUDE_CONFIG, False) facets = [] for provider in facet_registry.get_providers(): logger.debug("Fetching data from provider %r", provider) info = provider.get_info(lang=lang) + + if include_config: + info["config"] = provider.config + if add_links: link_args = {PARAM_ADD_LINKS: True} if lang_requested: # only add lang param if specified in current call @@ -83,11 +89,15 @@ def get_facet(request, facet): # parse some query params lang, lang_requested = _resolve_language(request) add_link = _resolve_boolean(request, PARAM_ADD_LINKS, False) + include_config = _resolve_boolean(request, PARAM_INCLUDE_CONFIG, False) + topic_contains = request.GET.get(PARAM_TOPIC_CONTAINS, None) page = int(request.GET.get(PARAM_PAGE, 0)) page_size = int(request.GET.get(PARAM_PAGE_SIZE, DEFAULT_FACET_PAGE_SIZE)) info = provider.get_info(lang) + if include_config: + info["config"] = provider.config qs = _prefilter_topics(request) topics = _get_topics( diff --git a/geonode/settings.py b/geonode/settings.py index 14b58fec600..4a90f085e68 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -2335,12 +2335,12 @@ def get_geonode_catalogue_service(): INSTALLED_APPS += ("geonode.facets",) GEONODE_APPS += ("geonode.facets",) -FACET_PROVIDERS = ( - "geonode.facets.providers.baseinfo.ResourceTypeFacetProvider", - "geonode.facets.providers.baseinfo.FeaturedFacetProvider", - "geonode.facets.providers.category.CategoryFacetProvider", - "geonode.facets.providers.keyword.KeywordFacetProvider", - "geonode.facets.providers.users.OwnerFacetProvider", - "geonode.facets.providers.thesaurus.ThesaurusFacetProvider", - "geonode.facets.providers.region.RegionFacetProvider", -) +FACET_PROVIDERS = [ + {"class": "geonode.facets.providers.baseinfo.ResourceTypeFacetProvider"}, + {"class": "geonode.facets.providers.baseinfo.FeaturedFacetProvider"}, + {"class": "geonode.facets.providers.category.CategoryFacetProvider", "config": {"order": 5, "type": "select"}}, + {"class": "geonode.facets.providers.keyword.KeywordFacetProvider", "config": {"order": 6, "type": "select"}}, + {"class": "geonode.facets.providers.region.RegionFacetProvider", "config": {"order": 7, "type": "select"}}, + {"class": "geonode.facets.providers.users.OwnerFacetProvider", "config": {"order": 8, "type": "select"}}, + {"class": "geonode.facets.providers.thesaurus.ThesaurusFacetProvider", "config": {"type": "select"}}, +] From 2a0eedbf28ea0b06f2c4c8feff94a321298df079 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Tue, 11 Jul 2023 12:18:29 +0200 Subject: [PATCH 063/330] [Fixes #11246] Create thumbnails for remote documents (#11247) * Create thumbnails for remote documents * removed unused import * adop storage manager and rendere remote pdf * tests for remote doc thumbanil * remove unused requests * fix document type checks --- geonode/documents/models.py | 6 ++-- geonode/documents/tasks.py | 57 +++++++++++++++++++++++++------------ geonode/documents/tests.py | 18 +++++++++++- geonode/resource/manager.py | 2 +- geonode/storage/manager.py | 2 +- 5 files changed, 61 insertions(+), 24 deletions(-) diff --git a/geonode/documents/models.py b/geonode/documents/models.py index e9a81dac1dc..402194f9908 100644 --- a/geonode/documents/models.py +++ b/geonode/documents/models.py @@ -116,17 +116,17 @@ def mime_type(self): @property def is_audio(self): AUDIOTYPES = [_e for _e, _t in DOCUMENT_TYPE_MAP.items() if _t == "audio"] - return self.is_file and self.extension.lower() in AUDIOTYPES + return self.extension and self.extension.lower() in AUDIOTYPES @property def is_image(self): IMGTYPES = [_e for _e, _t in DOCUMENT_TYPE_MAP.items() if _t == "image"] - return self.is_file and self.extension.lower() in IMGTYPES + return self.extension and self.extension.lower() in IMGTYPES @property def is_video(self): VIDEOTYPES = [_e for _e, _t in DOCUMENT_TYPE_MAP.items() if _t == "video"] - return self.is_file and self.extension.lower() in VIDEOTYPES + return self.extension and self.extension.lower() in VIDEOTYPES @property def class_name(self): diff --git a/geonode/documents/tasks.py b/geonode/documents/tasks.py index a5ddb247a19..e0ed9617354 100644 --- a/geonode/documents/tasks.py +++ b/geonode/documents/tasks.py @@ -25,7 +25,7 @@ from celery.utils.log import get_task_logger from geonode.celery_app import app -from geonode.storage.manager import storage_manager +from geonode.storage.manager import StorageManager from ..base.models import ResourceBase from .models import Document @@ -90,6 +90,8 @@ def create_document_thumbnail(self, object_id): """ logger.debug(f"Generating thumbnail for document #{object_id}.") + storage_manager = StorageManager() + try: document = Document.objects.get(id=object_id) except Document.DoesNotExist: @@ -98,33 +100,52 @@ def create_document_thumbnail(self, object_id): image_file = None thumbnail_content = None + remove_tmp_file = False centering = (0.5, 0.5) - if document.is_image: - dname = storage_manager.path(document.files[0]) - if storage_manager.exists(dname): - image_file = storage_manager.open(dname, "rb") + doc_path = None + if document.files: + doc_path = storage_manager.path(document.files[0]) + elif document.doc_url: + doc_path = document.doc_url + remove_tmp_file = True + if document.is_image: try: - image = Image.open(image_file) - with io.BytesIO() as output: - image.save(output, format="PNG") - thumbnail_content = output.getvalue() - output.close() + image_file = storage_manager.open(doc_path) except Exception as e: - logger.debug(f"Could not generate thumbnail: {e}") - finally: - if image_file is not None: - image_file.close() - - elif doc_renderer.supports(document.files[0]): + logger.debug(f"Could not generate thumbnail from remote document {document.doc_url}: {e}") + + if image_file: + try: + image = Image.open(image_file) + with io.BytesIO() as output: + image.save(output, format="PNG") + thumbnail_content = output.getvalue() + output.close() + except Exception as e: + logger.debug(f"Could not generate thumbnail: {e}") + finally: + if image_file is not None: + image_file.close() + if remove_tmp_file: + storage_manager.delete(doc_path) + + elif doc_renderer.supports(doc_path): + # in case it's a remote document we want to retrieve it first + if document.doc_url: + doc_path = storage_manager.open(doc_path).name + remove_tmp_file = True try: - thumbnail_content = doc_renderer.render(document.files[0]) - preferred_centering = doc_renderer.preferred_crop_centering(document.files[0]) + thumbnail_content = doc_renderer.render(doc_path) + preferred_centering = doc_renderer.preferred_crop_centering(doc_path) if preferred_centering is not None: centering = preferred_centering except Exception as e: print(e) + finally: + if remove_tmp_file: + storage_manager.delete(doc_path) if not thumbnail_content: logger.warning(f"Thumbnail for document #{object_id} empty.") ResourceBase.objects.filter(id=document.id).update(thumbnail_url=None) diff --git a/geonode/documents/tests.py b/geonode/documents/tests.py index 369256492fa..0da85504577 100644 --- a/geonode/documents/tests.py +++ b/geonode/documents/tests.py @@ -253,7 +253,7 @@ def test_non_image_documents_thumbnail(self): finally: Document.objects.filter(title="Non img File Doc").delete() - def test_image_documents_thumbnail(self): + def test_documents_thumbnail(self): self.client.login(username="admin", password="admin") try: # test image doc @@ -274,6 +274,22 @@ def test_image_documents_thumbnail(self): self.assertEqual(file.size, (400, 200)) # check thumbnail qualty and extention self.assertEqual(file.format, "JPEG") + data = { + "title": "Remote img File Doc", + "doc_url": "https://raw.githubusercontent.com/GeoNode/geonode/master/geonode/documents/tests/data/img.gif", + "extension": "gif", + } + with self.settings(THUMBNAIL_SIZE={"width": 400, "height": 200}): + self.client.post(reverse("document_upload"), data=data) + d = Document.objects.get(title="Remote img File Doc") + self.assertIsNotNone(d.thumbnail_url) + thumb_file = os.path.join( + settings.MEDIA_ROOT, f"thumbs/{os.path.basename(urlparse(d.thumbnail_url).path)}" + ) + file = Image.open(thumb_file) + self.assertEqual(file.size, (400, 200)) + # check thumbnail qualty and extention + self.assertEqual(file.format, "JPEG") # test pdf doc with open(os.path.join(f"{self.project_root}", "tests/data/pdf_doc.pdf"), "rb") as f: data = { diff --git a/geonode/resource/manager.py b/geonode/resource/manager.py index 28d52999e5f..38ca1a0e7c3 100644 --- a/geonode/resource/manager.py +++ b/geonode/resource/manager.py @@ -944,7 +944,7 @@ def set_thumbnail( file_name = _generate_thumbnail_name(_resource.get_real_instance()) _resource.save_thumbnail(file_name, thumbnail) else: - if instance and instance.files and isinstance(instance.get_real_instance(), Document): + if instance and isinstance(instance.get_real_instance(), Document): if overwrite or not instance.thumbnail_url: create_document_thumbnail.apply((instance.id,)) self._concrete_resource_manager.set_thumbnail( diff --git a/geonode/storage/manager.py b/geonode/storage/manager.py index f4adf983d2a..421ce6946e3 100644 --- a/geonode/storage/manager.py +++ b/geonode/storage/manager.py @@ -249,7 +249,7 @@ def clone_remote_files(self) -> Mapping: """ Using the data retriever object clone the remote path into a local temporary storage """ - self.data_retriever.get_paths(allow_transfer=True) + return self.data_retriever.get_paths(allow_transfer=True) def get_retrieved_paths(self) -> Mapping: """ From 61f69c458a7f274a4c22960a6268c46644d14a4f Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Tue, 11 Jul 2023 15:58:20 +0200 Subject: [PATCH 064/330] [Fixes #11249] Mark remote documents with sourcetype REMOTE (#11250) * mark remote documents with sourcetype REMOTE * added tests * fix test --- geonode/documents/api/tests.py | 17 +++++++++++++++++ geonode/documents/api/views.py | 2 ++ geonode/documents/tests.py | 16 ++++++++++++++++ geonode/documents/views.py | 6 +++++- 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/geonode/documents/api/tests.py b/geonode/documents/api/tests.py index dbb3b13588b..1951da4e2e1 100644 --- a/geonode/documents/api/tests.py +++ b/geonode/documents/api/tests.py @@ -28,6 +28,7 @@ from geonode import settings from geonode.base.populate_test_data import create_models +from geonode.base.enumerations import SOURCE_TYPE_REMOTE from geonode.documents.models import Document logger = logging.getLogger(__name__) @@ -172,6 +173,22 @@ def test_creation_from_url_should_create_the_doc(self): created_doc_url = actual.json().get("document", {}).get("doc_url", "") self.assertEqual(created_doc_url, doc_url) + def test_remote_document_is_marked_remote(self): + """Tests creating an external document set its sourcetype to REMOTE.""" + self.client.force_login(self.admin) + doc_url = "https://example.com/image" + payload = { + "document": { + "title": "A remote document is remote", + "doc_url": doc_url, + "extension": "jpeg", + } + } + actual = self.client.post(self.url, data=payload, format="json") + self.assertEqual(201, actual.status_code) + created_sourcetype = actual.json().get("document", {}).get("sourcetype", "") + self.assertEqual(created_sourcetype, SOURCE_TYPE_REMOTE) + def test_either_path_or_url_doc(self): """ If file_path is not available, should raise error diff --git a/geonode/documents/api/views.py b/geonode/documents/api/views.py index 05159272363..720cb7639ba 100644 --- a/geonode/documents/api/views.py +++ b/geonode/documents/api/views.py @@ -31,6 +31,7 @@ from geonode.base.api.filters import DynamicSearchFilter, ExtentFilter from geonode.base.api.pagination import GeoNodeApiPagination from geonode.base.api.permissions import UserHasPerms +from geonode.base import enumerations from geonode.documents.api.exceptions import DocumentException from geonode.documents.models import Document @@ -123,6 +124,7 @@ def perform_create(self, serializer): payload["files"] = [manager.get_retrieved_paths().get("base_file")] if doc_url: payload["doc_url"] = doc_url + payload["sourcetype"] = enumerations.SOURCE_TYPE_REMOTE resource = serializer.save(**payload) diff --git a/geonode/documents/tests.py b/geonode/documents/tests.py index 0da85504577..8bf91a88529 100644 --- a/geonode/documents/tests.py +++ b/geonode/documents/tests.py @@ -47,6 +47,7 @@ from geonode.layers.models import Dataset from geonode.compat import ensure_string from geonode.base.models import License, Region +from geonode.base.enumerations import SOURCE_TYPE_REMOTE from geonode.documents import DocumentsAppConfig from geonode.resource.manager import resource_manager from geonode.documents.forms import DocumentFormMixin @@ -137,6 +138,21 @@ def test_create_document_with_rel(self, thumb): self.assertEqual(Document.objects.get(pk=c.id).title, "theimg") self.assertEqual(DocumentResourceLink.objects.get(pk=_d.id).object_id, m.id) + def test_remote_document_is_marked_remote(self): + """Tests creating an external document set its sourcetype to REMOTE.""" + self.client.login(username="admin", password="admin") + form_data = { + "title": "A remote document through form is remote", + "doc_url": "http://www.geonode.org/map.pdf", + } + + response = self.client.post(reverse("document_upload"), data=form_data) + + self.assertEqual(response.status_code, 302) + + d = Document.objects.get(title="A remote document through form is remote") + self.assertEqual(d.sourcetype, SOURCE_TYPE_REMOTE) + def test_create_document_url(self): """Tests creating an external document instead of a file.""" diff --git a/geonode/documents/views.py b/geonode/documents/views.py index 3ba50d2f6bc..ed4355fcee2 100644 --- a/geonode/documents/views.py +++ b/geonode/documents/views.py @@ -51,6 +51,7 @@ from geonode.security.utils import get_user_visible_groups, AdvancedSecurityWorkflowManager from geonode.base.forms import CategoryForm, TKeywordForm, ThesaurusAvailableForm from geonode.base.models import Thesaurus, TopicCategory +from geonode.base import enumerations from .utils import get_download_response @@ -184,7 +185,10 @@ def form_valid(self, form): None, resource_type=Document, defaults=dict( - owner=self.request.user, doc_url=doc_form.pop("doc_url", None), title=doc_form.pop("title", None) + owner=self.request.user, + doc_url=doc_form.pop("doc_url", None), + title=doc_form.pop("title", None), + sourcetype=enumerations.SOURCE_TYPE_REMOTE, ), ) From 0842b134ece127c7a5fc54f732041f98a1c6b1ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Jul 2023 08:42:18 +0200 Subject: [PATCH 065/330] Bump jsonschema from 4.17.3 to 4.18.0 (#11245) * Bump jsonschema from 4.17.3 to 4.18.0 Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.17.3 to 4.18.0. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.17.3...v4.18.0) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 022221a3a3a..e6b588f93db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ vine==5.0.0 tqdm==4.65.0 Deprecated==1.2.14 wrapt==1.15.0 -jsonschema==4.17.3 +jsonschema==4.18.0 zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 diff --git a/setup.cfg b/setup.cfg index d30ae2d8c54..abfdb1a0903 100644 --- a/setup.cfg +++ b/setup.cfg @@ -46,7 +46,7 @@ install_requires = tqdm==4.65.0 Deprecated==1.2.14 wrapt==1.15.0 - jsonschema==4.17.3 + jsonschema==4.18.0 zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 From 84e85290cfc4b08b5f4a990dc23c74d492ffab0f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Jul 2023 08:57:28 +0200 Subject: [PATCH 066/330] Bump google-cloud-core from 2.3.2 to 2.3.3 (#11244) * Bump google-cloud-core from 2.3.2 to 2.3.3 Bumps [google-cloud-core](https://github.com/googleapis/python-cloud-core) from 2.3.2 to 2.3.3. - [Release notes](https://github.com/googleapis/python-cloud-core/releases) - [Changelog](https://github.com/googleapis/python-cloud-core/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/python-cloud-core/compare/v2.3.2...v2.3.3) --- updated-dependencies: - dependency-name: google-cloud-core dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index e6b588f93db..0669008598a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -112,7 +112,7 @@ django-bootstrap3-datetimepicker-2==2.8.3 django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 -google-cloud-core==2.3.2 +google-cloud-core==2.3.3 boto3==1.26.165 # Django Caches diff --git a/setup.cfg b/setup.cfg index abfdb1a0903..6719672bdbb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -137,7 +137,7 @@ install_requires = django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 - google-cloud-core==2.3.2 + google-cloud-core==2.3.3 boto3==1.26.165 # Django Caches From d779c443e5b7fc0c109aa12edb59bc248e8f57b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Jul 2023 08:57:55 +0200 Subject: [PATCH 067/330] Bump lxml from 4.9.2 to 4.9.3 (#11243) * Bump lxml from 4.9.2 to 4.9.3 Bumps [lxml](https://github.com/lxml/lxml) from 4.9.2 to 4.9.3. - [Release notes](https://github.com/lxml/lxml/releases) - [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt) - [Commits](https://github.com/lxml/lxml/compare/lxml-4.9.2...lxml-4.9.3) --- updated-dependencies: - dependency-name: lxml dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0669008598a..02b05af4d82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # native dependencies Pillow==10.0.0 -lxml==4.9.2 +lxml==4.9.3 psycopg2==2.9.6 Django==3.2.20 diff --git a/setup.cfg b/setup.cfg index 6719672bdbb..6351f23d76d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,7 +26,7 @@ setup_requires = install_requires = # native dependencies Pillow==10.0.0 - lxml==4.9.2 + lxml==4.9.3 psycopg2==2.9.6 Django==3.2.20 From 2da20b208cb02f2747663453d8137afea7efd240 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Jul 2023 08:58:41 +0200 Subject: [PATCH 068/330] Bump wandb from 0.15.4 to 0.15.5 (#11242) * Bump wandb from 0.15.4 to 0.15.5 Bumps [wandb](https://github.com/wandb/wandb) from 0.15.4 to 0.15.5. - [Release notes](https://github.com/wandb/wandb/releases) - [Changelog](https://github.com/wandb/wandb/blob/main/CHANGELOG.md) - [Commits](https://github.com/wandb/wandb/compare/v0.15.4...v0.15.5) --- updated-dependencies: - dependency-name: wandb dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 02b05af4d82..81494b1bc10 100644 --- a/requirements.txt +++ b/requirements.txt @@ -171,7 +171,7 @@ webdriver_manager==3.8.6 # Security and audit mistune==3.0.1 -wandb==0.15.4 +wandb==0.15.5 protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/setup.cfg b/setup.cfg index 6351f23d76d..fc454b6d816 100644 --- a/setup.cfg +++ b/setup.cfg @@ -196,7 +196,7 @@ install_requires = # Security and audit mistune==3.0.1 - wandb==0.15.4 + wandb==0.15.5 protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability From 572cde9ada2cd8171056d14d91fcb41dcd9b2b1d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Jul 2023 08:59:03 +0200 Subject: [PATCH 069/330] Bump sqlalchemy from 2.0.17 to 2.0.18 (#11241) * Bump sqlalchemy from 2.0.17 to 2.0.18 Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.17 to 2.0.18. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 81494b1bc10..7cce230a184 100644 --- a/requirements.txt +++ b/requirements.txt @@ -56,7 +56,7 @@ pyjwt==2.7.0 pyproj<3.7.0 OWSLib==0.29.2 pycsw==2.6.1 -SQLAlchemy==2.0.17 # required by PyCSW +SQLAlchemy==2.0.18 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 diff --git a/setup.cfg b/setup.cfg index fc454b6d816..bce71b8a3fb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -82,7 +82,7 @@ install_requires = pyproj<3.7.0 OWSLib==0.29.2 pycsw==2.6.1 - SQLAlchemy==2.0.17 # required by PyCSW + SQLAlchemy==2.0.18 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 From d7747b7ecd1051ca769027a4725ef71a67bfb3cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Jul 2023 09:00:14 +0200 Subject: [PATCH 070/330] Bump boto3 from 1.26.165 to 1.28.1 (#11240) * Bump boto3 from 1.26.165 to 1.28.1 Bumps [boto3](https://github.com/boto/boto3) from 1.26.165 to 1.28.1. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.165...1.28.1) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7cce230a184..e2ac13c71bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 -boto3==1.26.165 +boto3==1.28.1 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index bce71b8a3fb..2dacd3779a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 - boto3==1.26.165 + boto3==1.28.1 # Django Caches python-memcached<=1.59 From 2285c269296bf0058e2d5d6d2e8b8f02b31b3aee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Jul 2023 09:37:56 +0200 Subject: [PATCH 071/330] Bump black from 23.3.0 to 23.7.0 (#11253) * Bump black from 23.3.0 to 23.7.0 Bumps [black](https://github.com/psf/black) from 23.3.0 to 23.7.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/23.3.0...23.7.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index e2ac13c71bc..4539e9f56bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -153,7 +153,7 @@ invoke==2.1.3 coverage==7.2.7 requests-toolbelt==1.0.0 flake8==6.0.0 -black==23.3.0 +black==23.7.0 pytest==7.4.0 pytest-bdd==6.1.1 splinter==0.19.0 diff --git a/setup.cfg b/setup.cfg index 2dacd3779a2..121bace7dff 100644 --- a/setup.cfg +++ b/setup.cfg @@ -178,7 +178,7 @@ install_requires = coverage==7.2.7 requests-toolbelt==1.0.0 flake8==6.0.0 - black==23.3.0 + black==23.7.0 pytest==7.4.0 pytest-bdd==6.1.1 splinter==0.19.0 From 2315bee4c9504e5a4a2d5173c0a6bc19e98ba654 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Jul 2023 09:38:14 +0200 Subject: [PATCH 072/330] Bump django-cors-headers from 4.1.0 to 4.2.0 (#11254) * Bump django-cors-headers from 4.1.0 to 4.2.0 Bumps [django-cors-headers](https://github.com/adamchainz/django-cors-headers) from 4.1.0 to 4.2.0. - [Changelog](https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst) - [Commits](https://github.com/adamchainz/django-cors-headers/compare/4.1.0...4.2.0) --- updated-dependencies: - dependency-name: django-cors-headers dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4539e9f56bd..17733ac070d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -134,7 +134,7 @@ sherlock==0.4.1 # required by monitoring psutil==5.9.5 -django-cors-headers==4.1.0 +django-cors-headers==4.2.0 user-agents django-user-agents xmljson diff --git a/setup.cfg b/setup.cfg index 121bace7dff..6f5b98c65aa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -159,7 +159,7 @@ install_requires = # required by monitoring psutil==5.9.5 - django-cors-headers==4.1.0 + django-cors-headers==4.2.0 user-agents django-user-agents xmljson From d54d04afa5c1f7e8e4d2d1bea4a6f19051ba7b05 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Wed, 12 Jul 2023 16:37:22 +0200 Subject: [PATCH 073/330] [Fixes #11256] Assign regions based on contains and overlaps (#11257) * Assign regions based on contains and overlaps * tests * fix E501 --- geonode/base/models.py | 17 +++++++++++++++- geonode/base/tests.py | 41 ++++++++++++++++++++++++++++++++++++++- geonode/resource/utils.py | 10 ++-------- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/geonode/base/models.py b/geonode/base/models.py index 6da70b6994c..1efe7288b0d 100644 --- a/geonode/base/models.py +++ b/geonode/base/models.py @@ -41,7 +41,7 @@ from django.contrib.auth import get_user_model from django.db.models.fields.json import JSONField from django.utils.functional import cached_property, classproperty -from django.contrib.gis.geos import Polygon, Point +from django.contrib.gis.geos import GEOSGeometry, Polygon, Point from django.contrib.gis.db.models import PolygonField from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _ @@ -204,6 +204,21 @@ def geographic_bounding_box(self): """BBOX is in the format: [x0,x1,y0,y1].""" return bbox_to_wkt(self.bbox_x0, self.bbox_x1, self.bbox_y0, self.bbox_y1, srid=self.srid) + @property + def geom(self): + srid, wkt = self.geographic_bounding_box.split(";") + srid = re.findall(r"\d+", srid) + geom = GEOSGeometry(wkt, srid=int(srid[0])) + geom.transform(4326) + return geom + + def is_assignable_to_geom(self, extent_geom: GEOSGeometry): + region_geom = self.geom + + if region_geom.contains(extent_geom) or region_geom.overlaps(extent_geom): + return True + return False + class Meta: ordering = ("name",) verbose_name_plural = "Metadata Regions" diff --git a/geonode/base/tests.py b/geonode/base/tests.py index 7ee9a49b557..3c6cfb9b4f2 100644 --- a/geonode/base/tests.py +++ b/geonode/base/tests.py @@ -48,13 +48,14 @@ Menu, MenuItem, Configuration, + Region, TopicCategory, Thesaurus, ThesaurusKeyword, generate_thesaurus_reference, ) from django.conf import settings -from django.contrib.gis.geos import Polygon +from django.contrib.gis.geos import Polygon, GEOSGeometry from django.template import Template, Context from django.contrib.auth import get_user_model from geonode.storage.manager import storage_manager @@ -1124,3 +1125,41 @@ def test_keyword_raise_db_error(self, add_root_mocked): "Error during the keyword creation for keyword: keyword2", [x.message for x in _log.records], ) + + +class TestRegions(GeoNodeBaseTestSupport): + def setUp(self): + self.dataset_inside_region = GEOSGeometry( + "POLYGON ((-4.01799226543944599 57.18451093931114571, 8.89409253052255622 56.91828238681708285, \ + 9.29343535926363984 47.73339732577194638, -3.75176371294537603 48.13274015451304422, \ + -4.01799226543944599 57.18451093931114571))", + srid=4326, + ) + + self.dataset_overlapping_region = GEOSGeometry( + "POLYGON ((15.28357779038003628 33.6232840435866791, 28.19566258634203848 33.35705549109261625, \ + 28.5950054150831221 24.17217043004747978, 15.54980634287410624 24.57151325878857762, \ + 15.28357779038003628 33.6232840435866791))", + srid=4326, + ) + + self.dataset_outside_region = GEOSGeometry( + "POLYGON ((-3.75176371294537603 23.10725622007123548, 9.16032108301662618 22.84102766757717262, \ + 9.5596639117577098 13.65614260653203615, -3.48553516045130607 14.05548543527313399, \ + -3.75176371294537603 23.10725622007123548))", + srid=4326, + ) + + def test_region_assignment_for_extent(self): + region = Region.objects.get(code="EUR") + + self.assertTrue( + region.is_assignable_to_geom(self.dataset_inside_region), "Extent inside a region shouldn't be assigned" + ) + self.assertTrue( + region.is_assignable_to_geom(self.dataset_overlapping_region), + "Extent overlapping a region should be assigned", + ) + self.assertFalse( + region.is_assignable_to_geom(self.dataset_outside_region), "Extent outside a region should be assigned" + ) diff --git a/geonode/resource/utils.py b/geonode/resource/utils.py index 70dda53f921..ca7175c799b 100644 --- a/geonode/resource/utils.py +++ b/geonode/resource/utils.py @@ -483,13 +483,7 @@ def metadata_post_save(instance, *args, **kwargs): regions_to_add = [] for region in queryset: try: - srid2, wkt2 = region.geographic_bounding_box.split(";") - srid2 = re.findall(r"\d+", srid2) - - poly2 = GEOSGeometry(wkt2, srid=int(srid2[0])) - poly2.transform(4326) - - if not poly2.intersection(poly1).empty: + if region.is_assignable_to_geom(poly1): regions_to_add.append(region) if region.level == 0 and region.parent is None: global_regions.append(region) @@ -498,7 +492,7 @@ def metadata_post_save(instance, *args, **kwargs): if tb: logger.debug(tb) if regions_to_add or global_regions: - if regions_to_add and len(regions_to_add) > 0 and len(regions_to_add) <= 30: + if regions_to_add: instance.regions.add(*regions_to_add) else: instance.regions.add(*global_regions) From 910455379ce0aabbfafc3b7f3b45a82ebdf6b2df Mon Sep 17 00:00:00 2001 From: Emanuele Tajariol Date: Wed, 12 Jul 2023 16:45:16 +0200 Subject: [PATCH 074/330] [Fixes #11259] Faceting: fixes (#11260) --- geonode/facets/providers/keyword.py | 12 +++++++++--- geonode/facets/providers/users.py | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/geonode/facets/providers/keyword.py b/geonode/facets/providers/keyword.py index 570f2e04cbb..4d377538a2d 100644 --- a/geonode/facets/providers/keyword.py +++ b/geonode/facets/providers/keyword.py @@ -55,10 +55,16 @@ def get_facet_items( ) -> (int, list): logger.debug("Retrieving facets for %s", self.name) - q = queryset.values("keywords__slug", "keywords__name").filter(keywords__isnull=False) + filters = {"keywords__isnull": False} if topic_contains: - q = q.filter(keywords__name=topic_contains) - q = q.annotate(count=Count("keywords__slug")).order_by("-count") + filters["keywords__name__icontains"] = topic_contains + + q = ( + queryset.filter(**filters) + .values("keywords__slug", "keywords__name") + .annotate(count=Count("keywords__slug")) + .order_by("-count") + ) cnt = q.count() diff --git a/geonode/facets/providers/users.py b/geonode/facets/providers/users.py index a6399952868..80e47c50952 100644 --- a/geonode/facets/providers/users.py +++ b/geonode/facets/providers/users.py @@ -39,8 +39,8 @@ def name(self) -> str: def get_info(self, lang="en", **kwargs) -> dict: return { "name": "owner", - "key": "owner", # deprecated - "filter": "owner", + "key": "filter{owner.pk.in}", # deprecated + "filter": "filter{owner.pk.in}", "label": "Owner", "type": FACET_TYPE_USER, } From b3f3764bdaaf5f58e57bb2a05f0839d2472ef6b5 Mon Sep 17 00:00:00 2001 From: etj Date: Mon, 17 Jul 2023 17:34:11 +0200 Subject: [PATCH 075/330] [Fixes #11273] Faceting: /facets loses some filters along the way --- geonode/facets/tests.py | 56 ++++++++++++++++++++++++++++++++++++++--- geonode/facets/views.py | 4 ++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/geonode/facets/tests.py b/geonode/facets/tests.py index 50a7cef842c..127ada7cd7a 100644 --- a/geonode/facets/tests.py +++ b/geonode/facets/tests.py @@ -29,6 +29,7 @@ from geonode.base.models import Thesaurus, ThesaurusLabel, ThesaurusKeyword, ThesaurusKeywordLabel, ResourceBase, Region from geonode.facets.models import facet_registry +from geonode.facets.providers.baseinfo import FeaturedFacetProvider from geonode.facets.providers.region import RegionFacetProvider from geonode.tests.base import GeoNodeBaseTestSupport import geonode.facets.views as views @@ -136,13 +137,13 @@ def _create_resources(self): # RB19 -> T0K0 T0K1 FEAT if x % 2 == 1: - print(f"ADDING KEYWORDS {self.thesauri_k['0_0']} to RB {d}") + logger.debug(f"ADDING KEYWORDS {self.thesauri_k['0_0']} to RB {d}") d.tkeywords.add(self.thesauri_k["0_0"]) if x % 2 == 1 and x > 10: - print(f"ADDING KEYWORDS {self.thesauri_k['0_1']} to RB {d}") + logger.debug(f"ADDING KEYWORDS {self.thesauri_k['0_1']} to RB {d}") d.tkeywords.add(self.thesauri_k["0_1"]) if x < 10: - print(f"ADDING KEYWORDS {self.thesauri_k['1_0']} to RB {d}") + logger.debug(f"ADDING KEYWORDS {self.thesauri_k['1_0']} to RB {d}") d.tkeywords.add(self.thesauri_k["1_0"]) if 7 < x < 13: d.tkeywords.add(self.thesauri_k["1_1"]) @@ -335,6 +336,55 @@ def test_prefiltering(self): self.assertEqual(totals, obj["topics"]["total"], f"Bad totals for facet '{facet} and filter {filters}") self.assertEqual(count0, obj["topics"]["items"][0]["count"], f"Bad count0 for facet '{facet}") + def test_prefiltering_tkeywords(self): + regname = RegionFacetProvider().name + featname = FeaturedFacetProvider().name + t1filter = facet_registry.get_provider("t_1").get_info()["filter"] + tkey_1_1 = self.thesauri_k["1_1"].id + + expected_region = {"R1": 1} + expected_feat = {True: 2, False: 3} + + # Run the single requests + for facet, params, items in ( + (regname, {t1filter: tkey_1_1}, expected_region), + (featname, {t1filter: tkey_1_1}, expected_feat), + ): + req = self.rf.get(reverse("get_facet", args=[facet]), data=params) + res: JsonResponse = views.get_facet(req, facet) + obj = json.loads(res.content) + + self.assertEqual( + len(items), + len(obj["topics"]["items"]), + f"Bad count for items '{facet} \n PARAMS: {params} \n RESULT: {obj} \n EXPECTED: {items}", + ) + # search item + for item in items.keys(): + found = next((i for i in obj["topics"]["items"] if i["key"] == item), None) + self.assertIsNotNone(found, f"Topic '{item}' not found in facet {facet} -- {obj}") + self.assertEqual(items[item], found.get("count", None), f"Bad count for facet '{facet}:{item}") + + # Run the single request + req = self.rf.get(reverse("list_facets"), data={"include_topics": 1, t1filter: tkey_1_1}) + res: JsonResponse = views.list_facets(req) + obj = json.loads(res.content) + + facets_list = obj["facets"] + fmap = self._facets_to_map(facets_list) + + for name, items in ( + (regname, expected_region), + (featname, expected_feat), + ): + self.assertIn(name, fmap) + facet = fmap[name] + + for item in items.keys(): + found = next((i for i in facet["topics"]["items"] if i["key"] == item), None) + self.assertIsNotNone(found, f"Topic '{item}' not found in facet {facet} -- {facet}") + self.assertEqual(items[item], found.get("count", None), f"Bad count for facet '{facet}:{item}") + def test_config(self): for facet, type, order in ( ("resourcetype", None, None), diff --git a/geonode/facets/views.py b/geonode/facets/views.py index e3a8185ae4c..b6e876f1a4d 100644 --- a/geonode/facets/views.py +++ b/geonode/facets/views.py @@ -53,6 +53,7 @@ def list_facets(request, **kwargs): include_config = _resolve_boolean(request, PARAM_INCLUDE_CONFIG, False) facets = [] + prefiltered = None for provider in facet_registry.get_providers(): logger.debug("Fetching data from provider %r", provider) @@ -68,7 +69,8 @@ def list_facets(request, **kwargs): info["link"] = f"{reverse('get_facet', args=[info['name']])}?{urlencode(link_args)}" if include_topics: - info["topics"] = _get_topics(provider, queryset=_prefilter_topics(request), lang=lang) + prefiltered = prefiltered or _prefilter_topics(request) + info["topics"] = _get_topics(provider, queryset=prefiltered, lang=lang) facets.append(info) From 20efc5064ed416f885135e2d71af7a2b8ae0e4dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 10:00:31 +0200 Subject: [PATCH 076/330] Bump word-wrap from 1.2.3 to 1.2.4 in /geonode/monitoring/frontend (#11276) Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4. - [Release notes](https://github.com/jonschlinkert/word-wrap/releases) - [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4) --- updated-dependencies: - dependency-name: word-wrap dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- geonode/monitoring/frontend/yarn.lock | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/geonode/monitoring/frontend/yarn.lock b/geonode/monitoring/frontend/yarn.lock index d8f15f3051a..d0fab64321f 100644 --- a/geonode/monitoring/frontend/yarn.lock +++ b/geonode/monitoring/frontend/yarn.lock @@ -2337,11 +2337,16 @@ core-js@^1.0.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" integrity sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA== -core-js@^2.4.0, core-js@^2.5.0, core-js@^2.5.1: +core-js@^2.4.0, core-js@^2.5.0: version "2.6.12" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== +core-js@^3.4.2: + version "3.31.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.31.1.tgz#f2b0eea9be9da0def2c5fece71064a7e5d687653" + integrity sha512-2sKLtfq1eFST7l7v62zaqXacPc7uG8ZAya8ogijLhTtaKNcpzpB4TMoTw2Si+8GYKRwFPMMtUT0263QFWFfqyQ== + core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -6574,7 +6579,7 @@ react-simple-maps@0.12.1: d3-geo-projection "1.2.2" topojson-client "2.1.0" -react-smooth@^1.0.0: +react-smooth@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-1.0.6.tgz#18b964f123f7bca099e078324338cd8739346d0a" integrity sha512-B2vL4trGpNSMSOzFiAul9kFAsxTukL9Wyy9EXtkQy3GJr6sZqW9e1nShdVOJ3hRYamPZ94O17r3Q0bjSw3UYtg== @@ -6706,20 +6711,20 @@ recharts-scale@^0.4.2: dependencies: decimal.js-light "^2.4.1" -recharts@1.6.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/recharts/-/recharts-1.6.2.tgz#4ced884f04b680e8dac5d3e109f99b0e7cfb9b0f" - integrity sha512-NqVN8Hq5wrrBthTxQB+iCnZjup1dc+AYRIB6Q9ck9UjdSJTt4PbLepGpudQEYJEN5iIpP/I2vThC4uiTJa7xUQ== +recharts@1.8.6: + version "1.8.6" + resolved "https://registry.yarnpkg.com/recharts/-/recharts-1.8.6.tgz#ba651a4610dbac936da5001f8cb556b36a170410" + integrity sha512-UlfSEOnZRAxxaH33Fc86yHEcqN+IRauPP31NfVvlGudtwVZEIb2RFI5b1J3npQo7XyoSnkUodg3Ha6EupkV+SQ== dependencies: classnames "^2.2.5" - core-js "^2.5.1" + core-js "^3.4.2" d3-interpolate "^1.3.0" d3-scale "^2.1.0" d3-shape "^1.2.0" lodash "^4.17.5" prop-types "^15.6.0" react-resize-detector "^2.3.0" - react-smooth "^1.0.0" + react-smooth "^1.0.5" recharts-scale "^0.4.2" reduce-css-calc "^1.3.0" @@ -8464,9 +8469,9 @@ wide-align@^1.1.0: string-width "^1.0.2 || 2 || 3 || 4" word-wrap@~1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + version "1.2.4" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f" + integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA== wordwrap@~0.0.2: version "0.0.3" From be723297ed6946763e8e091ec821e353fc24194a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 10:36:05 +0200 Subject: [PATCH 077/330] Bump invoke from 2.1.3 to 2.2.0 (#11271) * Bump invoke from 2.1.3 to 2.2.0 Bumps [invoke](https://github.com/pyinvoke/invoke) from 2.1.3 to 2.2.0. - [Commits](https://github.com/pyinvoke/invoke/compare/2.1.3...2.2.0) --- updated-dependencies: - dependency-name: invoke dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 17733ac070d..73b3383050e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -147,7 +147,7 @@ uWSGI==2.0.21 gunicorn==20.1.0 ipython==8.14.0 docker==6.1.3 -invoke==2.1.3 +invoke==2.2.0 # tests coverage==7.2.7 diff --git a/setup.cfg b/setup.cfg index 6f5b98c65aa..054f960c72b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -172,7 +172,7 @@ install_requires = gunicorn==20.1.0 ipython==8.14.0 docker==6.1.3 - invoke==2.1.3 + invoke==2.2.0 # tests coverage==7.2.7 From 61c546f8b2d924a4690399cdb25d95362a88df74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 10:36:35 +0200 Subject: [PATCH 078/330] Bump boto3 from 1.28.1 to 1.28.3 (#11270) * Bump boto3 from 1.28.1 to 1.28.3 Bumps [boto3](https://github.com/boto/boto3) from 1.28.1 to 1.28.3. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.28.1...1.28.3) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 73b3383050e..f4277547478 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 -boto3==1.28.1 +boto3==1.28.3 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index 054f960c72b..a0680178b17 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 - boto3==1.28.1 + boto3==1.28.3 # Django Caches python-memcached<=1.59 From 279ce27a7dc43b950f00be33d3d15207c1c15a58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 10:36:52 +0200 Subject: [PATCH 079/330] Bump jsonschema from 4.18.0 to 4.18.3 (#11267) * Bump jsonschema from 4.18.0 to 4.18.3 Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.18.0 to 4.18.3. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.18.0...v4.18.3) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index f4277547478..3b99937e9da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ vine==5.0.0 tqdm==4.65.0 Deprecated==1.2.14 wrapt==1.15.0 -jsonschema==4.18.0 +jsonschema==4.18.3 zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 diff --git a/setup.cfg b/setup.cfg index a0680178b17..061a1917fe3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -46,7 +46,7 @@ install_requires = tqdm==4.65.0 Deprecated==1.2.14 wrapt==1.15.0 - jsonschema==4.18.0 + jsonschema==4.18.3 zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 From e5a47d50c348beb19560ea4ff71941d4212a37ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 10:38:35 +0200 Subject: [PATCH 080/330] Bump pathvalidate from 3.0.0 to 3.1.0 (#11268) * Bump pathvalidate from 3.0.0 to 3.1.0 Bumps [pathvalidate](https://github.com/thombashi/pathvalidate) from 3.0.0 to 3.1.0. - [Release notes](https://github.com/thombashi/pathvalidate/releases) - [Commits](https://github.com/thombashi/pathvalidate/compare/v3.0.0...v3.1.0) --- updated-dependencies: - dependency-name: pathvalidate dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3b99937e9da..4dc3fe59083 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,7 @@ schema==0.7.5 rdflib==6.3.2 smart_open==6.3.0 PyMuPDF==1.22.5 -pathvalidate==3.0.0 +pathvalidate==3.1.0 # Django Apps django-allauth==0.54.0 diff --git a/setup.cfg b/setup.cfg index 061a1917fe3..465bbf2b4bd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,7 +52,7 @@ install_requires = rdflib==6.3.2 smart_open==6.3.0 PyMuPDF==1.22.5 - pathvalidate==3.0.0 + pathvalidate==3.1.0 # Django Apps django-allauth==0.54.0 From 320892c572a267ed1f2d205af100af8728481325 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 10:39:56 +0200 Subject: [PATCH 081/330] Bump pip from 23.1.2 to 23.2 (#11266) * Bump pip from 23.1.2 to 23.2 Bumps [pip](https://github.com/pypa/pip) from 23.1.2 to 23.2. - [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/pip/compare/23.1.2...23.2) --- updated-dependencies: - dependency-name: pip dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4dc3fe59083..74ff30058e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -160,7 +160,7 @@ splinter==0.19.0 pytest-splinter==3.3.2 pytest-django==4.5.2 setuptools>=59.1.1,<68.1.0 -pip==23.1.2 +pip==23.2 Twisted==22.10.0 pixelmatch==0.3.0 factory-boy==3.2.1 diff --git a/setup.cfg b/setup.cfg index 465bbf2b4bd..d4b92a88e3b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -185,7 +185,7 @@ install_requires = pytest-splinter==3.3.2 pytest-django==4.5.2 setuptools>=59.1.1,<68.1.0 - pip==23.1.2 + pip==23.2 Twisted==22.10.0 pixelmatch==0.3.0 factory-boy==3.2.1 From abf6803a104a249421d2636e25f1b60d111adf81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 10:41:38 +0200 Subject: [PATCH 082/330] Bump sqlalchemy from 2.0.18 to 2.0.19 (#11269) * Bump sqlalchemy from 2.0.18 to 2.0.19 Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.18 to 2.0.19. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 74ff30058e3..4bc6f0e5eff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -56,7 +56,7 @@ pyjwt==2.7.0 pyproj<3.7.0 OWSLib==0.29.2 pycsw==2.6.1 -SQLAlchemy==2.0.18 # required by PyCSW +SQLAlchemy==2.0.19 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 diff --git a/setup.cfg b/setup.cfg index d4b92a88e3b..9b959977265 100644 --- a/setup.cfg +++ b/setup.cfg @@ -82,7 +82,7 @@ install_requires = pyproj<3.7.0 OWSLib==0.29.2 pycsw==2.6.1 - SQLAlchemy==2.0.18 # required by PyCSW + SQLAlchemy==2.0.19 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 From fdaa8ba3b62d9344cd465123d57f37fbcd03e8d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 11:56:16 +0200 Subject: [PATCH 083/330] Bump pyjwt from 2.7.0 to 2.8.0 (#11280) * Bump pyjwt from 2.7.0 to 2.8.0 Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.7.0 to 2.8.0. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/compare/2.7.0...2.8.0) --- updated-dependencies: - dependency-name: pyjwt dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4bc6f0e5eff..9371ba8f79e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,7 +50,7 @@ django-widget-tweaks==1.4.12 django-sequences==2.7 oauthlib==3.2.2 pyopenssl==23.2.0 -pyjwt==2.7.0 +pyjwt==2.8.0 # geopython dependencies pyproj<3.7.0 diff --git a/setup.cfg b/setup.cfg index 9b959977265..fa2a1178b7c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -76,7 +76,7 @@ install_requires = django-sequences==2.7 oauthlib==3.2.2 pyopenssl==23.2.0 - pyjwt==2.7.0 + pyjwt==2.8.0 # geopython dependencies pyproj<3.7.0 From b52ac9d1796cafbae69b95879bd0621e91235f12 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 11:56:38 +0200 Subject: [PATCH 084/330] Bump boto3 from 1.28.1 to 1.28.5 (#11279) * Bump sqlalchemy from 2.0.18 to 2.0.19 (#11269) * Bump sqlalchemy from 2.0.18 to 2.0.19 Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.18 to 2.0.19. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani * Bump boto3 from 1.28.1 to 1.28.5 Bumps [boto3](https://github.com/boto/boto3) from 1.28.1 to 1.28.5. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.28.1...1.28.5) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9371ba8f79e..17ef9ea54a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 -boto3==1.28.3 +boto3==1.28.5 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index fa2a1178b7c..3b87a7c6909 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 - boto3==1.28.3 + boto3==1.28.5 # Django Caches python-memcached<=1.59 From e780750ae26e879d2cc67a1cbdd577d32621ed4b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 11:56:56 +0200 Subject: [PATCH 085/330] Bump gunicorn from 20.1.0 to 21.1.0 (#11278) * Bump gunicorn from 20.1.0 to 21.1.0 Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 20.1.0 to 21.1.0. - [Release notes](https://github.com/benoitc/gunicorn/releases) - [Commits](https://github.com/benoitc/gunicorn/compare/20.1.0...21.1.0) --- updated-dependencies: - dependency-name: gunicorn dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 17ef9ea54a5..b5b5c9be906 100644 --- a/requirements.txt +++ b/requirements.txt @@ -144,7 +144,7 @@ pycountry # production uWSGI==2.0.21 -gunicorn==20.1.0 +gunicorn==21.1.0 ipython==8.14.0 docker==6.1.3 invoke==2.2.0 diff --git a/setup.cfg b/setup.cfg index 3b87a7c6909..424e3cdc7cf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -169,7 +169,7 @@ install_requires = # production uWSGI==2.0.21 - gunicorn==20.1.0 + gunicorn==21.1.0 ipython==8.14.0 docker==6.1.3 invoke==2.2.0 From 4d893a19ade200bb3186bfa63099fbb0dc2412be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 11:57:15 +0200 Subject: [PATCH 086/330] Bump jsonschema from 4.18.0 to 4.18.4 (#11277) * Bump jsonschema from 4.18.0 to 4.18.4 Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.18.0 to 4.18.4. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.18.0...v4.18.4) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index b5b5c9be906..9c40b5461a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ vine==5.0.0 tqdm==4.65.0 Deprecated==1.2.14 wrapt==1.15.0 -jsonschema==4.18.3 +jsonschema==4.18.4 zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 diff --git a/setup.cfg b/setup.cfg index 424e3cdc7cf..2e6ec3fa19d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -46,7 +46,7 @@ install_requires = tqdm==4.65.0 Deprecated==1.2.14 wrapt==1.15.0 - jsonschema==4.18.3 + jsonschema==4.18.4 zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 From a4cb52f3cf41592021b6b89babaef36551e365d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 12:56:17 +0200 Subject: [PATCH 087/330] Bump factory-boy from 3.2.1 to 3.3.0 (#11281) * Bump factory-boy from 3.2.1 to 3.3.0 Bumps [factory-boy](https://github.com/FactoryBoy/factory_boy) from 3.2.1 to 3.3.0. - [Changelog](https://github.com/FactoryBoy/factory_boy/blob/master/docs/changelog.rst) - [Commits](https://github.com/FactoryBoy/factory_boy/compare/3.2.1...3.3.0) --- updated-dependencies: - dependency-name: factory-boy dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9c40b5461a8..a9d73c1d309 100644 --- a/requirements.txt +++ b/requirements.txt @@ -163,7 +163,7 @@ setuptools>=59.1.1,<68.1.0 pip==23.2 Twisted==22.10.0 pixelmatch==0.3.0 -factory-boy==3.2.1 +factory-boy==3.3.0 flaky==3.7.0 selenium>=4.1.0,<5.0.0 selenium-requests==2.0.3 diff --git a/setup.cfg b/setup.cfg index 2e6ec3fa19d..3bbf22b9465 100644 --- a/setup.cfg +++ b/setup.cfg @@ -188,7 +188,7 @@ install_requires = pip==23.2 Twisted==22.10.0 pixelmatch==0.3.0 - factory-boy==3.2.1 + factory-boy==3.3.0 flaky==3.7.0 selenium>=4.1.0,<5.0.0 selenium-requests==2.0.3 From 2bdd2422ae20a389cd2a2910bb6afc121ce27656 Mon Sep 17 00:00:00 2001 From: etj Date: Thu, 20 Jul 2023 13:34:55 +0200 Subject: [PATCH 088/330] [Fixes #11225] Italian translations aren't built with the latest fixes [Fixes #11264] Fix the italian translation for Position --- geonode/locale/it/LC_MESSAGES/django.mo | Bin 155297 -> 161119 bytes geonode/locale/it/LC_MESSAGES/django.po | 2510 ++++++++++------------- 2 files changed, 1052 insertions(+), 1458 deletions(-) diff --git a/geonode/locale/it/LC_MESSAGES/django.mo b/geonode/locale/it/LC_MESSAGES/django.mo index a093d7f2986572518ff6c3196a2195cdfea49d9e..0a9e88e7f7d43b2150075b76272bf4703f194b8f 100644 GIT binary patch delta 38086 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 o-ILL_X&?9PXMD@M_5-$;`*Dudbom$ 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 delta 33511 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 b*7Ybc*jKo^w`O4YrkdVSzM-|e*F*jfhks># diff --git a/geonode/locale/it/LC_MESSAGES/django.po b/geonode/locale/it/LC_MESSAGES/django.po index 154a9219bfc..f698c7205b5 100644 --- a/geonode/locale/it/LC_MESSAGES/django.po +++ b/geonode/locale/it/LC_MESSAGES/django.po @@ -39,16 +39,15 @@ msgstr "" "Project-Id-Version: GeoNode\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-07-08 09:02+0300\n" -"PO-Revision-Date: 2021-04-29 13:43+0200\n" +"PO-Revision-Date: 2023-07-20 11:35+0200\n" "Last-Translator: Julien Collaer \n" -"Language-Team: Italian (http://www.transifex.com/geonode/geonode/language/" -"it/)\n" +"Language-Team: Italian (http://www.transifex.com/geonode/geonode/language/it/)\n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 2.4.2\n" +"X-Generator: Poedit 3.0.1\n" msgid "Request to download a resource" msgstr "Richiedi di scaricare la risorsa" @@ -60,8 +59,7 @@ msgid "Request resource change" msgstr "Richiedi di modificare la risorsa" msgid "Owner has requested permissions to modify a resource" -msgstr "" -"Il proprietario ha richiesto le autorizzazioni per modificare la risorsa" +msgstr "Il proprietario ha richiesto le autorizzazioni per modificare la risorsa" msgid "series" msgstr "serie" @@ -199,11 +197,10 @@ msgid "Free-text Keywords" msgstr "Free-text Keywords" msgid "" -"A space or comma-separated list of keywords. Use the widget to select from " -"Hierarchical tree." +"A space or comma-separated list of keywords. Use the widget to select from Hierarchical tree." msgstr "" -"Uno spazio o un elenco delimitato da virgole di parole chiave. Utilizzare il " -"widget per selezionare dalla struttura ad albero gerarchica." +"Uno spazio o un elenco delimitato da virgole di parole chiave. Utilizzare il widget per " +"selezionare dalla struttura ad albero gerarchica." msgid "Regions" msgstr "Regioni" @@ -220,11 +217,9 @@ msgstr "Licenza" msgid "Language" msgstr "Lingua" -#, fuzzy -#| msgid "Users" msgid "User" msgid_plural "Users" -msgstr[0] "Utenti" +msgstr[0] "Utente" msgstr[1] "Utenti" msgid "Permission Type" @@ -258,41 +253,37 @@ msgid "version of the cited resource" msgstr "versione della risorsa citata" msgid "" -"authority or function assigned, as to a ruler, legislative assembly, " -"delegate, or the like." +"authority or function assigned, as to a ruler, legislative assembly, delegate, or the like." msgstr "" -"autorità o funzione assegnata, ad esempio un regolatore, un'assemblea " -"legislativa, un delegato o simili." +"autorità o funzione assegnata, ad esempio un regolatore, un'assemblea legislativa, un " +"delegato o simili." msgid "a DOI will be added by Admin before publication." msgstr "un DOI verrà aggiunto dall'amministratore prima della pubblicazione." msgid "summary of the intentions with which the resource(s) was developed" -msgstr "" -"sintesi delle intenzioni con cui la risorsa (o le risorse) sono state " -"sviluppate" +msgstr "sintesi delle intenzioni con cui la risorsa (o le risorse) sono state sviluppate" msgid "" -"frequency with which modifications and deletions are made to the data after " -"it is first produced" +"frequency with which modifications and deletions are made to the data after it is first " +"produced" msgstr "" -"frequenza con cui le modifiche e le cancellazioni sui dati vengono eseguite " -"dopo essere stati prodotti la prima volta" +"frequenza con cui le modifiche e le cancellazioni sui dati vengono eseguite dopo essere " +"stati prodotti la prima volta" msgid "" -"commonly used word(s) or formalised word(s) or phrase(s) used to describe " -"the subject (space or comma-separated)" +"commonly used word(s) or formalised word(s) or phrase(s) used to describe the subject (space " +"or comma-separated)" msgstr "" -"parola (o parole) comunemente usate o parola (o parole) e frase (o frasi) " -"formalizzate usate per descrivere il soggetto (separando attraverso spazio o " -"virgola" +"parola (o parole) comunemente usate o parola (o parole) e frase (o frasi) formalizzate usate " +"per descrivere il soggetto (separando attraverso spazio o virgola" msgid "" -"formalised word(s) or phrase(s) from a fixed thesaurus used to describe the " -"subject (space or comma-separated)" +"formalised word(s) or phrase(s) from a fixed thesaurus used to describe the subject (space " +"or comma-separated)" msgstr "" -"parola(e) o frase(i) formalizzate da un thesaurus fisso utilizzato per " -"descrivere l'oggetto (spazio o delimitato da virgole)" +"parola(e) o frase(i) formalizzate da un thesaurus fisso utilizzato per descrivere l'oggetto " +"(spazio o delimitato da virgole)" msgid "keyword identifies a location" msgstr "parola chiave che identifica una posizione" @@ -301,11 +292,9 @@ msgid "limitation(s) placed upon the access or use of the data." msgstr "limitazione(i) sull'accesso o l'uso dei dati." msgid "" -"other restrictions and legal prerequisites for accessing and using the " -"resource or metadata" +"other restrictions and legal prerequisites for accessing and using the resource or metadata" msgstr "" -"altre restrizioni e requisiti legali per l'accesso e l'uso della risorsa o " -"dei metadati" +"altre restrizioni e requisiti legali per l'accesso e l'uso della risorsa o dei metadati" msgid "license of the dataset" msgstr "licenza del dataset" @@ -314,11 +303,11 @@ msgid "language used within the dataset" msgstr "lingua usata all'interno del dataset" msgid "" -"high-level geographic data thematic classification to assist in the grouping " -"and search of available geographic data sets." +"high-level geographic data thematic classification to assist in the grouping and search of " +"available geographic data sets." msgstr "" -"classificazione tematica di dati geografici di alto livello per facilitare " -"il raggruppamento e la ricerca di set di dati geografici disponibili." +"classificazione tematica di dati geografici di alto livello per facilitare il raggruppamento " +"e la ricerca di set di dati geografici disponibili." msgid "method used to represent geographic information in the dataset." msgstr "metodo di rappresentazione spaziale del dataset." @@ -329,12 +318,10 @@ msgstr "periodo di tempo coperto dal contenuto del dataset (inizio)" msgid "time period covered by the content of the dataset (end)" msgstr "periodo di tempo coperto dal contenuto del dataset (fine)" -msgid "" -"general explanation of the data producer's knowledge about the lineage of a " -"dataset" +msgid "general explanation of the data producer's knowledge about the lineage of a dataset" msgstr "" -"spiegazione generale della conoscenza del produttore dei dati riguardo il " -"lignaggio di un dataset" +"spiegazione generale della conoscenza del produttore dei dati riguardo il lignaggio di un " +"dataset" msgid "title" msgstr "titolo" @@ -438,10 +425,8 @@ msgstr "Questa risorsa è stata convalidata da un editore o un curatore?" msgid "Thumbnail url" msgstr "Url della miniatura" -#, fuzzy -#| msgid "Dirty State" msgid "State" -msgstr "Non ancora aggiornato" +msgstr "Stato" msgid "Hold the resource processing state." msgstr "" @@ -621,25 +606,17 @@ msgid "Reason for the request" msgstr "Motivo della richiesta" msgid "" -"To allow the change, set the resource to not \"Approved\" under the metadata " -"settingsand write message to the owner to notify him" +"To allow the change, set the resource to not \"Approved\" under the metadata settingsand " +"write message to the owner to notify him" msgstr "" -"Per consentire la modifica, impostare la risorsa su non \"Approvato\" nelle " -"impostazioni dei metadati e scrivere un messaggio al proprietario per " -"notificarlo" +"Per consentire la modifica, impostare la risorsa su non \"Approvato\" nelle impostazioni dei " +"metadati e scrivere un messaggio al proprietario per notificarlo" -#, fuzzy -#| msgid "Service rescanned successfully" msgid "Resource Cloned Successfully!" -msgstr "Il servizio è stato analizzato di nuovo correttamente" +msgstr "La risorsa è stata clonata con successo!" -#, fuzzy, python-brace-format -#| msgid "" -#| "Some error occurred while trying to access the uploaded schema: {str(e)}" msgid "Error Occurred while Cloning the Resource: {e}" -msgstr "" -"Si è verificato un errore durante il tentativo di accesso allo schema " -"caricato: {str(e)}" +msgstr "Si è verificato un errore durante la clonazione della risorsa: {e}" msgid "Resource Metadata" msgstr "Metadati delle risorse" @@ -675,237 +652,219 @@ msgid "GeoNode Client Library" msgstr "Libreria client di GeoNode" msgid "" -"Flora and/or fauna in natural environment. Examples: wildlife, vegetation, " -"biological sciences, ecology, wilderness, sealife, wetlands, habitat." +"Flora and/or fauna in natural environment. Examples: wildlife, vegetation, biological " +"sciences, ecology, wilderness, sealife, wetlands, habitat." msgstr "" -"Flora e/o fauna in ambiente naturale. Esempi: fauna selvatica, vegetazione, " -"scienze biologiche, ecologia, natura selvaggia, vita marina, zone umide, " -"habitat." +"Flora e/o fauna in ambiente naturale. Esempi: fauna selvatica, vegetazione, scienze " +"biologiche, ecologia, natura selvaggia, vita marina, zone umide, habitat." msgid "Biota" msgstr "Biota" -msgid "" -"Legal land descriptions. Examples: political and administrative boundaries." -msgstr "" -"Descrizioni legali dei terreni. Esempi: confini politici e amministrativi." +msgid "Legal land descriptions. Examples: political and administrative boundaries." +msgstr "Descrizioni legali dei terreni. Esempi: confini politici e amministrativi." msgid "Boundaries" msgstr "Confini" msgid "" -"Processes and phenomena of the atmosphere. Examples: cloud cover, weather, " -"climate, atmospheric conditions, climate change, precipitation." +"Processes and phenomena of the atmosphere. Examples: cloud cover, weather, climate, " +"atmospheric conditions, climate change, precipitation." msgstr "" -"Processi e fenomeni dell'atmosfera. Esempi: copertura nuvolosa, meteo, " -"clima, condizioni atmosferiche, cambiamenti climatici, precipitazioni." +"Processi e fenomeni dell'atmosfera. Esempi: copertura nuvolosa, meteo, clima, condizioni " +"atmosferiche, cambiamenti climatici, precipitazioni." msgid "Climatology Meteorology Atmosphere" msgstr "Climatologia Meteorologia Atmosfera" msgid "" -"Economic activities, conditions and employment. Examples: production, " -"labour, revenue, commerce, industry, tourism and ecotourism, forestry, " -"fisheries, commercial or subsistence hunting, exploration and exploitation " -"of resources such as minerals, oil and gas." +"Economic activities, conditions and employment. Examples: production, labour, revenue, " +"commerce, industry, tourism and ecotourism, forestry, fisheries, commercial or subsistence " +"hunting, exploration and exploitation of resources such as minerals, oil and gas." msgstr "" -"Attività economiche, condizioni e occupazione. Esempi: produzione, lavoro, " -"entrate, commercio, industria, turismo ed ecoturismo, silvicoltura, pesca, " -"caccia commerciale o di sussistenza, esplorazione e sfruttamento di risorse " -"quali minerali, petrolio e gas." +"Attività economiche, condizioni e occupazione. Esempi: produzione, lavoro, entrate, " +"commercio, industria, turismo ed ecoturismo, silvicoltura, pesca, caccia commerciale o di " +"sussistenza, esplorazione e sfruttamento di risorse quali minerali, petrolio e gas." msgid "Economy" msgstr "Economia" msgid "" -"Height above or below sea level. Examples: altitude, bathymetry, digital " -"elevation models, slope, derived products." +"Height above or below sea level. Examples: altitude, bathymetry, digital elevation models, " +"slope, derived products." msgstr "" -"Altezza sopra o sotto il livello del mare. Esempi: altitudine, batimetria, " -"modelli digitali di elevazione, pendenza, prodotti derivati." +"Altezza sopra o sotto il livello del mare. Esempi: altitudine, batimetria, modelli digitali " +"di elevazione, pendenza, prodotti derivati." msgid "Elevation" msgstr "Elevazione" msgid "" -"Environmental resources, protection and conservation. Examples: " -"environmental pollution, waste storage and treatment, environmental impact " -"assessment, monitoring environmental risk, nature reserves, landscape." +"Environmental resources, protection and conservation. Examples: environmental pollution, " +"waste storage and treatment, environmental impact assessment, monitoring environmental risk, " +"nature reserves, landscape." msgstr "" -"Risorse ambientali, protezione e conservazione. Esempi: inquinamento " -"ambientale, stoccaggio e trattamento dei rifiuti, valutazione dell'impatto " -"ambientale, monitoraggio del rischio ambientale, riserve naturali, paesaggio." +"Risorse ambientali, protezione e conservazione. Esempi: inquinamento ambientale, stoccaggio " +"e trattamento dei rifiuti, valutazione dell'impatto ambientale, monitoraggio del rischio " +"ambientale, riserve naturali, paesaggio." msgid "Environment" msgstr "Ambiente" msgid "" -"Rearing of animals and/or cultivation of plants. Examples: agriculture, " -"irrigation, aquaculture, plantations, herding, pests and diseases affecting " -"crops and livestock." +"Rearing of animals and/or cultivation of plants. Examples: agriculture, irrigation, " +"aquaculture, plantations, herding, pests and diseases affecting crops and livestock." msgstr "" -"Allevamento di animali e/o coltivazione di piante. Esempi: agricoltura, " -"irrigazione, acquacoltura, piantagioni, mandrie, parassiti e malattie che " -"colpiscono le colture e il bestiame." +"Allevamento di animali e/o coltivazione di piante. Esempi: agricoltura, irrigazione, " +"acquacoltura, piantagioni, mandrie, parassiti e malattie che colpiscono le colture e il " +"bestiame." msgid "Farming" msgstr "Agricoltura" msgid "" -"Information pertaining to earth sciences. Examples: geophysical features and " -"processes, geology, minerals, sciences dealing with the composition, " -"structure and origin of the earth s rocks, risks of earthquakes, volcanic " -"activity, landslides, gravity information, soils, permafrost, hydrogeology, " -"erosion." +"Information pertaining to earth sciences. Examples: geophysical features and processes, " +"geology, minerals, sciences dealing with the composition, structure and origin of the earth " +"s rocks, risks of earthquakes, volcanic activity, landslides, gravity information, soils, " +"permafrost, hydrogeology, erosion." msgstr "" -"Informazioni relative alle scienze della terra. Esempi: caratteristiche e " -"processi geofisici, geologia, minerali, scienze che si occupano della " -"composizione, struttura e origine delle rocce terrestri, rischi di " -"terremoti, attività vulcanica, frane, informazioni sulla gravità, suoli, " -"permafrost, idrogeologia, erosione." +"Informazioni relative alle scienze della terra. Esempi: caratteristiche e processi " +"geofisici, geologia, minerali, scienze che si occupano della composizione, struttura e " +"origine delle rocce terrestri, rischi di terremoti, attività vulcanica, frane, informazioni " +"sulla gravità, suoli, permafrost, idrogeologia, erosione." msgid "Geoscientific Information" msgstr "Informazioni geoscientifiche" msgid "" -"Health, health services, human ecology, and safety. Examples: disease and " -"illness, factors affecting health, hygiene, substance abuse, mental and " -"physical health, health services." +"Health, health services, human ecology, and safety. Examples: disease and illness, factors " +"affecting health, hygiene, substance abuse, mental and physical health, health services." msgstr "" -"Salute, servizi sanitari, ecologia umana e sicurezza. Esempi: malattia e " -"malattia, fattori che influenzano la salute, l'igiene, l'abuso di sostanze, " -"la salute mentale e fisica, i servizi sanitari." +"Salute, servizi sanitari, ecologia umana e sicurezza. Esempi: malattia e malattia, fattori " +"che influenzano la salute, l'igiene, l'abuso di sostanze, la salute mentale e fisica, i " +"servizi sanitari." msgid "Health" msgstr "Salute" msgid "" -"Base maps. Examples: land cover, topographic maps, imagery, unclassified " -"images, annotations." +"Base maps. Examples: land cover, topographic maps, imagery, unclassified images, annotations." msgstr "" -"Mappe di base. Esempi: copertura del terreno, mappe topografiche, immagini, " -"immagini non classificate, annotazioni." +"Mappe di base. Esempi: copertura del terreno, mappe topografiche, immagini, immagini non " +"classificate, annotazioni." msgid "Imagery Base Maps Earth Cover" msgstr "Mappe Immagini di Base Copertina della Terra" msgid "" -"Inland water features, drainage systems and their characteristics. Examples: " -"rivers and glaciers, salt lakes, water utilization plans, dams, currents, " -"floods, water quality, hydrographic charts." +"Inland water features, drainage systems and their characteristics. Examples: rivers and " +"glaciers, salt lakes, water utilization plans, dams, currents, floods, water quality, " +"hydrographic charts." msgstr "" -"Caratteristiche dell'acqua dell'entroterra, sistemi di drenaggio e loro " -"caratteristiche. Esempi: fiumi e ghiacciai, laghi salati, piani di utilizzo " -"dell'acqua, dighe, correnti, inondazioni, qualità dell'acqua, grafici " -"idrografici." +"Caratteristiche dell'acqua dell'entroterra, sistemi di drenaggio e loro caratteristiche. " +"Esempi: fiumi e ghiacciai, laghi salati, piani di utilizzo dell'acqua, dighe, correnti, " +"inondazioni, qualità dell'acqua, grafici idrografici." msgid "Inland Waters" msgstr "Acque interne" msgid "" -"Military bases, structures, activities. Examples: barracks, training " -"grounds, military transportation, information collection." +"Military bases, structures, activities. Examples: barracks, training grounds, military " +"transportation, information collection." msgstr "" -"Basi militari, strutture, attività. Esempi: caserme, campi di addestramento, " -"trasporto militare, raccolta di informazioni." +"Basi militari, strutture, attività. Esempi: caserme, campi di addestramento, trasporto " +"militare, raccolta di informazioni." msgid "Intelligence Military" msgstr "Intelligence militare" msgid "" -"Positional information and services. Examples: addresses, geodetic networks, " -"control points, postal zones and services, place names." +"Positional information and services. Examples: addresses, geodetic networks, control points, " +"postal zones and services, place names." msgstr "" -"Informazioni e servizi di localizzazione. Esempi: indirizzi, reti " -"geodetiche, punti di controllo, zone postali e servizi, nomi di luoghi." +"Informazioni e servizi di localizzazione. Esempi: indirizzi, reti geodetiche, punti di " +"controllo, zone postali e servizi, nomi di luoghi." msgid "Location" msgstr "Posizione" msgid "" -"Features and characteristics of salt water bodies (excluding inland waters). " -"Examples: tides, tidal waves, coastal information, reefs." +"Features and characteristics of salt water bodies (excluding inland waters). Examples: " +"tides, tidal waves, coastal information, reefs." msgstr "" -"Caratteristiche e caratteristiche dei corpi idrici dell'acqua salata " -"(escluse le acque interne). Esempi: maree, onde di marea, informazioni " -"costiere, barriere coralline." +"Caratteristiche e caratteristiche dei corpi idrici dell'acqua salata (escluse le acque " +"interne). Esempi: maree, onde di marea, informazioni costiere, barriere coralline." msgid "Oceans" msgstr "Oceani" msgid "" -"Information used for appropriate actions for future use of the land. " -"Examples: land use maps, zoning maps, cadastral surveys, land ownership." +"Information used for appropriate actions for future use of the land. Examples: land use " +"maps, zoning maps, cadastral surveys, land ownership." msgstr "" -"Informazioni utilizzate per azioni appropriate per un uso futuro del " -"terreno. Esempi: mappe dell'uso del suolo, mappe di zonzing, indagini " -"cadastrali, proprietà del terreno." +"Informazioni utilizzate per azioni appropriate per un uso futuro del terreno. Esempi: mappe " +"dell'uso del suolo, mappe di zonzing, indagini cadastrali, proprietà del terreno." msgid "Planning Cadastre" msgstr "Pianificazione catasto" msgid "" -"Settlements, anthropology, archaeology, education, traditional beliefs, " -"manners and customs, demographic data, recreational areas and activities, " -"social impact assessments, crime and justice, census information. Economic " -"activities, conditions and employment." +"Settlements, anthropology, archaeology, education, traditional beliefs, manners and customs, " +"demographic data, recreational areas and activities, social impact assessments, crime and " +"justice, census information. Economic activities, conditions and employment." msgstr "" -"Insediamenti, antropologia, archeologia, educazione, credenze tradizionali, " -"modi e costumi, dati demografici, aree e attività ricreative, valutazioni di " -"impatto sociale, criminalità e giustizia, informazioni sul censimento. " -"Attività economiche, condizioni e occupazione." +"Insediamenti, antropologia, archeologia, educazione, credenze tradizionali, modi e costumi, " +"dati demografici, aree e attività ricreative, valutazioni di impatto sociale, criminalità e " +"giustizia, informazioni sul censimento. Attività economiche, condizioni e occupazione." msgid "Population" msgstr "Popolazione" msgid "" -"Characteristics of society and cultures. Examples: settlements, " -"anthropology, archaeology, education, traditional beliefs, manners and " -"customs, demographic data, recreational areas and activities, social impact " -"assessments, crime and justice, census information." +"Characteristics of society and cultures. Examples: settlements, anthropology, archaeology, " +"education, traditional beliefs, manners and customs, demographic data, recreational areas " +"and activities, social impact assessments, crime and justice, census information." msgstr "" -"Caratteristiche della società e delle culture. Esempi: insediamenti, " -"antropologia, archeologia, istruzione, credenze tradizionali, modi e " -"costumi, dati demografici, aree ricreative e attività, valutazioni " -"dell'impatto sociale, criminalità e giustizia, informazioni sul censimento." +"Caratteristiche della società e delle culture. Esempi: insediamenti, antropologia, " +"archeologia, istruzione, credenze tradizionali, modi e costumi, dati demografici, aree " +"ricreative e attività, valutazioni dell'impatto sociale, criminalità e giustizia, " +"informazioni sul censimento." msgid "Society" msgstr "Società" msgid "" -"Man-made construction. Examples: buildings, museums, churches, factories, " -"housing, monuments, shops, towers." +"Man-made construction. Examples: buildings, museums, churches, factories, housing, " +"monuments, shops, towers." msgstr "" -"Costruzione artificiale. Esempi: edifici, musei, chiese, fabbriche, " -"abitazioni, monumenti, negozi, torri." +"Costruzione artificiale. Esempi: edifici, musei, chiese, fabbriche, abitazioni, monumenti, " +"negozi, torri." msgid "Structure" msgstr "Strutture" msgid "" -"Means and aids for conveying persons and/or goods. Examples: roads, airports/" -"airstrips, shipping routes, tunnels, nautical charts, vehicle or vessel " -"location, aeronautical charts, railways." +"Means and aids for conveying persons and/or goods. Examples: roads, airports/airstrips, " +"shipping routes, tunnels, nautical charts, vehicle or vessel location, aeronautical charts, " +"railways." msgstr "" -"Mezzi e aiuti per il trasporto di persone e/o merci. Esempi: strade, " -"aeroporti/sciviole, rotte marittime, gallerie, carte nautiche, posizione di " -"veicoli o navi, carte aeronautiche, ferrovie." +"Mezzi e aiuti per il trasporto di persone e/o merci. Esempi: strade, aeroporti/sciviole, " +"rotte marittime, gallerie, carte nautiche, posizione di veicoli o navi, carte aeronautiche, " +"ferrovie." msgid "Transportation" msgstr "Trasporti" msgid "" -"Energy, water and waste systems and communications infrastructure and " -"services. Examples: hydroelectricity, geothermal, solar and nuclear sources " -"of energy, water purification and distribution, sewage collection and " -"disposal, electricity and gas distribution, data communication, " -"telecommunication, radio, communication networks." +"Energy, water and waste systems and communications infrastructure and services. Examples: " +"hydroelectricity, geothermal, solar and nuclear sources of energy, water purification and " +"distribution, sewage collection and disposal, electricity and gas distribution, data " +"communication, telecommunication, radio, communication networks." msgstr "" -"Sistemi energetici, idrici e dei rifiuti, infrastrutture e servizi di " -"comunicazione. Esempi: fonti idroelettriche, geotermiche, solari e nucleari " -"di energia, depurazione e distribuzione dell'acqua, raccolta e smaltimento " -"delle acque reflue, distribuzione di elettricità e gas, comunicazione dei " -"dati, telecomunicazioni, radio, reti di comunicazione." +"Sistemi energetici, idrici e dei rifiuti, infrastrutture e servizi di comunicazione. Esempi: " +"fonti idroelettriche, geotermiche, solari e nucleari di energia, depurazione e distribuzione " +"dell'acqua, raccolta e smaltimento delle acque reflue, distribuzione di elettricità e gas, " +"comunicazione dei dati, telecomunicazioni, radio, reti di comunicazione." msgid "Utilities Communication" msgstr "Servizi Comunicazioni" @@ -1051,15 +1010,13 @@ msgstr "Procedura guidata" msgid "Advanced Edit" msgstr "Modifica avanzata" -#, fuzzy -#| msgid "Document" msgid "Document" msgid_plural "Documents" msgstr[0] "Documento" -msgstr[1] "Documento" +msgstr[1] "Documenti" msgid "Clone" -msgstr "" +msgstr "Clona" msgid "Replace" msgstr "Sostituisci" @@ -1098,8 +1055,7 @@ msgid "Permissions" msgstr "Permessi" msgid "Click the button below to change the permissions of this document." -msgstr "" -"Clicca il pulsante qui sotto per modificare i permessi di questo documento." +msgstr "Clicca il pulsante qui sotto per modificare i permessi di questo documento." msgid "Change Document Permissions" msgstr "Cambia i permessi del Documento" @@ -1124,9 +1080,7 @@ msgid "Check Schema mandatory fields" msgstr "Controllare i campi obbligatori dello schema" msgid "Error updating metadata. Please check the following fields: " -msgstr "" -"Errore nell'aggiornamento dei metadati. Per favore controlla i seguenti " -"campi: " +msgstr "Errore nell'aggiornamento dei metadati. Per favore controlla i seguenti campi: " msgid "Done" msgstr "Fatto" @@ -1156,7 +1110,7 @@ msgid "Return to Document" msgstr "Ritorna al documento" msgid "Deleting" -msgstr "Sto cancellando" +msgstr "Sto eliminando" msgid "Remove Document" msgstr "Rimuovi documento" @@ -1164,13 +1118,13 @@ msgstr "Rimuovi documento" #, python-format msgid "" "\n" -" Are you sure you want to remove %(document_title)s?\n" +" Are you sure you want to remove %(document_title)s?\n" " " msgstr "" "\n" -" Sei sicuro di voler rimuovere %(document_title)s?\n" +" Sei sicuro di voler rimuovere %(document_title)s?\n" " " msgid "Yes, I am sure" @@ -1300,7 +1254,7 @@ msgid "Log in to add/delete Favorites." msgstr "Accedi per aggiungere/eliminare preferiti." msgid "Delete from Favorites" -msgstr "Cancella dai preferiti" +msgstr "Elimina dai preferiti" msgid "Favorites" msgstr "Preferiti" @@ -1400,12 +1354,11 @@ msgid "Embed Iframe" msgstr "Incorpora Iframe" msgid "" -"To embed this map, add the following code snippet and customize its " -"properties (scrolling, width, height) based on your needs to your site" +"To embed this map, add the following code snippet and customize its properties (scrolling, " +"width, height) based on your needs to your site" msgstr "" -"Per incorporare questa mappa, aggiungere il frammento di codice seguente e " -"personalizzarne le proprietà (scorrimento, larghezza, altezza) in base alle " -"proprie esigenze nel sito" +"Per incorporare questa mappa, aggiungere il frammento di codice seguente e personalizzarne " +"le proprietà (scorrimento, larghezza, altezza) in base alle proprie esigenze nel sito" msgid "Download" msgstr "Scarica" @@ -1425,22 +1378,19 @@ msgid "for %(map_title)s" msgstr "per %(map_title)s" msgid "" -"Note: this geoapp's orginal metadata was populated by importing a metadata " -"XML file.\n" -" GeoNode's metadata import supports a subset of ISO, FGDC, and Dublin " -"Core metadata elements.\n" +"Note: this geoapp's orginal metadata was populated by importing a metadata XML file.\n" +" GeoNode's metadata import supports a subset of ISO, FGDC, and Dublin Core metadata " +"elements.\n" " Some of your original metadata may have been lost." msgstr "" -"Nota: i metadati orginali di questa geoapp sono stati popolati importando un " -"file XML di metadati.\n" -" L'importazione dei metadati di GeoNode supporta un sottoinsieme di " -"elementi di metadati ISO, FGDC e Dublin Core.\n" +"Nota: i metadati orginali di questa geoapp sono stati popolati importando un file XML di " +"metadati.\n" +" L'importazione dei metadati di GeoNode supporta un sottoinsieme di elementi di " +"metadati ISO, FGDC e Dublin Core.\n" " Alcuni dei metadati originali potrebbero essere stati persi." -#, fuzzy -#| msgid "Explore Maps" msgid "Explore Apps" -msgstr "Esplora mappe" +msgstr "Esplora apps" #, python-format msgid "" @@ -1452,10 +1402,8 @@ msgstr "" " Modifica dei dettagli per il %(map_title)s\n" " " -#, fuzzy -#| msgid "Return to Map" msgid "Return to GeoApp" -msgstr "Ritorna alla mappa" +msgstr "Ritorna alla GeoApp" msgid "New Map" msgstr "Nuova mappa" @@ -1463,13 +1411,13 @@ msgstr "Nuova mappa" #, python-format msgid "" "\n" -" Are you sure you want to remove %(resource_title)s?\n" +" Are you sure you want to remove %(resource_title)s?\n" " " msgstr "" "\n" -" Siete sicuri di voler rimuovere %(resource_title)s?\n" +" Siete sicuri di voler rimuovere %(resource_title)s?\n" " " msgid "Other Settings" @@ -1523,39 +1471,29 @@ msgstr "Linee" msgid "Polygons" msgstr "Poligoni" -#, fuzzy -#| msgid "dataset" msgid "Dataset name" -msgstr "dataset" +msgstr "Nome del dataset" -#, fuzzy -#| msgid "Dataset Attributes" msgid "Dataset title" -msgstr "Attributi del dataset" +msgstr "Titolo del dataset" msgid "Geometry type" msgstr "Tipo di geometria" -#, fuzzy -#| msgid "Featured Datasets" msgid "Create Dataset" -msgstr "Dataset in primo piano" +msgstr "Crea Dataset" -#, fuzzy -#| msgid "Explore Layers" msgid "Explore Datasets" -msgstr "Esplora livelli" +msgstr "Esplora dataset" -#, fuzzy -#| msgid "Create an empty layer" msgid "Create an empty dataset" -msgstr "Creare un livello vuoto" +msgstr "Crea un dataset vuoto" msgid "Add Attribute" -msgstr "Aggiungere attributo" +msgstr "Aggiungi attributo" msgid "Create" -msgstr "Creare" +msgstr "Crea" msgid "String" msgstr "Stringa" @@ -1616,18 +1554,16 @@ msgstr "PNG" msgid "You don't have permissions to change style for this layer" msgstr "" -"Non si dispone delle autorizzazioni necessarie per modificare lo stile di " -"questo livello" +"Non si dispone delle autorizzazioni necessarie per modificare lo stile di questo livello" msgid "Bad HTTP Authorization Credentials." msgstr "Credenziali di autorizzazione HTTP errate." msgid "" -"a short version of the name consisting only of letters, numbers, underscores " -"and hyphens." +"a short version of the name consisting only of letters, numbers, underscores and hyphens." msgstr "" -"una versione abbreviata del nome consiste solo di lettere, numeri, caratteri " -"di sottolineatura e trattini." +"una versione abbreviata del nome consiste solo di lettere, numeri, caratteri di " +"sottolineatura e trattini." msgid "A group already exists with that slug." msgstr "Un gruppo esiste già con questo slug." @@ -1642,10 +1578,8 @@ msgid "Assign manager role" msgstr "Assegnare il ruolo di manager" #, python-format -msgid "" -"The following are not valid usernames: %(errors)s; not added to the group" -msgstr "" -"I seguenti non sono nomi utente validi: %(errors)s; non aggiunto al gruppo" +msgid "The following are not valid usernames: %(errors)s; not added to the group" +msgstr "I seguenti non sono nomi utente validi: %(errors)s; non aggiunto al gruppo" msgid "Group Categories" msgstr "Categorie gruppo" @@ -1660,24 +1594,23 @@ msgid "Private" msgstr "Privato" msgid "" -"Public: Any registered user can view and join a public group.
Public " -"(invite-only):Any registered user can view the group. Only invited users " -"can join.
Private: Registered users cannot see any details about the " -"group, including membership. Only invited users can join." +"Public: Any registered user can view and join a public group.
Public (invite-only):Any " +"registered user can view the group. Only invited users can join.
Private: Registered " +"users cannot see any details about the group, including membership. Only invited users can " +"join." msgstr "" -"Pubblico: Qualsiasi utente registrato può visualizzare e unirsi a un gruppo " -"pubblico.
(Solo su invito) Pubblico: Qualsiasi utente registrato può " -"visualizzare il gruppo. Solo gli utenti invitati possono partecipare.
" -"Per uso privato: Gli utenti registrati non possono vedere tutti i dettagli " -"sul gruppo, compresa l'appartenenza. Solo gli utenti invitati possono " -"partecipare." +"Pubblico: Qualsiasi utente registrato può visualizzare e unirsi a un gruppo pubblico.
" +"(Solo su invito) Pubblico: Qualsiasi utente registrato può visualizzare il gruppo. Solo gli " +"utenti invitati possono partecipare.
Per uso privato: Gli utenti registrati non possono " +"vedere tutti i dettagli sul gruppo, compresa l'appartenenza. Solo gli utenti invitati " +"possono partecipare." msgid "" -"Email used to contact one or all group members, such as a mailing list, " -"shared email, or exchange group." +"Email used to contact one or all group members, such as a mailing list, shared email, or " +"exchange group." msgstr "" -"E-mail utilizzato per contattare gli utenti di uno o di tutti i gruppi, come " -"ad esempio una mailing list, e-mail condivisa, o un gruppo di scambio." +"E-mail utilizzato per contattare gli utenti di uno o di tutti i gruppi, come ad esempio una " +"mailing list, e-mail condivisa, o un gruppo di scambio." msgid "Logo" msgstr "Logo" @@ -1777,9 +1710,7 @@ msgid "Join Group" msgstr "Iscriviti al gruppo" msgid "Anyone may view this group but membership is by invitation only." -msgstr "" -"Chiunque può visualizzare questo gruppo, ma l'iscrizione è possibile solo su " -"invito." +msgstr "Chiunque può visualizzare questo gruppo, ma l'iscrizione è possibile solo su invito." msgid "Membership is by invitation only." msgstr "L'iscrizione è possibile solo su invito." @@ -1862,30 +1793,25 @@ msgstr "Inserimento risorse..." msgid "checking-availability" msgstr "" -#, fuzzy -#| msgid "Harvested" msgid "Harvester name" -msgstr "Raccolte" +msgstr "Nome dell'harvester" msgid "Base URL of the remote service that is to be harvested" msgstr "" msgid "" -"Whether to periodically schedule this harvester to look for resources on the " -"remote service" +"Whether to periodically schedule this harvester to look for resources on the remote service" msgstr "" msgid "" -"How often (in minutes) should new harvesting sessions be automatically " -"scheduled? Setting this value to zero has the same effect as setting " -"`scheduling_enabled` to False " +"How often (in minutes) should new harvesting sessions be automatically scheduled? Setting " +"this value to zero has the same effect as setting `scheduling_enabled` to False " msgstr "" msgid "Whether the remote service is known to be available or not" msgstr "" -msgid "" -"How often (in minutes) should the remote service be checked for availability?" +msgid "How often (in minutes) should the remote service be checked for availability?" msgstr "" msgid "Last time the remote server was checked for availability" @@ -1894,26 +1820,20 @@ msgstr "" msgid "Last time the remote server was checked for harvestable resources" msgstr "" -#, fuzzy -#| msgid "version of the cited resource" msgid "Default owner of harvested resources" -msgstr "versione della risorsa citata" +msgstr "Proprietario di default della risorsa harvestata" -#, fuzzy -#| msgid "Set permissions on selected resources" msgid "Default access permissions of harvested resources" -msgstr "Impostare le autorizzazioni per le risorse selezionate" +msgstr "Autorizzazioni di default per le risorse harvestate" -msgid "" -"Should new resources be harvested automatically without explicit selection?" +msgid "Should new resources be harvested automatically without explicit selection?" msgstr "" msgid "" -"Orphan resources are those that have previously been created by means of a " -"harvesting operation but that GeoNode can no longer find on the remote " -"service being harvested. Should these resources be deleted from GeoNode " -"automatically? This also applies to when a harvester configuration is " -"deleted, in which case all of the resources that originated from that " +"Orphan resources are those that have previously been created by means of a harvesting " +"operation but that GeoNode can no longer find on the remote service being harvested. Should " +"these resources be deleted from GeoNode automatically? This also applies to when a harvester " +"configuration is deleted, in which case all of the resources that originated from that " "harvester are now considered to be orphan." msgstr "" @@ -1921,14 +1841,14 @@ msgid "Date of last update to the harvester configuration." msgstr "" msgid "" -"Harvester class used to perform harvesting sessions. New harvester types can " -"be added by an admin by changing the main GeoNode `settings.py` file" +"Harvester class used to perform harvesting sessions. New harvester types can be added by an " +"admin by changing the main GeoNode `settings.py` file" msgstr "" msgid "" -"Configuration specific to each harvester type. Please consult GeoNode " -"documentation on harvesting for more info. This field is mandatory, so at " -"the very least an empty object (i.e. {}) must be supplied." +"Configuration specific to each harvester type. Please consult GeoNode documentation on " +"harvesting for more info. This field is mandatory, so at the very least an empty object (i." +"e. {}) must be supplied." msgstr "" msgid "Periodic task used to configure harvest scheduling" @@ -1950,17 +1870,15 @@ msgid "harvesting-resource" msgstr "Inserimento risorse..." msgid "" -"Identifier that allows referencing the resource on its remote service in a " -"unique fashion. This is usually automatically filled by the harvester " -"worker. The harvester worker needs to know how to either read or generate " -"this from each remote resource in order to be able to compare the " -"availability of resources between consecutive harvesting sessions." +"Identifier that allows referencing the resource on its remote service in a unique fashion. " +"This is usually automatically filled by the harvester worker. The harvester worker needs to " +"know how to either read or generate this from each remote resource in order to be able to " +"compare the availability of resources between consecutive harvesting sessions." msgstr "" msgid "" -"Type of the resource in the remote service. Each harvester worker knows how " -"to fill this field, in accordance with the resources for which harvesting is " -"supported" +"Type of the resource in the remote service. Each harvester worker knows how to fill this " +"field, in accordance with the resources for which harvesting is supported" msgstr "" #, python-brace-format @@ -1973,9 +1891,7 @@ msgstr "L'indirizzo e-mail '{email}' ha già accettato un invito." #, python-brace-format msgid "An active user is already using the e-mail address '{email}'" -msgstr "" -"Un utente attivo sta già utilizzando l'indirizzo di posta elettronica " -"'{email}'" +msgstr "Un utente attivo sta già utilizzando l'indirizzo di posta elettronica '{email}'" msgid "E-mail" msgstr "E-mail" @@ -1986,11 +1902,11 @@ msgstr "Inviti mandati con successo a '%(email)s'" #, python-format msgid "" -"Sorry, it was not possible to invite '%(email)s' due to the following isse: " -"%(error)s (%(type)s)" +"Sorry, it was not possible to invite '%(email)s' due to the following isse: %(error)s " +"(%(type)s)" msgstr "" -"Spiacenti, non è stato possibile invitare '%(email)s' a causa di quanto " -"segue: %(error)s (%(type)s)" +"Spiacenti, non è stato possibile invitare '%(email)s' a causa di quanto segue: %(error)s " +"(%(type)s)" msgid "Layer Created" msgstr "Livello creato" @@ -2074,8 +1990,7 @@ msgid "use featureinfo custom template?" msgstr "utilizzare il modello personalizzato featureinfo?" msgid "specifies wether or not use a custom GetFeatureInfo template." -msgstr "" -"specifica se si utilizza o meno un modello GetFeatureInfo personalizzato." +msgstr "specifica se si utilizza o meno un modello GetFeatureInfo personalizzato." msgid "featureinfo custom template" msgstr "featureinfo modello personalizzato" @@ -2119,11 +2034,8 @@ msgstr "specifica se l'attributo deve essere visualizzato nei risultati" msgid "display order" msgstr "mostra ordine" -msgid "" -"specifies the order in which attribute should be displayed in identify " -"results" -msgstr "" -"specifica l'ordine con cui l'attributo deve essere visualizzato nei risultati" +msgid "specifies the order in which attribute should be displayed in identify results" +msgstr "specifica l'ordine con cui l'attributo deve essere visualizzato nei risultati" msgid "Label" msgstr "Etichetta" @@ -2159,11 +2071,10 @@ msgid "featureinfo type" msgstr "tipo featureinfo" msgid "" -"specifies if the attribute should be rendered with an HTML widget on " -"GetFeatureInfo template." +"specifies if the attribute should be rendered with an HTML widget on GetFeatureInfo template." msgstr "" -"specifica se il rendering dell'attributo deve essere eseguito con un widget " -"HTML nel modello GetFeatureInfo." +"specifica se il rendering dell'attributo deve essere eseguito con un widget HTML nel modello " +"GetFeatureInfo." msgid "count" msgstr "conta" @@ -2216,15 +2127,11 @@ msgstr "ultimo modificato" msgid "date when attribute statistics were last updated" msgstr "data in cui le statistiche degli attributi sono stati aggiornati" -#, fuzzy -#| msgid "Append Data" msgid "Append to Dataset" -msgstr "Accoda dati" +msgstr "Aggiungi al dataset" -#, fuzzy -#| msgid "Return to Layer" msgid "Return to Dataset" -msgstr "Torna al livello" +msgstr "Torna al dataset" msgid "Preserve Metadata XML" msgstr "Preserva metadati XML" @@ -2241,10 +2148,8 @@ msgstr "Modifica contatto" msgid "Assign a new point of contact to the layers below:" msgstr "Assegna un nuovo contatto ai layer sottostanti:" -#, fuzzy -#| msgid "Layer WMS GetCapabilities document" msgid "Dataset WMS GetCapabilities document" -msgstr "Documento WMS GetCapabilities del livello" +msgstr "Documento WMS GetCapabilities del dataset" msgid "Filter Granules" msgstr "Filtra granuli" @@ -2300,18 +2205,14 @@ msgstr "Mediana" msgid "Standard Deviation" msgstr "Deviazione standard" -#, fuzzy -#| msgid "Rate this layer" msgid "Rate this dataset" -msgstr "Giudica questo livello" +msgstr "Valuta questo dataset" msgid "Analyze with" msgstr "Analizza con" -#, fuzzy -#| msgid "Download the" msgid "Download Dataset" -msgstr "Scarica il" +msgstr "Scarica dataset" msgid "Images" msgstr "Immagini" @@ -2356,11 +2257,10 @@ msgid "Pick your download format:" msgstr "Seleziona formato da scaricare:" msgid "" -"No data available for this resource. Please contact a system administrator " -"or a manager." +"No data available for this resource. Please contact a system administrator or a manager." msgstr "" -"Nessun dato disponibile per questa risorsa. Contattare un amministratore di " -"sistema o un responsabile." +"Nessun dato disponibile per questa risorsa. Contattare un amministratore di sistema o un " +"responsabile." msgid "Warning" msgstr "Attenzione" @@ -2377,11 +2277,9 @@ msgstr "Stili" msgid "Manage" msgstr "Gestisci" -#, fuzzy -#| msgid "Layers" msgid "Layer" msgid_plural "Layers" -msgstr[0] "Livelli" +msgstr[0] "Livello" msgstr[1] "Livelli" msgid "Append Data" @@ -2390,10 +2288,8 @@ msgstr "Accoda dati" msgid "Edit data" msgstr "Modifica dati" -#, fuzzy -#| msgid "View Metadata" msgid "View Dataset" -msgstr "Metadati" +msgstr "Vedi Dataset" msgid "Attribute Information" msgstr "Informazioni sugli attributi" @@ -2404,44 +2300,26 @@ msgstr "ISO Feature Catalogue" msgid "Legend" msgstr "Legenda" -#, fuzzy -#| msgid "Maps using this layer" msgid "Maps using this dataset" -msgstr "Mappe che usano questo livello" +msgstr "Mappe che usano questo dataset" -#, fuzzy -#| msgid "List of maps using this layer:" msgid "List of maps using this dataset:" -msgstr "Elenco di mappe che usano questo livello:" +msgstr "Elenco delle mappe che usano questo dataset:" -#, fuzzy -#| msgid "This layer is not currently used in any maps." msgid "This dataset is not currently used in any maps." -msgstr "Questo livello non è al momento utilizzato da alcuna mappa." +msgstr "Questo dataset non è al momento utilizzato da alcuna mappa." -#, fuzzy -#| msgid "Create a map using this layer" msgid "Create a map using this dataset" -msgstr "Crea una mappa usando questo livello" +msgstr "Crea una mappa usando questo dataset" -#, fuzzy -#| msgid "Click the button below to generate a new map based on this layer." msgid "Click the button below to generate a new map based on this dataset." -msgstr "" -"Clicca il pulsante qui sotto per generare una nuova mappa basata su questo " -"livello." +msgstr "Clicca il pulsante qui sotto per generare una nuova mappa basata su questo dataset." -#, fuzzy -#| msgid "Add the layer to an existing map" msgid "Add the dataset to an existing map" -msgstr "Aggiungi il livello ad una mappa esistente" +msgstr "Aggiungi il dataset ad una mappa esistente" -#, fuzzy -#| msgid "Click the button below to add the layer to the selected map." msgid "Click the button below to add the dataset to the selected map." -msgstr "" -"Fare clic sul pulsante sottostante per aggiungere il livello alla mappa " -"selezionata." +msgstr "Fare clic sul pulsante sottostante per aggiungere il dataset alla mappa selezionata." msgid "Add to Map" msgstr "Aggiungi alla mappa" @@ -2453,11 +2331,11 @@ msgid "List of documents related to this layer:" msgstr "Elenco di documenti correlati a questo livello:" msgid "" -"The following styles are associated with this layer. Choose a style to view " -"it in the preview map." +"The following styles are associated with this layer. Choose a style to view it in the " +"preview map." msgstr "" -"I seguenti stili sono associati a questo livello. Scegli uno stile da " -"visualizzare nell'anteprima della mappa." +"I seguenti stili sono associati a questo livello. Scegli uno stile da visualizzare " +"nell'anteprima della mappa." msgid "(default style)" msgstr "(stile predefinito)" @@ -2476,17 +2354,17 @@ msgstr "Aggiorna attributi e statistiche di questo livello" #, fuzzy #| msgid "" -#| "Click the button below to allow GeoNode refreshing the list of available " -#| "Layer Attributes. If the option 'WPS_ENABLED' has been also set on the " -#| "backend, it will recalculate their statistics too." +#| "Click the button below to allow GeoNode refreshing the list of available Layer " +#| "Attributes. If the option 'WPS_ENABLED' has been also set on the backend, it will " +#| "recalculate their statistics too." msgid "" -"Click the button below to allow GeoNode refreshing the list of available " -"Dataset Attributes. If the option 'WPS_ENABLED' has been also set on the " -"backend, it will recalculate their statistics too." +"Click the button below to allow GeoNode refreshing the list of available Dataset Attributes. " +"If the option 'WPS_ENABLED' has been also set on the backend, it will recalculate their " +"statistics too." msgstr "" -"Clicca il pulsante sotto per permettere a GeoNode di aggiornare la lista " -"degli attributi del livello. Se l'opzione 'WPS_ENABLED' è stata settata sul " -"backend, verranno anche ricalcolate le statistiche del livello." +"Clicca il pulsante sotto per permettere a GeoNode di aggiornare la lista degli attributi del " +"livello. Se l'opzione 'WPS_ENABLED' è stata settata sul backend, verranno anche ricalcolate " +"le statistiche del livello." msgid "Refresh Attributes and Statistics" msgstr "Aggiorna attributi e statistiche" @@ -2495,8 +2373,7 @@ msgid "Clear the Server Cache of this layer" msgstr "Pulisci la Cache Server di questo livello" msgid "Click the button below to wipe the tile-cache of this layer." -msgstr "" -"Clicca il pulsante qui sotto per eliminare la tile-cache di questo livello." +msgstr "Clicca il pulsante qui sotto per eliminare la tile-cache di questo livello." #, fuzzy #| msgid "Empty Tiled-Layer Cache" @@ -2504,13 +2381,10 @@ msgid "Empty Tiled-Dataset Cache" msgstr "Svuota la tile-cache del livello" msgid "Click the button below to change the permissions of this layer." -msgstr "" -"Clicca il pulsante qui sotto per modificare i permessi di questo livello." +msgstr "Clicca il pulsante qui sotto per modificare i permessi di questo livello." -#, fuzzy -#| msgid "Change Layer Permissions" msgid "Change Dataset Permissions" -msgstr "Cambia i permessi del livello" +msgstr "Cambia i permessi del dataset" msgid "Remove Mosaic Granules" msgstr "Rimuovere i granuli del mosaico" @@ -2518,13 +2392,13 @@ msgstr "Rimuovere i granuli del mosaico" #, python-format msgid "" "\n" -" Are you sure you want to remove Granule %(granule_id)s of the " -"Mosaic %(layer_title)s?\n" +" Are you sure you want to remove Granule %(granule_id)s of the Mosaic %(layer_title)s?\n" " " msgstr "" "\n" -" Si è sicuri di voler rimuovere il granulo %(granule_id)s del " -"mosaico %(layer_title)s?\n" +" Si è sicuri di voler rimuovere il granulo %(granule_id)s del mosaico %(layer_title)s?\n" " " msgid "This action affects the following maps:" @@ -2533,35 +2407,32 @@ msgstr "Questa azione avrà effetto sulle seguenti mappe:" msgid "No maps are using this layer" msgstr "Nessuna mappa utilizza questo livello" -#, fuzzy -#| msgid "Upload status" msgid "Upload Datasets" -msgstr "Stato caricamento" +msgstr "Carica dataset" #, python-format msgid "for %(layer_title)s" msgstr "per %(layer_title)s" msgid "" -"Note: this layer's orginal metadata was populated and preserved by importing " -"a metadata XML file.\n" +"Note: this layer's orginal metadata was populated and preserved by importing a metadata XML " +"file.\n" " This metadata cannot be edited." msgstr "" -"Nota: i metadati originali di questo livello sono stati popolati e " -"conservati importando un file XML di metadati.\n" +"Nota: i metadati originali di questo livello sono stati popolati e conservati importando un " +"file XML di metadati.\n" " Questi metadati non possono essere modificati." msgid "" -"Note: this layer's orginal metadata was populated by importing a metadata " -"XML file.\n" -" GeoNode's metadata import supports a subset of ISO, FGDC, and " -"Dublin Core metadata elements.\n" +"Note: this layer's orginal metadata was populated by importing a metadata XML file.\n" +" GeoNode's metadata import supports a subset of ISO, FGDC, and Dublin Core metadata " +"elements.\n" " Some of your original metadata may have been lost." msgstr "" -"Note: I metadati originali di questo livello sono stati popolati importando " -"un file di metadati XML.\n" -" L'import dei metadati di GeoNode supporta un sottinsieme di elementi " -"di metadata Dublin Core, ISO e FGDC.\n" +"Note: I metadati originali di questo livello sono stati popolati importando un file di " +"metadati XML.\n" +" L'import dei metadati di GeoNode supporta un sottinsieme di elementi di metadata " +"Dublin Core, ISO e FGDC.\n" " Alcuni dei tuoi metadati originali potrebbero essere andati persi." #, python-format @@ -2608,13 +2479,13 @@ msgstr "Rimuovi i livelli" #, python-format msgid "" "\n" -" Are you sure you want to remove %(layer_title)s?\n" +" Are you sure you want to remove %(layer_title)s?\n" " " msgstr "" "\n" -" Sei sicuro di voler eliminare %(layer_title)s?\n" +" Sei sicuro di voler eliminare %(layer_title)s?\n" " " #, fuzzy @@ -2628,29 +2499,25 @@ msgstr "Gestisci stili" #, python-format msgid "" "\n" -" Manage Available Styles for " -"%(layer_title)s\n" +" Manage Available Styles for %(layer_title)s\n" " " msgstr "" "\n" -" Gestisci gli stili disponibili per " -"%(layer_title)s\n" +" Gestisci gli stili disponibili per %(layer_title)s\n" " " -#, fuzzy -#| msgid "Layer Default Style" msgid "Dataset Default Style" -msgstr "Stile predefinito del livello" +msgstr "Stile predefinito del dataset" msgid "Available styles" msgstr "Stili disponibili" msgid "" -"Click on an available style in the upper box to assign it to this layer. " -"Selected styles appear in the lower box." +"Click on an available style in the upper box to assign it to this layer. Selected styles " +"appear in the lower box." msgstr "" -"Fare clic su uno stile disponibile nella casella in alto per assegnare a " -"questo livello. Stili selezionati vengono visualizzati nel riquadro in basso." +"Fare clic su uno stile disponibile nella casella in alto per assegnare a questo livello. " +"Stili selezionati vengono visualizzati nel riquadro in basso." msgid "Update Available Styles" msgstr "Aggiorna gli stili disponibili" @@ -2658,10 +2525,8 @@ msgstr "Aggiorna gli stili disponibili" msgid "Return to Layer" msgstr "Torna al livello" -#, fuzzy -#| msgid "Upload Layer Style" msgid "Upload Dataset Style" -msgstr "Carica stile del livello" +msgstr "Carica stile del dataset" msgid "(SLD - Style Layer Descriptor 1.0, 1.1)" msgstr "(SLD - Descrittore di stile del livello 1.0, 1.1)" @@ -2670,8 +2535,7 @@ msgid "WARNING" msgstr "ATTENZIONE" msgid "This will most probably overwrite the current default style!" -msgstr "" -"Questo molto probabilmente sovrascriverà lo stile predefinito corrente!" +msgstr "Questo molto probabilmente sovrascriverà lo stile predefinito corrente!" msgid "Preview" msgstr "Anteprima" @@ -2679,34 +2543,29 @@ msgstr "Anteprima" msgid "Dataset Attributes" msgstr "Attributi del dataset" -#, fuzzy -#| msgid "Upload status" msgid "Upload Dataset" -msgstr "Stato caricamento" +msgstr "Carica dataset" msgid "Upload status" msgstr "Stato caricamento" msgid "" -"This file needs additional configuration to complete the upload process. " -"Please click on the button to fill the required configuration" +"This file needs additional configuration to complete the upload process. Please click on the " +"button to fill the required configuration" msgstr "" -"Questo file necessita di una configurazione aggiuntiva per completare il " -"processo di caricamento. Clicca sul pulsante per riempire la configurazione " -"richiesta" +"Questo file necessita di una configurazione aggiuntiva per completare il processo di " +"caricamento. Clicca sul pulsante per riempire la configurazione richiesta" msgid "Delete" -msgstr "Cancella" +msgstr "Elimina" msgid "Upload process completed" msgstr "Processo di caricamento completato" -msgid "" -"The upload process cannot be completed because the original file is no more " -"available" +msgid "The upload process cannot be completed because the original file is no more available" msgstr "" -"Impossibile completare il processo di caricamento perché il file originale " -"non è più disponibile" +"Impossibile completare il processo di caricamento perché il file originale non è più " +"disponibile" msgid "Created" msgstr "Creato" @@ -2726,10 +2585,8 @@ msgstr "Eliminare caricamenti incompleti" msgid "Are you sure you want to remove this incomplete upload?" msgstr "Rimuovere questo caricamento incompleto?" -#, fuzzy -#| msgid "Upload Layer Step: Set SRS" msgid "Upload Dataset Step: Set SRS" -msgstr "Carica livello, passo: Imposta SRS" +msgstr "Carica dataset, passo: Imposta SRS" msgid "Provide CRS for " msgstr "Fornire CRS per " @@ -2740,34 +2597,28 @@ msgstr "Sistema di riferimento delle coordinate" #, fuzzy #| msgid "" #| "\n" -#| " A coordinate reference system for this layer could not be " -#| "determined.\n" -#| " Locate or enter the appropriate ESPG code for this layer " -#| "below.\n" +#| " A coordinate reference system for this layer could not be determined.\n" +#| " Locate or enter the appropriate ESPG code for this layer below.\n" #| " One way to do this is do visit:\n" -#| " prj2epsg\n" +#| " prj2epsg\n" #| " and enter the following:\n" #| " " msgid "" "\n" -" A coordinate reference system for this dataset could not be " -"determined.\n" -" Locate or enter the appropriate ESPG code for this dataset " -"below.\n" +" A coordinate reference system for this dataset could not be determined.\n" +" Locate or enter the appropriate ESPG code for this dataset below.\n" " One way to do this is do visit:\n" -" prj2epsg\n" +" prj2epsg\n" " and enter the following:\n" " " msgstr "" "\n" -" Un sistema di riferimento di coordinate per questo strato " -"non poteva essere determinata.\n" -" Individuare o immettere il codice ESPG appropriato per " -"questo livello sottostante.\n" -" Un modo per farlo è fare visitare: prj2epsg\n" +" Un sistema di riferimento di coordinate per questo strato non poteva essere " +"determinata.\n" +" Individuare o immettere il codice ESPG appropriato per questo livello " +"sottostante.\n" +" Un modo per farlo è fare visitare: prj2epsg\n" " e immettere il seguente:\n" " " @@ -2784,27 +2635,24 @@ msgid "Select a Source SRS" msgstr "Seleziona un SRS di origine" msgid "" -"Source SRS EPSG Code is mandatory and represents the native data Spatial " -"Reference System.\n" +"Source SRS EPSG Code is mandatory and represents the native data Spatial Reference System.\n" "

\n" -" This must be coherent with the Geometry values (lon/lat " -"coordinates as an instance) stored on the geospatial dataset.\n" +" This must be coherent with the Geometry values (lon/lat coordinates as " +"an instance) stored on the geospatial dataset.\n" "

\n" -" If not specified on the geospatial data itself, it must " -"be manually declared by the operator.\n" +" If not specified on the geospatial data itself, it must be manually " +"declared by the operator.\n" "

\n" -" More information is provided at the bottom of the page " -"in the \"Additional Help\" sections.\n" +" More information is provided at the bottom of the page in the " +"\"Additional Help\" sections.\n" " " msgstr "" -"Source SRS EPSG Code è obbligatorio e rappresenta il sistema di riferimento " -"spaziale dei dati nativi.

Deve essere coerente " -"con i valori Geometry (coordinate lon/lat come istanza) archiviati nel set " -"di dati geospaziali.

Se non specificato nei dati " -"geospaziali stessi, deve essere dichiarato manualmente " -"dall'operatore.

Ulteriori informazioni sono " -"disponibili nella parte inferiore della pagina nelle sezioni \"Guida " -"aggiuntiva\". " +"Source SRS EPSG Code è obbligatorio e rappresenta il sistema di riferimento spaziale dei " +"dati nativi.

Deve essere coerente con i valori Geometry " +"(coordinate lon/lat come istanza) archiviati nel set di dati geospaziali. " +"

Se non specificato nei dati geospaziali stessi, deve essere dichiarato manualmente " +"dall'operatore.

Ulteriori informazioni sono disponibili nella " +"parte inferiore della pagina nelle sezioni \"Guida aggiuntiva\". " msgid "Next" msgstr "Prossimo" @@ -2831,140 +2679,130 @@ msgid "Spatial Reference System" msgstr "Sistema di riferimento spaziale" msgid "" -"A spatial reference system (SRS) or coordinate reference system (CRS) is a " -"coordinate-based local,\n" -" regional or global system used to locate " -"geographical entities. A spatial reference system defines a specific map\n" -" projection, as well as transformations between " -"different spatial reference systems. Spatial reference systems are\n" -" defined by the OGC's Simple feature access using " -"well-known text, and support has been implemented by several\n" -" standards-based geographic information systems. " -"Spatial reference systems can be referred to using a SRID integer,\n" -" including EPSG codes defined by the " -"International Association of Oil and Gas Producers.\n" -" It is specified in ISO 19111:2007 Geographic " -"information—Spatial referencing by coordinates, also published as\n" -" OGC Abstract Specification, Topic 2: Spatial " -"referencing by coordinate." -msgstr "" -"Un sistema di riferimento spaziale (SRS) o un sistema di riferimento delle " -"coordinate (CRS) è un sistema di riferimento\n" -" sistema regionale o globale utilizzato per " -"individuare le entità geografiche. Un sistema di riferimento spaziale " -"definisce una mappa specifica\n" -" proiezioni, nonché trasformazioni tra diversi " -"sistemi di riferimento spaziale. I sistemi di riferimento spaziali sono\n" -" definito dall'accesso alle funzionalità semplice " -"dell'OGC utilizzando testo noto, e il supporto è stato implementato da " -"diversi\n" -" sistemi di informazione geografica basati su " -"standard. I sistemi di riferimento spaziale possono essere indicati " -"utilizzando un numero intero SRID,\n" -" compresi i codici EPSG definiti " -"dall'Associazione internazionale dei produttori di petrolio e gas.\n" -" È specificato in ISO 19111:2007 Informazioni " -"geografiche: riferimento spaziale in base alle coordinate, pubblicato anche " -"come\n" -" Specifica astratta OGC, Argomento 2: Riferimento " -"spaziale per coordinata." +"A spatial reference system (SRS) or coordinate reference system (CRS) is a coordinate-based " +"local,\n" +" regional or global system used to locate geographical entities. " +"A spatial reference system defines a specific map\n" +" projection, as well as transformations between different spatial " +"reference systems. Spatial reference systems are\n" +" defined by the OGC's Simple feature access using well-known " +"text, and support has been implemented by several\n" +" standards-based geographic information systems. Spatial " +"reference systems can be referred to using a SRID integer,\n" +" including EPSG codes defined by the International Association of " +"Oil and Gas Producers.\n" +" It is specified in ISO 19111:2007 Geographic information—Spatial " +"referencing by coordinates, also published as\n" +" OGC Abstract Specification, Topic 2: Spatial referencing by " +"coordinate." +msgstr "" +"Un sistema di riferimento spaziale (SRS) o un sistema di riferimento delle coordinate (CRS) " +"è un sistema di riferimento\n" +" sistema regionale o globale utilizzato per individuare le entità " +"geografiche. Un sistema di riferimento spaziale definisce una mappa specifica\n" +" proiezioni, nonché trasformazioni tra diversi sistemi di " +"riferimento spaziale. I sistemi di riferimento spaziali sono\n" +" definito dall'accesso alle funzionalità semplice dell'OGC " +"utilizzando testo noto, e il supporto è stato implementato da diversi\n" +" sistemi di informazione geografica basati su standard. I sistemi " +"di riferimento spaziale possono essere indicati utilizzando un numero intero SRID,\n" +" compresi i codici EPSG definiti dall'Associazione internazionale " +"dei produttori di petrolio e gas.\n" +" È specificato in ISO 19111:2007 Informazioni geografiche: " +"riferimento spaziale in base alle coordinate, pubblicato anche come\n" +" Specifica astratta OGC, Argomento 2: Riferimento spaziale per " +"coordinata." msgid "Identifiers" msgstr "Identificatori" msgid "" "\n" -" A Spatial Reference System Identifier (SRID) is a " -"unique value used to unambiguously identify projected, unprojected,\n" -" and local spatial coordinate system definitions. " -"These coordinate systems form the heart of all GIS applications.\n" +" A Spatial Reference System Identifier (SRID) is a unique value used " +"to unambiguously identify projected, unprojected,\n" +" and local spatial coordinate system definitions. These coordinate " +"systems form the heart of all GIS applications.\n" "\n" -" Virtually all major spatial vendors have created " -"their own SRID implementation or refer to those of an authority,\n" +" Virtually all major spatial vendors have created their own SRID " +"implementation or refer to those of an authority,\n" " such as the European Petroleum Survey Group (EPSG).\n" " " msgstr "" "\n" -"Un identificatore SRID (Spatial Reference System Identifier) è un valore " -"univoco utilizzato per identificare in modo inequivocabile\n" -" e le definizioni del sistema di coordinate spaziali " -"locali. Questi sistemi di coordinate costituiscono il cuore di tutte le " -"applicazioni GIS.\n" +"Un identificatore SRID (Spatial Reference System Identifier) è un valore univoco utilizzato " +"per identificare in modo inequivocabile\n" +" e le definizioni del sistema di coordinate spaziali locali. Questi " +"sistemi di coordinate costituiscono il cuore di tutte le applicazioni GIS.\n" "\n" -"Praticamente tutti i principali fornitori spaziali hanno creato la propria " -"implementazione SRID o si riferiscono a quelli di un'autorità,\n" +"Praticamente tutti i principali fornitori spaziali hanno creato la propria implementazione " +"SRID o si riferiscono a quelli di un'autorità,\n" " come l'European Petroleum Survey Group (EPSG).\n" " " msgid "" -"NOTE: As of 2005 the EPSG SRID values are now maintained by the " -"International\n" -" Association of Oil & Gas Producers (OGP) " -"Surveying & Positioning Committee" +"NOTE: As of 2005 the EPSG SRID values are now maintained by the International\n" +" Association of Oil & Gas Producers (OGP) Surveying & Positioning " +"Committee" msgstr "" -"NOTA: a partire dal 2005 i valori EPSG SRID sono ora mantenuti " -"dall'International\n" -" Comitato di rilevamento e posizionamento " -"dell'Associazione dei produttori di petrolio e gas (OGP)" +"NOTA: a partire dal 2005 i valori EPSG SRID sono ora mantenuti dall'International\n" +" Comitato di rilevamento e posizionamento dell'Associazione dei " +"produttori di petrolio e gas (OGP)" msgid "" "\n" -" SRIDs are the primary key for the Open Geospatial " -"Consortium (OGC) spatial_ref_sys metadata table for the Simple\n" -" Features for SQL Specification, Versions 1.1 and " -"1.2, which is defined as follows:\n" +" SRIDs are the primary key for the Open Geospatial Consortium (OGC) " +"spatial_ref_sys metadata table for the Simple\n" +" Features for SQL Specification, Versions 1.1 and 1.2, which is " +"defined as follows:\n" " " msgstr "" "\n" -"Gli S SRID sono la chiave primaria per l'Open Geospatial Consortium (OGC) " -"spatial_ref_sys di metadati per il\n" -" Funzionalità per la specifica SQL, le versioni 1.1 e " -"1.2, definite come segue:\n" +"Gli S SRID sono la chiave primaria per l'Open Geospatial Consortium (OGC) spatial_ref_sys di " +"metadati per il\n" +" Funzionalità per la specifica SQL, le versioni 1.1 e 1.2, definite " +"come segue:\n" " " msgid "" "\n" -" In spatially enabled databases (such as IBM DB2, IBM " -"Informix, Microsoft SQL Server, MySQL, Oracle RDBMS, Teradata, PostGIS and\n" -" SQL Anywhere), SRIDs are used to uniquely identify " -"the coordinate systems used to define columns of spatial data or individual\n" -" spatial objects in a spatial column (depending on " -"the spatial implementation). SRIDs are typically associated with a well " -"known\n" -" text (WKT) string definition of the coordinate " -"system (SRTEXT, above). From the Well Known Text Wikipedia page\n" +" In spatially enabled databases (such as IBM DB2, IBM Informix, " +"Microsoft SQL Server, MySQL, Oracle RDBMS, Teradata, PostGIS and\n" +" SQL Anywhere), SRIDs are used to uniquely identify the coordinate " +"systems used to define columns of spatial data or individual\n" +" spatial objects in a spatial column (depending on the spatial " +"implementation). SRIDs are typically associated with a well known\n" +" text (WKT) string definition of the coordinate system (SRTEXT, " +"above). From the Well Known Text Wikipedia page\n" " " msgstr "" "\n" -"Nei database abilitati per l'ambiente (come IBM DB2, IBM Informix, Microsoft " -"SQL Server, MySQL, Oracle RDBMS, Teradata, PostGIS e\n" -" SQL Anywhere), gli SED vengono utilizzati per " -"identificare in modo univoco i sistemi di coordinate utilizzati per definire " -"colonne di dati spaziali o singoli\n" +"Nei database abilitati per l'ambiente (come IBM DB2, IBM Informix, Microsoft SQL Server, " +"MySQL, Oracle RDBMS, Teradata, PostGIS e\n" +" SQL Anywhere), gli SED vengono utilizzati per identificare in modo " +"univoco i sistemi di coordinate utilizzati per definire colonne di dati spaziali o singoli\n" " oggetti spaziali in una colonna spaziale (a seconda " "dell'implementazione spaziale). I SED sono in genere associati a un\n" -" text (WKT) definizione di stringa del sistema di " -"coordinate (SRTEXT, sopra). Dalla pagina Wikipedia di Ben Known Text\n" +" text (WKT) definizione di stringa del sistema di coordinate (SRTEXT, " +"sopra). Dalla pagina Wikipedia di Ben Known Text\n" " " msgid "" -"“A WKT string for a spatial reference system describes the datum, geoid, " -"coordinate system,\n" +"“A WKT string for a spatial reference system describes the datum, geoid, coordinate system,\n" " and map projection of the spatial objects”." msgstr "" -"\"Una stringa WKT per un sistema di riferimento spaziale descrive il sistema " -"di coordinate di riferimento, geoide,\n" +"\"Una stringa WKT per un sistema di riferimento spaziale descrive il sistema di coordinate " +"di riferimento, geoide,\n" " e la proiezione mappa degli oggetti spaziali\"." msgid "" "\n" -" Here are two common coordinate systems with their " -"EPSG SRID value followed by their well known text:\n" +" Here are two common coordinate systems with their EPSG SRID value " +"followed by their well known text:\n" " " msgstr "" "\n" -" Qui ci sono due sistemi di coordinate comuni con il " -"loro valore di EPSG SRID seguita dal loro WKT:\n" +" Qui ci sono due sistemi di coordinate comuni con il loro valore di " +"EPSG SRID seguita dal loro WKT:\n" " " msgid "" @@ -2987,87 +2825,76 @@ msgstr "" msgid "" "\n" -" SRID values associated with spatial data can be used " -"to constrain spatial operations — for instance, spatial operations cannot be " -"performed\n" -" between spatial objects with differing SRIDs in some " -"systems, or trigger coordinate system transformations between spatial " -"objects in others.\n" +" SRID values associated with spatial data can be used to constrain " +"spatial operations — for instance, spatial operations cannot be performed\n" +" between spatial objects with differing SRIDs in some systems, or " +"trigger coordinate system transformations between spatial objects in others.\n" " " msgstr "" "\n" -"I valori SRID associati ai dati spaziali possono essere utilizzati per " -"vincolare le operazioni spaziali, ad esempio le operazioni spaziali non " -"possono essere eseguite\n" -" tra oggetti spaziali con SRID diversi in alcuni " -"sistemi o attivare trasformazioni del sistema di coordinate tra oggetti " -"spaziali in altri.\n" +"I valori SRID associati ai dati spaziali possono essere utilizzati per vincolare le " +"operazioni spaziali, ad esempio le operazioni spaziali non possono essere eseguite\n" +" tra oggetti spaziali con SRID diversi in alcuni sistemi o attivare " +"trasformazioni del sistema di coordinate tra oggetti spaziali in altri.\n" " " msgid "" -"Source SRS EPSG Code is mandatory and represents the native data Spatial " -"Reference System. This must be coherent with the\n" -" Geometry values (lon/lat coordinates as an " -"instance) stored on the geospatial dataset. If not specified on the " -"geospatial data itself, it\n" +"Source SRS EPSG Code is mandatory and represents the native data Spatial Reference System. " +"This must be coherent with the\n" +" Geometry values (lon/lat coordinates as an instance) stored on " +"the geospatial dataset. If not specified on the geospatial data itself, it\n" " must be manually declared by the operator." msgstr "" -"Source SRS EPSG Code è obbligatorio e rappresenta il sistema di riferimento " -"spaziale dei dati nativi. Questo deve essere coerente con il\n" -" Valori geometrici (coordinate lon/lat come " -"istanza) archiviati nel set di dati geospaziali. Se non viene specificato " -"sui dati geospaziali stessi,\n" -" deve essere dichiarato manualmente " -"dall'operatore." +"Source SRS EPSG Code è obbligatorio e rappresenta il sistema di riferimento spaziale dei " +"dati nativi. Questo deve essere coerente con il\n" +" Valori geometrici (coordinate lon/lat come istanza) archiviati " +"nel set di dati geospaziali. Se non viene specificato sui dati geospaziali stessi,\n" +" deve essere dichiarato manualmente dall'operatore." msgid "" -"Target SRS EPSG Code is optional. This must be used only if we need to re-" -"project the coordinates from Source SRS to another one.\n" +"Target SRS EPSG Code is optional. This must be used only if we need to re-project the " +"coordinates from Source SRS to another one.\n" " " msgstr "" -"Target SRS EPSG Code è facoltativo. Questo deve essere utilizzato solo se è " -"necessario ri-proiettare le coordinate da Source SRS ad un altro.\n" +"Target SRS EPSG Code è facoltativo. Questo deve essere utilizzato solo se è necessario ri-" +"proiettare le coordinate da Source SRS ad un altro.\n" " " #, fuzzy #| msgid "Upload Layer Step: CSV Field Mapping" msgid "Upload Dataset Step: CSV Field Mapping" -msgstr "Upload Layer Step: CSV Field Mapping" +msgstr "Carica dataset, passo: campi CSV" msgid "Geospatial Data" msgstr "Dati geospaziali" msgid "" -"Please indicate which attributes contain the latitude and longitude " -"coordinates in the CSV data." +"Please indicate which attributes contain the latitude and longitude coordinates in the CSV " +"data." msgstr "" -"Si prega di indicare quali attributi contengono le coordinate di latitudine " -"e longitudine nei dati CSV." +"Si prega di indicare quali attributi contengono le coordinate di latitudine e longitudine " +"nei dati CSV." msgid "" "With this data, GeoNode was able to guess which attributes contain the\n" -" latitude and longitude coordinates, but please confirm that " -"the correct\n" +" latitude and longitude coordinates, but please confirm that the correct\n" " attributes are selected below." msgstr "" -"Con questi dati, GeoNode è stato in grado di capire quali attributi " -"contengono\n" -" latitudine e longitudine, ma si prega di verificare che le " -"coordinate siano corrette\n" +"Con questi dati, GeoNode è stato in grado di capire quali attributi contengono\n" +" latitudine e longitudine, ma si prega di verificare che le coordinate siano " +"corrette\n" " per gli attributi sono selezionati di seguito." msgid "Select an attribute" msgstr "Seleziona un attributo" msgid "" -"We did not detect columns that could be used for the latitude and " -"longitude.\n" -" Please verify that you have two columns in your csv file that can be " -"used for\n" +"We did not detect columns that could be used for the latitude and longitude.\n" +" Please verify that you have two columns in your csv file that can be used for\n" " the latitude and longitude." msgstr "" -"Non abbiamo rilevato colonne che potessero essere utilizzate per latitudine " -"e la longitudine.\n" +"Non abbiamo rilevato colonne che potessero essere utilizzate per latitudine e la " +"longitudine.\n" " Verificare di avere due colonne nel file csv che possono essere\n" " latitudine e la longitudine." @@ -3080,10 +2907,8 @@ msgstr "Torna a" msgid "Upload Form" msgstr "Carica Form" -#, fuzzy -#| msgid "Upload Layer Step: Time" msgid "Upload Dataset Step: Time" -msgstr "Caricamento livello: Dimensione temporale" +msgstr "Caricamento dataset: Dimensione temporale" msgid "Inspect data for " msgstr "Esamina dati per " @@ -3092,26 +2917,25 @@ msgid "Configure as Time-Series" msgstr "Configura come serie temporale" msgid "" -"Toggling this selector allows you to configure (or not) this data as a time " -"series; in this case you will also have to select an attribute\n" +"Toggling this selector allows you to configure (or not) this data as a time series; in this " +"case you will also have to select an attribute\n" " to drive the time dimension.\n" "

\n" -" If GeoNode is not able to parse any of the values for " -"the selected attribute red markers will appear to highlight the problems.\n" +" If GeoNode is not able to parse any of the values for the selected " +"attribute red markers will appear to highlight the problems.\n" "

\n" -" More information is provided at the bottom of the page " -"in the \"Additional Help\" sections.\n" +" More information is provided at the bottom of the page in the " +"\"Additional Help\" sections.\n" " " msgstr "" -"L'attivazione/attivazione/attivazione/configurazione di questo selettore " -"consente di configurare (o meno) questi dati come serie di tempo; in questo " -"caso si dovrà anche selezionare un attributo\n" -" per guidare la dimensione " -"temporale.

Se GeoNode non è in grado di " -"analizzare nessuno dei valori per l'attributo selezionato, gli indicatori " -"rossi verranno visualizzati per evidenziare i problemi. " -"

Ulteriori informazioni sono disponibili nella parte inferiore della " -"pagina nelle sezioni \"Guida aggiuntiva\". " +"L'attivazione/attivazione/attivazione/configurazione di questo selettore consente di " +"configurare (o meno) questi dati come serie di tempo; in questo caso si dovrà anche " +"selezionare un attributo\n" +" per guidare la dimensione temporale.

Se " +"GeoNode non è in grado di analizzare nessuno dei valori per l'attributo selezionato, gli " +"indicatori rossi verranno visualizzati per evidenziare i problemi. " +"

Ulteriori informazioni sono disponibili nella parte inferiore della pagina nelle " +"sezioni \"Guida aggiuntiva\". " msgid "No" msgstr "No" @@ -3122,12 +2946,9 @@ msgstr "Utilizzare un attributo timestamp esistente nei dati" msgid "Yes: with an existing Time-Attribute" msgstr "Sì: con un attributo Time esistente" -msgid "" -"Yes: by converting data to a timestamp using standard date/time " -"representation" +msgid "Yes: by converting data to a timestamp using standard date/time representation" msgstr "" -"Sì: convertendo i dati in un timestamp utilizzando la rappresentazione " -"standard di data/ora" +"Sì: convertendo i dati in un timestamp utilizzando la rappresentazione standard di data/ora" msgid "Convert a number field into a year" msgstr "Convertire un campo numerico in un anno" @@ -3135,12 +2956,10 @@ msgstr "Convertire un campo numerico in un anno" msgid "Yes: by converting a number as Year" msgstr "Sì: convertendo un numero come Anno" -msgid "" -"Convert data to a timestamp using standard date/time representation or a " -"custom format" +msgid "Convert data to a timestamp using standard date/time representation or a custom format" msgstr "" -"Convertire i dati in un timestamp utilizzando la rappresentazione di data/" -"ora standard o un formato personalizzato" +"Convertire i dati in un timestamp utilizzando la rappresentazione di data/ora standard o un " +"formato personalizzato" msgid "Start Importer" msgstr "Avvia importazione" @@ -3187,12 +3006,10 @@ msgstr "definito dalla risoluzione" msgid "Continuous Intervals" msgstr "Intervalli continui" -msgid "" -"for data that is frequently updated, resolution describes the frequency of " -"updates" +msgid "for data that is frequently updated, resolution describes the frequency of updates" msgstr "" -"per i dati che viene aggiornato di frequente, risoluzione descrive la " -"frequenza degli aggiornamenti" +"per i dati che viene aggiornato di frequente, risoluzione descrive la frequenza degli " +"aggiornamenti" msgid "Resolution of time attribute" msgstr "Risoluzione di un attributo tempo" @@ -3200,31 +3017,20 @@ msgstr "Risoluzione di un attributo tempo" msgid "Enabling Time" msgstr "Tempo di attivazione" -#, fuzzy -#| msgid "" -#| "A layer can support one or two time attributes. If a single\n" -#| " attribute is used, the layer is considered to " -#| "contain data that is valid at single points in time. If two\n" -#| " attributes are used, the second attribute " -#| "represents the end of a valid period hence the layer is considered\n" -#| " to contain data that is valid at certain periods " -#| "in time." msgid "" "A dataset can support one or two time attributes. If a single\n" -" attribute is used, the dataset is considered to " -"contain data that is valid at single points in time. If two\n" -" attributes are used, the second attribute represents " -"the end of a valid period hence the dataset is considered\n" -" to contain data that is valid at certain periods in " -"time." -msgstr "" -"Un layer può supportare uno o due attributi temporali. Se un singolo\n" -" attributo viene utilizzato, il layer è considerato " -"contenere dati validi in singoli punti nel tempo. Se due\n" -" attributi, il secondo attributo rappresenta la fine " -"di un periodo valido, quindi il layer è considerato\n" -" per contenere dati validi in determinati periodi di " -"tempo." +" attribute is used, the dataset is considered to contain data that is " +"valid at single points in time. If two\n" +" attributes are used, the second attribute represents the end of a " +"valid period hence the dataset is considered\n" +" to contain data that is valid at certain periods in time." +msgstr "" +"Un dataset può supportare uno o due attributi temporali. Se un singolo\n" +" attributo viene utilizzato, il layer è considerato contenere dati " +"validi in singoli punti nel tempo. Se due\n" +" attributi, il secondo attributo rappresenta la fine di un periodo " +"valido, quindi il layer è considerato\n" +" per contenere dati validi in determinati periodi di tempo." msgid "Selecting an Attribute" msgstr "Seleziona un attributo" @@ -3243,30 +3049,24 @@ msgstr "Un numero che rappresenta l'anno" msgid "" "\n" -" For text attributes, one can specify a custom format " -"(as part of the \"Advanced Options\") or use the 'best guess' approach which " -"will try to\n" -" automatically translate well-known recognized " -"patterns into valid times.\n" +" For text attributes, one can specify a custom format (as part of the " +"\"Advanced Options\") or use the 'best guess' approach which will try to\n" +" automatically translate well-known recognized patterns into valid " +"times.\n" " " msgstr "" "\n" -" Per gli attributi di testo, è possibile specificare " -"un formato personalizzato (come parte delle \"Opzioni avanzate\") o " -"utilizzare l'approccio \"best guess\" che cercherà di\n" -" convertire automaticamente i patterns riconosciuti " -"in date valide.\n" +" Per gli attributi di testo, è possibile specificare un formato " +"personalizzato (come parte delle \"Opzioni avanzate\") o utilizzare l'approccio \"best " +"guess\" che cercherà di\n" +" convertire automaticamente i patterns riconosciuti in date valide.\n" " " msgid "The 'best guess' will handle date and optional time variants of" -msgstr "" -"L''ipotesi migliore' in grado di gestire le varianti di data e ora opzionale " -"di" +msgstr "L''ipotesi migliore' in grado di gestire le varianti di data e ora opzionale di" msgid "In terms of the formatting flags noted above, these are" -msgstr "" -"Per quanto riguarda le bandiere di formattazione osservato in precedenza, si " -"tratta di" +msgstr "Per quanto riguarda le bandiere di formattazione osservato in precedenza, si tratta di" msgid "Modal Header" msgstr "Intestazione modale" @@ -3295,11 +3095,9 @@ msgstr "Scelta sbagliata" msgid "Please, select one Time Attribute to test!" msgstr "Si prega di selezionare un Attributo Temporale!" -msgid "" -"Returning to the upload starting page in 5seconds " +msgid "Returning to the upload starting page in 5seconds " msgstr "" -"Sarai rediretto alla pagina iniziale di caricamento entro 5 secondi " +"Sarai rediretto alla pagina iniziale di caricamento entro 5 secondi " msgid " Or just go " msgstr " O semplicemente andare " @@ -3318,21 +3116,13 @@ msgid "You are attempting to {action_type} a raster dataset with a vector." msgstr "Si sta tentando di {action_type} un livello raster con un vettore." #, fuzzy, python-brace-format -#| msgid "" -#| "You are attempting to {action_type} a vector layer with an unknown format." -msgid "" -"You are attempting to {action_type} a vector dataset with an unknown format." -msgstr "" -"Si sta tentando di {action_type} un livello vettoriale con un formato " -"sconosciuto." +#| msgid "You are attempting to {action_type} a vector layer with an unknown format." +msgid "You are attempting to {action_type} a vector dataset with an unknown format." +msgstr "Si sta tentando di {action_type} un livello vettoriale con un formato sconosciuto." #, python-brace-format -msgid "" -"Please ensure the name is consistent with the file you are trying to " -"{action_type}." -msgstr "" -"Assicurarsi che il nome sia coerente con il file che si sta tentando di " -"{action_type}." +msgid "Please ensure the name is consistent with the file you are trying to {action_type}." +msgstr "Assicurarsi che il nome sia coerente con il file che si sta tentando di {action_type}." #, fuzzy #| msgid "Local GeoNode layer has no geometry type." @@ -3341,83 +3131,55 @@ msgstr "Il livello GeoNode locale non ha alcun tipo di geometria." #, python-brace-format msgid "" -"Please ensure there is at least one geometry " -"type that is consistent with the file you are " -"trying to {action_type}." +"Please ensure there is at least one geometry type that is " +"consistent with the file you are trying to {action_type}." msgstr "" -"Assicurarsi che sia disponibile almeno un tipo di geometria coerente con il " -"file che si sta tentando di {action_type}." +"Assicurarsi che sia disponibile almeno un tipo di geometria coerente con il file che si sta " +"tentando di {action_type}." -#, fuzzy -#| msgid "The selected Layer does not exists in the catalog." msgid "The selected Dataset does not exists in the catalog." -msgstr "Il livello selezionato non esiste nel catalogo." +msgstr "Il dataset selezionato non esiste nel catalogo." -#, fuzzy -#| msgid "Please ensure that the layer structure is consistent " msgid "Please ensure that the dataset structure is consistent " -msgstr "Assicurarsi che la struttura del livello sia coerente " +msgstr "Assicurarsi che la struttura del dataset sia coerente " -msgid "" -"Some error occurred while trying to access the uploaded schema: {str(e)}" +msgid "Some error occurred while trying to access the uploaded schema: {str(e)}" msgstr "" -"Si è verificato un errore durante il tentativo di accesso allo schema " -"caricato: {str(e)}" +"Si è verificato un errore durante il tentativo di accesso allo schema caricato: {str(e)}" msgid "" -"There was an error while attempting to upload your data. Please try again, " -"or contact and administrator if the problem continues." +"There was an error while attempting to upload your data. Please try again, or contact and " +"administrator if the problem continues." msgstr "" -"E' occorso un errore durante l'invio dei tuoi dati. Riprova o contatta un " -"amministratore se il problema persiste." +"E' occorso un errore durante l'invio dei tuoi dati. Riprova o contatta un amministratore se " +"il problema persiste." -#, fuzzy -#| msgid "" -#| "Note: this layer's orginal metadata was populated and preserved by " -#| "importing a metadata XML file. This metadata cannot be edited." msgid "" -"Note: this dataset's orginal metadata was populated and preserved by " -"importing a metadata XML file. This metadata cannot be edited." +"Note: this dataset's orginal metadata was populated and preserved by importing a metadata " +"XML file. This metadata cannot be edited." msgstr "" -"Nota: i metadati originali di questo livello sono stati popolati e " -"conservati importando un file XML di metadati. Questi metadati non possono " -"essere modificati." +"Nota: i metadati originali di questo dataset sono stati popolati e conservati importando un " +"file XML di metadati. Questi metadati non possono essere modificati." -#, fuzzy -#| msgid "You are not permitted to delete this layer" msgid "You are not permitted to delete this dataset" -msgstr "Non hai il permesso di eliminare questo livello" +msgstr "Non hai il permesso di eliminare questo dataset" -#, fuzzy -#| msgid "You do not have permissions for this layer." msgid "You do not have permissions for this dataset." -msgstr "Non hai i permessi per questo livello." +msgstr "Non hai i permessi per questo dataset." -#, fuzzy -#| msgid "You are not permitted to modify this layer" msgid "You are not permitted to modify this dataset" -msgstr "Non hai i permessi per modificare questo livello" +msgstr "Non hai i permessi per modificare questo dataset" -#, fuzzy -#| msgid "You are not permitted to modify this layer's metadata" msgid "You are not permitted to modify this dataset's metadata" -msgstr "Non hai il permesso di modificare i metadati di questo livello" +msgstr "Non hai il permesso di modificare i metadati di questo dataset" -#, fuzzy -#| msgid "You are not permitted to view this layer" msgid "You are not permitted to view this dataset" -msgstr "Non hai il permesso di visualizzare questo livello" +msgstr "Non hai il permesso di visualizzare questo dataset" -#, fuzzy -#| msgid "" -#| "This layer is a member of a layer group, you must remove the layer from " -#| "the group before deleting." msgid "" -"This dataset is a member of a layer group, you must remove the dataset from " -"the group before deleting." -msgstr "" -"Questo livello è membro di un gruppo, devi rimuoverlo dal gruppo prima di " -"eliminarlo." +"This dataset is a member of a layer group, you must remove the dataset from the group before " +"deleting." +msgstr "Questo dataset è membro di un gruppo, devi rimuoverlo dal gruppo prima di eliminarlo." #, python-format msgid "couldn't generate thumbnail: %s" @@ -3516,11 +3278,9 @@ msgstr "Valuta questa mappa" msgid "Download Map" msgstr "Scarica la mappa" -#, fuzzy -#| msgid "Maps" msgid "Map" msgid_plural "Maps" -msgstr[0] "Mappe" +msgstr[0] "Mappa" msgstr[1] "Mappe" msgid "Map Layers" @@ -3560,25 +3320,23 @@ msgstr "" msgid "" "\n" -"

Could not find downloadable layers " -"for this map. You can go back to \n" +"
Could not find downloadable layers for this map. " +"You can go back to \n" " " msgstr "" "\n" -"
Impossibile trovare livelli " -"scaricabili per questa mappa. Si può tornare a \n" +"
Impossibile trovare livelli scaricabili per questa " +"mappa. Si può tornare a \n" " " msgid "" "\n" -" Additionally, the map contains these layers which will not be " -"downloaded\n" +" Additionally, the map contains these layers which will not be downloaded\n" " due to security restrictions:\n" " " msgstr "" "\n" -" Inoltre, la mappa contiene questi livelli che non possono essere " -"scaricati\n" +" Inoltre, la mappa contiene questi livelli che non possono essere scaricati\n" " per vincoli di sicurezza:\n" " " @@ -3589,8 +3347,7 @@ msgid "" " " msgstr "" "\n" -" Infine, la mappa contiene questi livelli che non possono essere " -"scaricati\n" +" Infine, la mappa contiene questi livelli che non possono essere scaricati\n" " perchè non sono disponibili direttamente da questo GeoNode:\n" " " @@ -3616,16 +3373,14 @@ msgid "Explore Maps" msgstr "Esplora mappe" msgid "" -"Note: this map's orginal metadata was populated by importing a metadata XML " -"file.\n" -" GeoNode's metadata import supports a subset of ISO, FGDC, and Dublin " -"Core metadata elements.\n" +"Note: this map's orginal metadata was populated by importing a metadata XML file.\n" +" GeoNode's metadata import supports a subset of ISO, FGDC, and Dublin Core metadata " +"elements.\n" " Some of your original metadata may have been lost." msgstr "" -"Nota: i metadati originale di questa mappa era popolato importando un file " -"XML di metadati. Metadati importazione di GeoNode supporta un sottoinsieme " -"di elementi di metadati ISO, FGDC, e Dublin Core. Alcuni dei i metadati " -"originale Potrebbero essere state compromesse." +"Nota: i metadati originale di questa mappa era popolato importando un file XML di metadati. " +"Metadati importazione di GeoNode supporta un sottoinsieme di elementi di metadati ISO, FGDC, " +"e Dublin Core. Alcuni dei i metadati originale Potrebbero essere state compromesse." msgid "Return to Map" msgstr "Ritorna alla mappa" @@ -3636,13 +3391,13 @@ msgstr "Rimuovi mappa" #, python-format msgid "" "\n" -" Are you sure you want to remove %(map_title)s?\n" +" Are you sure you want to remove %(map_title)s?\n" " " msgstr "" "\n" -" Sei sicuro di voler rimuovere " -"%(map_title)s?\n" +" Sei sicuro di voler rimuovere %(map_title)s?\n" " " msgid "You are not permitted to delete this map." @@ -3688,18 +3443,18 @@ msgid "Show list of services" msgstr "Elenco dei servizi" msgid "" -"Process data since specific timestamp (YYYY-MM-DD HH:MM:SS format). If not " -"provided, last sync will be used." +"Process data since specific timestamp (YYYY-MM-DD HH:MM:SS format). If not provided, last " +"sync will be used." msgstr "" -"Elaborare i dati dal timestamp specifico (formato AAAA-MM-GG HH:MM:SS). Se " -"non viene specificato, verrà utilizzata l'ultima sincronizzazione." +"Elaborare i dati dal timestamp specifico (formato AAAA-MM-GG HH:MM:SS). Se non viene " +"specificato, verrà utilizzata l'ultima sincronizzazione." msgid "" -"Process data until specific timestamp (YYYY-MM-DD HH:MM:SS format). If not " -"provided, now will be used." +"Process data until specific timestamp (YYYY-MM-DD HH:MM:SS format). If not provided, now " +"will be used." msgstr "" -"Elaborare i dati fino al timestamp specifico (formato AAAA-MM-GG HH:MM:SS). " -"Se non specificato, verrà utilizzata l'ora attuale." +"Elaborare i dati fino al timestamp specifico (formato AAAA-MM-GG HH:MM:SS). Se non " +"specificato, verrà utilizzata l'ora attuale." msgid "Force check" msgstr "Controllo forzato" @@ -3708,11 +3463,11 @@ msgid "Format of audit log (xml, json)" msgstr "Formato del log di audit (xml, json)" msgid "" -"Should old data be preserved (default: no, data older than settings." -"MONITORING_DATA_TTL will be removed)" +"Should old data be preserved (default: no, data older than settings.MONITORING_DATA_TTL will " +"be removed)" msgstr "" -"Conservare i dati precedenti (impostazione predefinita: no, i dati più " -"vecchi di settings.MONITORING_DATA_TTL verranno rimossi)" +"Conservare i dati precedenti (impostazione predefinita: no, i dati più vecchi di settings." +"MONITORING_DATA_TTL verranno rimossi)" msgid "Should stop on first error occured (default: no)" msgstr "Interruzione al primo errore (impostazione predefinita: no)" @@ -3739,8 +3494,7 @@ msgid "Metric name" msgstr "Nome metrica" msgid "Show data for specific resource in resource_type=resource_name format" -msgstr "" -"Mostra i dati per una risorsa specifica nel formato tipo_risorsa-nome_risorsa" +msgstr "Mostra i dati per una risorsa specifica nel formato tipo_risorsa-nome_risorsa" msgid "Show data for specific resource" msgstr "Mostra dati per risorsa specifica" @@ -3752,12 +3506,10 @@ msgstr "Mostra dati per un'etichetta specifica" msgid "Write result to file, default GEOIP_PATH: {settings.GEOIP_PATH}" msgstr "Scritto risultato su file, default GEOIP_PATH: {settings.GEOIP_PATH}" -msgid "" -"Fetch database from specific url. If nothing provided, default {} will be " -"used" +msgid "Fetch database from specific url. If nothing provided, default {} will be used" msgstr "" -"Recuperare il database da un URL specifico. Se non viene fornito nulla, " -"verrà utilizzato il valore predefinito {}" +"Recuperare il database da un URL specifico. Se non viene fornito nulla, verrà utilizzato il " +"valore predefinito {}" msgid "Overwrite file if exists" msgstr "Sovrascrivi file se esiste già" @@ -3908,8 +3660,7 @@ msgid "Last update must not be older than" msgstr "L'ultimo aggiornamento non deve essere più vecchio di" msgid "Max timeout for given metric before error should be raised" -msgstr "" -"Timeout massimo per la metrica specificata prima che venga generato l'errore" +msgstr "Timeout massimo per la metrica specificata prima che venga generato l'errore" msgid "Monitoring & Analytics" msgstr "Monitoraggio & Analytics" @@ -3972,29 +3723,22 @@ msgstr "Cambia password: %s" msgid "party who authored the resource" msgstr "gruppo che ha creato la risorsa" -msgid "" -"party who has processed the data in a manner such that the resource has been " -"modified" -msgstr "" -"gruppo che ha elaborato i dati in modo tale che la risorsa sia stata " -"modificata" +msgid "party who has processed the data in a manner such that the resource has been modified" +msgstr "gruppo che ha elaborato i dati in modo tale che la risorsa sia stata modificata" msgid "party who published the resource" msgstr "gruppo che ha pubblicato la risorsa" msgid "" -"party that accepts accountability and responsibility for the data and " -"ensures appropriate care and maintenance of the resource" +"party that accepts accountability and responsibility for the data and ensures " +"appropriate care and maintenance of the resource" msgstr "" -"partito che accetta la responsabilità e la responsabilità per i dati e " -"garantisce la cura e la manutenzione appropriata della risorsa" +"partito che accetta la responsabilità e la responsabilità per i dati e garantisce la cura e " +"la manutenzione appropriata della risorsa" -msgid "" -"party who can be contacted for acquiring knowledge about or acquisition of " -"the resource" +msgid "party who can be contacted for acquiring knowledge about or acquisition of the resource" msgstr "" -"parte che può essere contattata per l'acquisizione di conoscenza o " -"acquisizione della risorsa" +"parte che può essere contattata per l'acquisizione di conoscenza o acquisizione della risorsa" msgid "party who distributes the resource" msgstr "parte che distribuisce la risorsa" @@ -4013,8 +3757,7 @@ msgstr "parte che possiede la risorsa" msgid "key party responsible for gathering information and conducting research" msgstr "" -"parte chiave responsabile della raccolta di informazioni e della conduzione " -"della ricerca" +"parte chiave responsabile della raccolta di informazioni e della conduzione della ricerca" msgid "Email Address" msgstr "Indirizzo email" @@ -4041,30 +3784,23 @@ msgid "Voice" msgstr "Voce" msgid "" -"telephone number by which individuals can speak to the responsible " -"organization or individual" +"telephone number by which individuals can speak to the responsible organization or individual" msgstr "" -"numero di telefono con cui le persone possono parlare con l'organizzazione o " -"l'individuo responsabile" +"numero di telefono con cui le persone possono parlare con l'organizzazione o l'individuo " +"responsabile" msgid "Facsimile" msgstr "Fax" -msgid "" -"telephone number of a facsimile machine for the responsible organization or " -"individual" -msgstr "" -"numero di telefono o fax per l'organizzazione o l'individuo responsabile" +msgid "telephone number of a facsimile machine for the responsible organization or individual" +msgstr "numero di telefono o fax per l'organizzazione o l'individuo responsabile" msgid "Delivery Point" msgstr "Informazioni di contatto" -msgid "" -"physical and email address at which the organization or individual may be " -"contacted" +msgid "physical and email address at which the organization or individual may be contacted" msgstr "" -"indirizzo fisico e email ai quali l'organizzazione o l'individuo possono " -"essere contattati" +"indirizzo fisico e email ai quali l'organizzazione o l'individuo possono essere contattati" msgid "City" msgstr "Città" @@ -4091,11 +3827,11 @@ msgid "country of the physical address" msgstr "paese dell'indirizzo fisico" msgid "" -"commonly used word(s) or formalised word(s) or phrase(s) used to describe " -"the subject (space or comma-separated" +"commonly used word(s) or formalised word(s) or phrase(s) used to describe the " +"subject (space or comma-separated" msgstr "" -"parola di uso comune (s) o una parola formalizzata (s) o la frase (s) " -"utilizzati per descrivere il soggetto (spazio o separato da virgole" +"parola di uso comune (s) o una parola formalizzata (s) o la frase (s) utilizzati per " +"descrivere il soggetto (spazio o separato da virgole" msgid "Timezone" msgstr "Fuso orario" @@ -4113,11 +3849,11 @@ msgid "Forgot Username" msgstr "Username dimenticato" msgid "" -"Enter your email address and click the submit button.
Your username " -"will be sent to you." +"Enter your email address and click the submit button.
Your username will be sent to " +"you." msgstr "" -"Inserisci il tuo indirizzo email e clicca il bottone submit.
Il tuo " -"username sarà spedito all'indirizzo specificato." +"Inserisci il tuo indirizzo email e clicca il bottone submit.
Il tuo username sarà " +"spedito all'indirizzo specificato." msgid "Create Profile" msgstr "Crea profilo" @@ -4129,7 +3865,7 @@ msgid "Not provided." msgstr "Non fornito." msgid "Position" -msgstr "Posizione" +msgstr "Incarico" msgid "Organization" msgstr "Organizzazione" @@ -4219,12 +3955,8 @@ msgstr "Non è consentito salvare o modificare questa risorsa." msgid "You are not authorized to download this resource." msgstr "Non è consentito scaricare questa risorsa." -msgid "" -"No files have been found for this resource. Please, contact a system " -"administrator." -msgstr "" -"Nessun file trovato per questa risorsa. Contattare un amministratore di " -"sistema." +msgid "No files have been found for this resource. Please, contact a system administrator." +msgstr "Nessun file trovato per questa risorsa. Contattare un amministratore di sistema." msgid "No files found." msgstr "Nessun file trovato." @@ -4232,10 +3964,8 @@ msgstr "Nessun file trovato." msgid "Not Authorized" msgstr "Non autorizzato" -#, fuzzy -#| msgid "GeoNode Themes Library" msgid "GeoNode Resource Processing Library" -msgstr "Libreria dei temi GeoNode" +msgstr "Libreria dei processamenti GeoNode" msgid "Disabling this Task will make the Processing Workflow to skip it." msgstr "" @@ -4253,11 +3983,11 @@ msgid "Error updating permissions :(" msgstr "Errore durante l'aggiornamento delle autorizzazioni :(" msgid "" -"User {username} has download permissions but cannot access the resource. " -"Please update permission consistently!" +"User {username} has download permissions but cannot access the resource. Please update " +"permission consistently!" msgstr "" -"L'utente {username} ha le autorizzazioni di scaricamento ma non può " -"visualizzare la risorsa. Controllare che le autorizzazioni siano consistenti." +"L'utente {username} ha le autorizzazioni di scaricamento ma non può visualizzare la risorsa. " +"Controllare che le autorizzazioni siano consistenti." msgid "You are not allowed to change permissions for this resource" msgstr "Non ti è consentito modificare le autorizzazioni per questa risorsa" @@ -4480,10 +4210,8 @@ msgstr "La Risorsa {resource_id} è in fase di elaborazione" msgid "Service rescanned successfully" msgstr "Il servizio è stato analizzato di nuovo correttamente" -#, fuzzy -#| msgid "You are not permitted to save or edit this resource." msgid "You dont have enougth rigths to see the resource detail" -msgstr "Non è consentito salvare o modificare questa risorsa." +msgstr "Non hai i privilegi necessari per vedere i dettagli di questa risorsa" msgid "You are not permitted to change this service." msgstr "Non è consentito modificare questo servizio." @@ -4545,13 +4273,12 @@ msgstr "Pagina non trovata" msgid "" "\n" -" The page you requested does not exist. Perhaps you are using an " -"outdated bookmark?\n" +" The page you requested does not exist. Perhaps you are using an outdated " +"bookmark?\n" " " msgstr "" "\n" -" La pagina richiesta non esiste. Forse si utilizza un segnalibro " -"obsoleto?\n" +" La pagina richiesta non esiste. Forse si utilizza un segnalibro obsoleto?\n" " " msgid "Toggle navigation" @@ -4578,31 +4305,28 @@ msgid "You are using an outdated browser that is not supported by GeoNode." msgstr "Stai usando un browser obsoleto che non è supportato da GeoNode." msgid "" -"Please use a modern browser like Mozilla Firefox, Google " -"Chrome or Safari." +"Please use a modern browser like Mozilla Firefox, Google Chrome or Safari." msgstr "" -"Utilizza un browser moderno come Mozilla Firefox, Google " -"Chrome o Safari." +"Utilizza un browser moderno come Mozilla Firefox, Google Chrome o Safari." msgid "There was a problem loading this page" msgstr "C'è stato un problema nel caricare la pagina" msgid "" "\n" -" Please contact your GeoNode administrator (they may have " -"received an email automatically if they configured it properly).\n" -" If you are the site administrator, enable debug mode to see the " -"actual error and fix it or file an issue in
GeoNode's issue tracker\n" +" Please contact your GeoNode administrator (they may have received an email " +"automatically if they configured it properly).\n" +" If you are the site administrator, enable debug mode to see the actual error and " +"fix it or file an issue in GeoNode's " +"issue tracker\n" " " msgstr "" "\n" -" Si prega di contattare l'amministratore di GeoNode (può aver " -"ricevuto un'e-mail automaticamente se ha configurato correttamente).\n" -" Se sei l'amministratore del sito, attivare la modalità di debug " -"visualizzare l'errore effettivo e risolvere il problema o un problema del " -"file nel tracciatore di " -"problemi di GeoNode\n" +" Si prega di contattare l'amministratore di GeoNode (può aver ricevuto un'e-mail " +"automaticamente se ha configurato correttamente).\n" +" Se sei l'amministratore del sito, attivare la modalità di debug visualizzare " +"l'errore effettivo e risolvere il problema o un problema del file nel tracciatore di problemi di GeoNode\n" " " #, fuzzy @@ -4703,21 +4427,16 @@ msgstr "Chi può scaricarlo?" msgid "Who can change metadata for it?" msgstr "Chi può modificare i metadati per questo?" -#, fuzzy -#| msgid "Who can edit data for this layer?" msgid "Who can edit data for this dataset?" -msgstr "Chi può modificare i dati di questo livello?" +msgstr "Chi può modificare i dati di questo dataset?" -#, fuzzy -#| msgid "Who can edit styles for this layer?" msgid "Who can edit styles for this dataset?" -msgstr "Chi può modificare gli stili di questo livello?" +msgstr "Chi può modificare gli stili di questo dataset?" -msgid "" -"Who can manage it? (update, delete, change permissions, publish/unpublish it)" +msgid "Who can manage it? (update, delete, change permissions, publish/unpublish it)" msgstr "" -"Chi può gestirlo? (Aggiornare, cancellare, modificare le autorizzazioni, " -"pubblicare / annullare la pubblicazione di esso)" +"Chi può gestirlo? (Aggiornare, eliminare, modificare le autorizzazioni, pubblicare / " +"annullare la pubblicazione di esso)" msgid "Set permissions for this resource" msgstr "Impostare le autorizzazioni per questa risorsa" @@ -4753,12 +4472,11 @@ msgid "Mandatory files : SHP , DBF" msgstr "File obbligatori: .shp, .dbf" msgid "" -"Upload a ZIP file containing an ESRI Shapefile. If the ZIP provides also a ." -"prj file, you don't have to specify the EPSG SRID" +"Upload a ZIP file containing an ESRI Shapefile. If the ZIP provides also a .prj file, you " +"don't have to specify the EPSG SRID" msgstr "" -"Carica uo file ZIP contenente uno Shapegile ESRI. Se lo ZIP fornisceanche un " -"file .prj non è necessario specificare il Sistema di Riferimento (Codice " -"EPSG)" +"Carica uo file ZIP contenente uno Shapegile ESRI. Se lo ZIP fornisceanche un file .prj non è " +"necessario specificare il Sistema di Riferimento (Codice EPSG)" msgid "Choose" msgstr "Seleziona" @@ -4782,32 +4500,31 @@ msgid "Load SHP-ZIP" msgstr "Carica uno Shapefile .zip" msgid "" -"

This will remove the Geo Limits currently drawn on the map.

In " -"order to store the Geo Limits you will need to save them " -"anyway.

Do you want to proceed?

" +"

This will remove the Geo Limits currently drawn on the map.

In order to store the " +"Geo Limits you will need to save them anyway.

Do you want to proceed?" +"

" msgstr "" -"

Questa azione rimuoverà i limiti spaziali attualmente disegnati " -"sullamappa.

Vuoi procedere?

" +"

Questa azione rimuoverà i limiti spaziali attualmente disegnati sullamappa.

Vuoi " +"procedere?

" msgid "" -"

This will override the current stored Geo Limits on the DB.

To " -"apply them you will need to click on Apply Changes button " -"anyway.

WARNING: This operation cannot be reverted!

Do you want to proceed?

" +"

This will override the current stored Geo Limits on the DB.

To apply them you will " +"need to click on Apply Changes button anyway.

WARNING: This operation cannot be reverted!

Do you want to proceed?

" msgstr "" -"

Questa azione sovrascriverà i limiti spaziali attualmente salvantinel DB." -"

Per procedere clicca su Esegui.

ATTENZIONE: Questa azione è irreversibile!

" +"

Questa azione sovrascriverà i limiti spaziali attualmente salvantinel DB.

Per " +"procedere clicca su Esegui.

ATTENZIONE: Questa " +"azione è irreversibile!

" msgid "Save Geo Limit" msgstr "Salve limiti spaziali" msgid "" -"

Geometry successfully saved!

To apply them you " -"will need to click on Apply Changes button.

" +"

Geometry successfully saved!

To apply them you will need to click " +"on Apply Changes button.

" msgstr "" -"

Geometria salvata con successo!

Per applicarli è " -"necessario fare clic sul pulsante Applica modifiche.

" +"

Geometria salvata con successo!

Per applicarli è necessario fare " +"clic sul pulsante Applica modifiche.

" msgid "

Error while trying to save the Geometry!

" msgstr "

Errore nel salvataggio della geometria!

" @@ -4822,13 +4539,13 @@ msgid "

No Geometry found!

" msgstr "

Nessuna geometria trovata!

" msgid "" -"

This will permanently remove the Geo Limits from the DB." -"

To apply them you will need to click on Apply Changes button.

Do you want to proceed?

" +"

This will permanently remove the Geo Limits from the DB.

To apply " +"them you will need to click on Apply Changes button.

Do you want to " +"proceed?

" msgstr "" -"

In questo modo i limiti geografici verranno rimossi " -"definitivamente dal database.

Per applicarli è necessario fare clic " -"sul pulsante Applica modifiche.

Vuoi procedere?

" +"

In questo modo i limiti geografici verranno rimossi definitivamente dal " +"database.

Per applicarli è necessario fare clic sul pulsante Applica modifiche." +"

Vuoi procedere?

" msgid "Delete Geo Limit" msgstr "Elimina limite geografico" @@ -4836,11 +4553,9 @@ msgstr "Elimina limite geografico" msgid "

Geo Limitsy successfully deleted!

" msgstr "

Geo Limite eliminato con successo!

" -msgid "" -"

Error occurred while trying to delete Geo Limits:

" +msgid "

Error occurred while trying to delete Geo Limits:

" msgstr "" -"

Errore durante il tentativo di eliminazione dei limiti " -"geografici:

" +"

Errore durante il tentativo di eliminazione dei limiti geografici:

" msgid "Choose users..." msgstr "Scegli gli utenti ..." @@ -4858,43 +4573,37 @@ msgid "About GeoNode" msgstr "A proposito di GeoNode" msgid "" -"GeoNode is a geospatial content management system, a platform for the " -"management and publication of geospatial data. It brings together mature and " -"stable open-source software projects under a consistent and easy-to-use " -"interface allowing non-specialized users to share data and create " -"interactive maps." +"GeoNode is a geospatial content management system, a platform for the management and " +"publication of geospatial data. It brings together mature and stable open-source software " +"projects under a consistent and easy-to-use interface allowing non-specialized users to " +"share data and create interactive maps." msgstr "" -"GeoNode è un sistema di gestione dei contenuti geospaziali, una piattaforma " -"per la gestione e la pubblicazione di dati geospaziali. Riunisce progetti " -"software open source maturi e stabili sotto un'interfaccia coerente e facile " -"da usare che consente agli utenti non specializzati di condividere dati e " -"creare mappe interattive." +"GeoNode è un sistema di gestione dei contenuti geospaziali, una piattaforma per la gestione " +"e la pubblicazione di dati geospaziali. Riunisce progetti software open source maturi e " +"stabili sotto un'interfaccia coerente e facile da usare che consente agli utenti non " +"specializzati di condividere dati e creare mappe interattive." msgid "" -"Data management tools built into GeoNode allow for integrated creation of " -"data, metadata, and map visualizations. Each dataset in the system can be " -"shared publicly or restricted to allow access to only specific users. Social " -"features like user profiles and commenting and rating systems allow for the " -"development of communities around each platform to facilitate the use, " -"management, and quality control of the data the GeoNode instance contains." +"Data management tools built into GeoNode allow for integrated creation of data, metadata, " +"and map visualizations. Each dataset in the system can be shared publicly or restricted to " +"allow access to only specific users. Social features like user profiles and commenting and " +"rating systems allow for the development of communities around each platform to facilitate " +"the use, management, and quality control of the data the GeoNode instance contains." msgstr "" -"Gli strumenti di gestione dei dati integrati in GeoNode consentono la " -"creazione integrata di dati, metadati e visualizzazioni mappa. Ogni set di " -"dati nel sistema può essere condiviso pubblicamente o limitato per " -"consentire l'accesso solo a utenti specifici. Le funzionalità di social " -"networking come i profili utente e i sistemi di commenti e classificazione " -"consentono lo sviluppo di comunità intorno a ogni piattaforma per facilitare " -"l'utilizzo, la gestione e il controllo qualità dei dati contenuti " -"nell'istanza GeoNode." +"Gli strumenti di gestione dei dati integrati in GeoNode consentono la creazione integrata di " +"dati, metadati e visualizzazioni mappa. Ogni set di dati nel sistema può essere condiviso " +"pubblicamente o limitato per consentire l'accesso solo a utenti specifici. Le funzionalità " +"di social networking come i profili utente e i sistemi di commenti e classificazione " +"consentono lo sviluppo di comunità intorno a ogni piattaforma per facilitare l'utilizzo, la " +"gestione e il controllo qualità dei dati contenuti nell'istanza GeoNode." msgid "" -"It is also designed to be a flexible platform that software developers can " -"extend, modify or integrate against to meet requirements in their own " -"applications." +"It is also designed to be a flexible platform that software developers can extend, modify or " +"integrate against to meet requirements in their own applications." msgstr "" -"È inoltre progettato per essere una piattaforma flessibile che gli " -"sviluppatori di software possono estendere, modificare o integrare per " -"soddisfare i requisiti nelle proprie applicazioni." +"È inoltre progettato per essere una piattaforma flessibile che gli sviluppatori di software " +"possono estendere, modificare o integrare per soddisfare i requisiti nelle proprie " +"applicazioni." msgid "Account Inactive" msgstr "Account non attivo" @@ -4907,13 +4616,11 @@ msgstr "Account in attesa di approvazione" #, python-format msgid "" -"We have sent the administrators a notice to approve your account associated " -"with %(email)s. If the account is approved, you will receive a " -"confirmation notice." +"We have sent the administrators a notice to approve your account associated with " +"%(email)s. If the account is approved, you will receive a confirmation notice." msgstr "" -"Abbiamo inviato agli amministratori un avviso per approvare il tuo account " -"associato a %(email)s. Se l'account viene approvato, riceverai un " -"avviso di conferma." +"Abbiamo inviato agli amministratori un avviso per approvare il tuo account associato a " +"%(email)s. Se l'account viene approvato, riceverai un avviso di conferma." msgid "Account" msgstr "Account" @@ -4943,12 +4650,12 @@ msgid "Warning:" msgstr "Avviso:" msgid "" -"You currently do not have any e-mail address set up. You should really add " -"an e-mail address so you can receive notifications, reset your password, etc." +"You currently do not have any e-mail address set up. You should really add an e-mail address " +"so you can receive notifications, reset your password, etc." msgstr "" -"Al momento non è stato impostato alcun indirizzo di posta elettronica. Si " -"dovrebbe davvero aggiungere un indirizzo e-mail in modo da poter ricevere " -"notifiche, reimpostare la password, ecc." +"Al momento non è stato impostato alcun indirizzo di posta elettronica. Si dovrebbe davvero " +"aggiungere un indirizzo e-mail in modo da poter ricevere notifiche, reimpostare la password, " +"ecc." msgid "Add E-mail Address" msgstr "Aggiungi indirizzo e-mail" @@ -4963,15 +4670,15 @@ msgstr "Vuoi davvero rimuovere l'indirizzo e-mail selezionato?" msgid "" "Hello from %(site_name)s!\n" "\n" -"You're receiving this e-mail because user %(user_display)s has given yours " -"as an e-mail address to connect their account.\n" +"You're receiving this e-mail because user %(user_display)s has given yours as an e-mail " +"address to connect their account.\n" "\n" "To confirm this is correct, go to %(activate_url)s\n" msgstr "" "Ciao da %(site_name)s!\n" "\n" -"Ricevi questa e-mail perché utente %(user_display)s ha dato la tua come un " -"indirizzo di posta elettronica per collegare il proprio account.\n" +"Ricevi questa e-mail perché utente %(user_display)s ha dato la tua come un indirizzo di " +"posta elettronica per collegare il proprio account.\n" "\n" "Per confermare che questo è corretto, andare al s %(activate_url)s\n" @@ -4995,8 +4702,7 @@ msgstr "Sei stato invitato a registrarti all'indirizzo %(site_name)s." msgid "Create an account on %(site_name)s" msgstr "Creare un account su %(site_name)s" -msgid "" -"This is the email notification to confirm your password has been changed on" +msgid "This is the email notification to confirm your password has been changed on" msgstr "Questa è la notifica e-mail per confermare che la password è stata" msgid "Change password email notification" @@ -5006,17 +4712,17 @@ msgstr "Modifica password" msgid "" "Hello from %(site_name)s!\n" "\n" -"You're receiving this e-mail because you or someone else has requested a " -"password for your user account.\n" -"It can be safely ignored if you did not request a password reset. Click the " -"link below to reset your password." +"You're receiving this e-mail because you or someone else has requested a password for your " +"user account.\n" +"It can be safely ignored if you did not request a password reset. Click the link below to " +"reset your password." msgstr "" "Ciao da %(site_name)s!\n" "\n" -"Ricevi questa e-mail perché tu o qualcun altro ha richiesto una password per " -"l'account utente.\n" -"Può essere tranquillamente ignorato se non hai richiesto la reimpostazione " -"della password. Clicca sul link qui sotto per reimpostare la password." +"Ricevi questa e-mail perché tu o qualcun altro ha richiesto una password per l'account " +"utente.\n" +"Può essere tranquillamente ignorato se non hai richiesto la reimpostazione della password. " +"Clicca sul link qui sotto per reimpostare la password." #, python-format msgid "In case you forgot, your username is %(username)s." @@ -5039,23 +4745,22 @@ msgstr "Confermare Indirizzo e-Mail" #, python-format msgid "" -"Please confirm that %(email)s is an e-mail " -"address for user %(user_display)s." +"Please confirm that %(email)s is an e-mail address for user " +"%(user_display)s." msgstr "" -"Verificare che %(email)s sia un indirizzo " -"di posta elettronica per l'utente %(user_display)s." +"Verificare che %(email)s sia un indirizzo di posta " +"elettronica per l'utente %(user_display)s." msgid "Confirm" msgstr "Conferma" #, python-format msgid "" -"This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request." +"This e-mail confirmation link expired or is invalid. Please issue " +"a new e-mail confirmation request." msgstr "" -"Questo link di conferma e-mail è scaduto o non è valido. Si prega di " -"emettere una nuova richiesta di conferma e-mail." +"Questo link di conferma e-mail è scaduto o non è valido. Si prega di emettere una nuova " +"richiesta di conferma e-mail." msgid "Log in" msgstr "Entra" @@ -5066,13 +4771,12 @@ msgstr "Effettua il login con un account esistente" #, python-format msgid "" "Please sign in with one\n" -" of your existing third party accounts. Or, sign up\n" +" of your existing third party accounts. Or, sign up\n" " for a %(site_name)s account and sign in below:" msgstr "" "Si prega di accedere con uno\n" -" degli account di terze parti esistenti. In alternativa, iscriviti\n" +" degli account di terze parti esistenti. In alternativa, iscriviti\n" " per un account %(site_name)s e accedere di seguito:" msgid "or" @@ -5116,32 +4820,27 @@ msgstr "Reimposta password" msgid "Forgotten your password?" msgstr "Hai dimenticato la password?" -msgid "" -"Enter your email address below, and we'll send you an email allowing you to " -"reset it." +msgid "Enter your email address below, and we'll send you an email allowing you to reset it." msgstr "" -"Inserisci sotto il tuo indirizzo email e ti spediremo un'email per " -"permetterti di cambiare la password." +"Inserisci sotto il tuo indirizzo email e ti spediremo un'email per permetterti di cambiare " +"la password." msgid "Reset my password" msgstr "Reimposta la mia password" #, python-format msgid "" -"If you have any trouble resetting your password, contact us at %(THEME_ACCOUNT_CONTACT_EMAIL)s." +"If you have any trouble resetting your password, contact us at %(THEME_ACCOUNT_CONTACT_EMAIL)s." msgstr "" -"Se hai dei problemi nel reimpostare la password, contattaci a %(THEME_ACCOUNT_CONTACT_EMAIL)s." +"Se hai dei problemi nel reimpostare la password, contattaci a %(THEME_ACCOUNT_CONTACT_EMAIL)s." msgid "" -"We have sent you an e-mail. Please contact us if you do not receive it " -"within a few minutes." +"We have sent you an e-mail. Please contact us if you do not receive it within a few minutes." msgstr "" -"Vi abbiamo inviato un'e-mail. Vi preghiamo di contattarci se non lo ricevete " -"entro pochi minuti." +"Vi abbiamo inviato un'e-mail. Vi preghiamo di contattarci se non lo ricevete entro pochi " +"minuti." msgid "Change Password" msgstr "Cambia Password" @@ -5151,13 +4850,12 @@ msgstr "Token non valido" #, python-format msgid "" -"The password reset link was invalid, possibly because it has already been " -"used. Please request a new password reset." +"The password reset link was invalid, possibly because it has already been used. Please " +"request a new password reset." msgstr "" -"Il collegamento per la reimpostazione della password non è valido, " -"probabilmente perché è già stato utilizzato. Si prega di richiedere una nuova reimpostazione della password." +"Il collegamento per la reimpostazione della password non è valido, probabilmente perché è " +"già stato utilizzato. Si prega di richiedere una nuova " +"reimpostazione della password." msgid "change password" msgstr "modifica password" @@ -5208,13 +4906,11 @@ msgstr "Verificare l'indirizzo di posta elettronica" msgid "" "We have sent an email to you for verification. Follow the\n" -" link provided to finalize the signup process. Please contact us if you " -"do\n" +" link provided to finalize the signup process. Please contact us if you do\n" " not receive it within a few minutes" msgstr "" "We have sent an email to you for verification. Follow the\n" -" link provided to finalize the signup process. Please contact us if you " -"do\n" +" link provided to finalize the signup process. Please contact us if you do\n" " not receive it within a few minutes" msgid "Verify Your E-mail Address" @@ -5235,17 +4931,16 @@ msgid "" "contact us if you do not receive it within a few minutes." msgstr "" "Vi abbiamo inviato un'e-mail per\n" -"verifica. Si prega di fare clic sul link all'interno di questa e-mail. per " -"favore\n" +"verifica. Si prega di fare clic sul link all'interno di questa e-mail. per favore\n" "contattarci se non lo ricevi entro pochi minuti." #, python-format msgid "" -"Note: you can still change your e-" -"mail address." +"Note: you can still change your e-mail address." msgstr "" -"Nota: è comunque possibile modificare l'indirizzo e-mail." +"Nota: è comunque possibile modificare l'indirizzo " +"e-mail." msgid "My Activity feed" msgstr "Il mio feed attività" @@ -5292,13 +4987,11 @@ msgstr "Pubblicato da" #, python-format msgid "" "\n" -" Published from %(publish_start)s to %(publish_end)s.\n" +" Published from %(publish_start)s to %(publish_end)s.\n" " " msgstr "" "\n" -" Pubblicato da %(publish_start)s per %(publish_end)s.\n" +" Pubblicato da %(publish_start)s per %(publish_end)s.\n" " " #, python-format @@ -5328,14 +5021,13 @@ msgstr "Si prega di selezionare gli avatar che si desidera eliminare." #, python-format msgid "" -"You have no avatars to delete. Please upload one now." +"You have no avatars to delete. Please upload one now." msgstr "" -"Non ci sono gli avatar da eliminare. Si prega di caricare un ora." +"Non ci sono gli avatar da eliminare. Si prega di caricare " +"un ora." msgid "Delete These" -msgstr "Cancella questi" +msgstr "Elimina questi" msgid "GeoNode Search" msgstr "Ricerca GeoNode" @@ -5361,10 +5053,8 @@ msgstr "Aggiornamento Miniatura..." msgid "Message. Do you want to proceed?" msgstr "Sei sicuro di voler procedere?" -#, fuzzy -#| msgid "Harvesting resources..." msgid "Processing Resource..." -msgstr "Inserimento risorse..." +msgstr "Processamento risorse..." msgid "Information for Developers" msgstr "Informazioni per gli sviluppatori" @@ -5375,160 +5065,141 @@ msgstr "Informazioni utili per gli sviluppatori interessati a GeoNode." #, fuzzy, python-format #| msgid "" #| "\n" -#| "

GeoNode is an open service " -#| "built on open source software. We encourage you to build new applications " -#| "using the components and resources it provides. This page is a starting " -#| "point for developers interesting in taking full advantage of GeoNode. It " -#| "also includes links to the project's source code so anyone can build and " -#| "customize their own GeoNode.

\n" +#| "

GeoNode is an open service built on open " +#| "source software. We encourage you to build new applications using the components and " +#| "resources it provides. This page is a starting point for developers interesting in taking " +#| "full advantage of GeoNode. It also includes links to the project's source code so anyone " +#| "can build and customize their own GeoNode.

\n" #| "\n" #| "

GeoNode Software

\n" #| "\n" -#| "

All the code that runs GeoNode is open source. The code is " -#| "available at http://github." -#| "com/GeoNode/geonode/. The issue tracker for the project is at http://github.com/GeoNode/" -#| "geonode/issues.

\n" +#| "

All the code that runs GeoNode is open source. The code is available at http://github.com/GeoNode/geonode/. The " +#| "issue tracker for the project is at http://github.com/GeoNode/geonode/issues.

\n" #| "\n" -#| "

GeoNode is built using several open source projects, each with its " -#| "own community. If you are interested in contributing new features to the " -#| "GeoNode, we encourage you to do so by contributing to one of the projects " -#| "on which it is built:

\n" +#| "

GeoNode is built using several open source projects, each with its own community. " +#| "If you are interested in contributing new features to the GeoNode, we encourage you to do " +#| "so by contributing to one of the projects on which it is built:

\n" #| "
    \n" -#| "
  • GeoServer - Standards " -#| "based server for geospatial information
  • \n" -#| "
  • GeoWebCache - Cache " -#| "engine for WMS Tiles
  • OpenLayers - Pure JavaScript library powering the maps of GeoExt\n" -#| "
  • pycsw - CSW, OpenSearch and " -#| "OAI-PMH metadata catalogue server
  • \n" +#| "
  • GeoServer - Standards based server for " +#| "geospatial information
  • \n" +#| "
  • GeoWebCache - Cache engine for WMS " +#| "Tiles
  • OpenLayers - Pure JavaScript library " +#| "powering the maps of GeoExt
  • \n" +#| "
  • pycsw - CSW, OpenSearch and OAI-PMH metadata " +#| "catalogue server
  • \n" #| "
\n" #| "\n" #| "

What are OGC Services?

\n" -#| "

The data in this application is served using open standards " -#| "endorsed by ISO and the Open " -#| "Geospatial Consortium; in particular, WMS (Web Map Service) is used " -#| "for accessing maps, WFS (Web Feature Service) is used for accessing " -#| "vector data, and WCS (Web Coverage Service) is used for accessing raster " -#| "data. WMC (Web Map Context Documents) is used for sharing maps. You can " -#| "use these services in your own applications using libraries such as " -#| "OpenLayers, GeoTools, and OGR (all of which are open-source software and " -#| "available at zero cost). Additionally, CSW (Catalog Service for the Web) " -#| "supports access to collections of descriptive information (metadata) " -#| "about data and services.

\n" +#| "

The data in this application is served using open standards endorsed by ISO and " +#| "the Open Geospatial Consortium; in particular, " +#| "WMS (Web Map Service) is used for accessing maps, WFS (Web Feature Service) is used for " +#| "accessing vector data, and WCS (Web Coverage Service) is used for accessing raster data. " +#| "WMC (Web Map Context Documents) is used for sharing maps. You can use these services in " +#| "your own applications using libraries such as OpenLayers, GeoTools, and OGR (all of which " +#| "are open-source software and available at zero cost). Additionally, CSW (Catalog Service " +#| "for the Web) supports access to collections of descriptive information (metadata) about " +#| "data and services.

\n" #| "\n" #| "

What is GeoWebCache?

\n" -#| "

GeoWebCache provides mapping tiles that are compatible with a " -#| "number of mapping engines, including Google Maps, Bing Maps and " -#| "OpenLayers. All the data hosted by GeoNode is also available through " -#| "GeoWebCache. GeoWebCache improves on WMS by caching data and providing " -#| "more responsive maps.

\n" +#| "

GeoWebCache provides mapping tiles that are compatible with a number of mapping " +#| "engines, including Google Maps, Bing Maps and OpenLayers. All the data hosted by GeoNode " +#| "is also available through GeoWebCache. GeoWebCache improves on WMS by caching data and " +#| "providing more responsive maps.

\n" #| "\n" #| "

CSW Example Code

\n" -#| "

To interact with GeoNode's CSW you can use any CSW client (QGIS " -#| "MetaSearch, GRASS, etc.). The following example illustrates a simple " -#| "invocation using the OWSLib Python package:

\n" +#| "

To interact with GeoNode's CSW you can use any CSW client (QGIS MetaSearch, GRASS, " +#| "etc.). The following example illustrates a simple invocation using the OWSLib Python " +#| "package:

\n" #| "

from owslib.csw import CatalogueServiceWeb

\n" #| "

from owslib.fes import PropertyIsLike

\n" -#| "

csw = CatalogueServiceWeb('%(CATALOGUE_BASE_URL)s')\n" -#| "

anytext = PropertyIsLike('csw:AnyText', 'birds')')\n" +#| "

csw = CatalogueServiceWeb('%(CATALOGUE_BASE_URL)s')

\n" +#| "

anytext = PropertyIsLike('csw:AnyText', 'birds')')

\n" #| "

csw.getrecords2(constraints=[anytext])

\n" #| "

print csw.results

\n" #| "

print csw.records

\n" #| "\n" #| "

OpenLayers Example Code

\n" #| "\n" -#| "

To include a GeoNode map layer in an OpenLayers map, first find " -#| "the name for that layer. This is found in the layer's name " -#| "field (not title) of the layer list. For this example, we " -#| "will use the Nicaraguan political boundaries background layer, whose name " -#| "is risk:nicaragua_admin. Then, create an instance of " -#| "OpenLayers.Layer.WMS:

\n" -#| "

var geonodeLayer = new OpenLayers.Layer.WMS(\"GeoNode Risk " -#| "Data\", \"http://demo.geonode.org/geoserver/wms\",{ layers: \"risk:" -#| "nicaragua_admin\" });

\n" +#| "

To include a GeoNode map layer in an OpenLayers map, first find the name for that " +#| "layer. This is found in the layer's name field (not title) of " +#| "the layer list. For this example, we will use the Nicaraguan political boundaries " +#| "background layer, whose name is risk:nicaragua_admin. Then, create an " +#| "instance of OpenLayers.Layer.WMS:

\n" +#| "

var geonodeLayer = new OpenLayers.Layer.WMS(\"GeoNode Risk Data\", \"http://" +#| "demo.geonode.org/geoserver/wms\",{ layers: \"risk:nicaragua_admin\" });

\n" #| "\n" #| "

Google Maps Example Code

\n" -#| "

To include a GeoNode map layer in a Google Map, include the layer " -#| "namein the URL template.

\n" -#| "

var tilelayer = new GTileLayer(null, null, null, " -#| "{tileUrlTemplate: 'http://demo.geonode.org/geoserver/gwc/service/gmaps?" -#| "layers=risk:nicaragua_admin&zoom={Z}&x={X}&y={Y}', isPng:" -#| "true, opacity:0.5 } );

\n" +#| "

To include a GeoNode map layer in a Google Map, include the layer namein the URL " +#| "template.

\n" +#| "

var tilelayer = new GTileLayer(null, null, null, {tileUrlTemplate: 'http://" +#| "demo.geonode.org/geoserver/gwc/service/gmaps?layers=risk:nicaragua_admin&zoom={Z}&" +#| "x={X}&y={Y}', isPng:true, opacity:0.5 } );

\n" #| "\n" #| "

Shapefile/GeoJSON/GML Output

\n" -#| "

To get data from the GeoNode web services use the WFS protocol. " -#| "For example, to get the full Nicaraguan admin boundaries use:

\n" -#| "

http://demo.geonode.org/geoserver/wfs?request=GetFeature&" -#| "typeName=risk:nicaragua_admin&outputformat=SHAPE-ZIP

\n" -#| "

Changing output format to json, GML2, " -#| "GML3, or csv will get data in those formats. " -#| "The WFS protocol also can handle more precise queries, specifying a " -#| "bounding box or various spatial and non-spatial filters based on the " -#| "attributes of the data.

\n" +#| "

To get data from the GeoNode web services use the WFS protocol. For example, to " +#| "get the full Nicaraguan admin boundaries use:

\n" +#| "

http://demo.geonode.org/geoserver/wfs?request=GetFeature&typeName=risk:" +#| "nicaragua_admin&outputformat=SHAPE-ZIP

\n" +#| "

Changing output format to json, GML2, GML3, " +#| "or csv will get data in those formats. The WFS protocol also can handle more " +#| "precise queries, specifying a bounding box or various spatial and non-spatial filters " +#| "based on the attributes of the data.

\n" #| "\n" #| "

GeoTools Example Code

\n" -#| "

Create a DataStore and extract a FeatureType from it, then run a " -#| "Query. It is all documented on the wiki at http://geotools.org/.

\n" +#| "

Create a DataStore and extract a FeatureType from it, then run a Query. It is all " +#| "documented on the wiki at http://geotools.org/.

\n" #| " " msgid "" "\n" -"

GeoNode is an open service built " -"on open source software. We encourage you to build new applications using " -"the components and resources it provides. This page is a starting point for " -"developers interesting in taking full advantage of GeoNode. It also includes " -"links to the project's source code so anyone can build and customize their " -"own GeoNode.

\n" +"

GeoNode is an open service built on open source " +"software. We encourage you to build new applications using the components and resources it " +"provides. This page is a starting point for developers interesting in taking full advantage " +"of GeoNode. It also includes links to the project's source code so anyone can build and " +"customize their own GeoNode.

\n" "\n" "

GeoNode Software

\n" "\n" -"

All the code that runs GeoNode is open source. The code is available " -"at http://github.com/GeoNode/" -"geonode/. The issue tracker for the project is at http://github.com/GeoNode/geonode/" -"issues.

\n" +"

All the code that runs GeoNode is open source. The code is available at http://github.com/GeoNode/geonode/. The issue " +"tracker for the project is at http://" +"github.com/GeoNode/geonode/issues.

\n" "\n" -"

GeoNode is built using several open source projects, each with its " -"own community. If you are interested in contributing new features to the " -"GeoNode, we encourage you to do so by contributing to one of the projects on " -"which it is built:

\n" +"

GeoNode is built using several open source projects, each with its own community. If " +"you are interested in contributing new features to the GeoNode, we encourage you to do so by " +"contributing to one of the projects on which it is built:

\n" "
    \n" -"
  • GeoServer - Standards based " -"server for geospatial information
  • \n" -"
  • GeoWebCache - Cache engine " -"for WMS Tiles
  • OpenLayers - " -"Pure JavaScript library powering the maps of GeoExt
  • \n" -"
  • pycsw - CSW, OpenSearch and OAI-" -"PMH metadata catalogue server
  • \n" +"
  • GeoServer - Standards based server for " +"geospatial information
  • \n" +"
  • GeoWebCache - Cache engine for WMS Tiles
  • OpenLayers - Pure JavaScript library powering " +"the maps of GeoExt
  • \n" +"
  • pycsw - CSW, OpenSearch and OAI-PMH metadata " +"catalogue server
  • \n" "
\n" "\n" "

What are OGC Services?

\n" -"

The data in this application is served using open standards endorsed " -"by ISO and the Open Geospatial " -"Consortium; in particular, WMS (Web Map Service) is used for accessing " -"maps, WFS (Web Feature Service) is used for accessing vector data, and WCS " -"(Web Coverage Service) is used for accessing raster data. WMC (Web Map " -"Context Documents) is used for sharing maps. You can use these services in " -"your own applications using libraries such as OpenLayers, GeoTools, and OGR " -"(all of which are open-source software and available at zero cost). " -"Additionally, CSW (Catalog Service for the Web) supports access to " -"collections of descriptive information (metadata) about data and services.\n" +"

The data in this application is served using open standards endorsed by ISO and the " +"Open Geospatial Consortium; in particular, WMS " +"(Web Map Service) is used for accessing maps, WFS (Web Feature Service) is used for " +"accessing vector data, and WCS (Web Coverage Service) is used for accessing raster data. " +"WMC (Web Map Context Documents) is used for sharing maps. You can use these services in your " +"own applications using libraries such as OpenLayers, GeoTools, and OGR (all of which are " +"open-source software and available at zero cost). Additionally, CSW (Catalog Service for the " +"Web) supports access to collections of descriptive information (metadata) about data and " +"services.

\n" "\n" "

What is GeoWebCache?

\n" -"

GeoWebCache provides mapping tiles that are compatible with a number " -"of mapping engines, including Google Maps, Bing Maps and OpenLayers. All the " -"data hosted by GeoNode is also available through GeoWebCache. GeoWebCache " -"improves on WMS by caching data and providing more responsive maps.

\n" +"

GeoWebCache provides mapping tiles that are compatible with a number of mapping " +"engines, including Google Maps, Bing Maps and OpenLayers. All the data hosted by GeoNode is " +"also available through GeoWebCache. GeoWebCache improves on WMS by caching data and " +"providing more responsive maps.

\n" "\n" "

CSW Example Code

\n" -"

To interact with GeoNode's CSW you can use any CSW client (QGIS " -"MetaSearch, GRASS, etc.). The following example illustrates a simple " -"invocation using the OWSLib Python package:

\n" +"

To interact with GeoNode's CSW you can use any CSW client (QGIS MetaSearch, GRASS, " +"etc.). The following example illustrates a simple invocation using the OWSLib Python " +"package:

\n" "

from owslib.csw import CatalogueServiceWeb

\n" "

from owslib.fes import PropertyIsLike

\n" "

csw = CatalogueServiceWeb('%(CATALOGUE_BASE_URL)s')

\n" @@ -5539,95 +5210,84 @@ msgid "" "\n" "

OpenLayers Example Code

\n" "\n" -"

To include a GeoNode map layer in an OpenLayers map, first find the " -"name for that layer. This is found in the layer's name field " -"(not title) of the layer list. For this example, we will use " -"the Nicaraguan political boundaries background layer, whose name is " -"risk:nicaragua_admin. Then, create an instance of OpenLayers." -"Layer.WMS:

\n" -"

var geonodeLayer = new OpenLayers.Layer.WMS(\"GeoNode Risk Data" -"\", \"http://demo.geonode.org/geoserver/wms\",{ layers: \"risk:" -"nicaragua_admin\" });

\n" +"

To include a GeoNode map layer in an OpenLayers map, first find the name for that " +"layer. This is found in the layer's name field (not title) of the " +"layer list. For this example, we will use the Nicaraguan political boundaries background " +"layer, whose name is risk:nicaragua_admin. Then, create an instance of " +"OpenLayers.Layer.WMS:

\n" +"

var geonodeLayer = new OpenLayers.Layer.WMS(\"GeoNode Risk Data\", \"http://" +"demo.geonode.org/geoserver/wms\",{ layers: \"risk:nicaragua_admin\" });

\n" "\n" "

Google Maps Example Code

\n" -"

To include a GeoNode map layer in a Google Map, include the dataset " -"name in the URL template.

\n" -"

var tilelayer = new GTileLayer(null, null, null, " -"{tileUrlTemplate: 'http://demo.geonode.org/geoserver/gwc/service/gmaps?" -"layers=risk:nicaragua_admin&zoom={Z}&x={X}&y={Y}', isPng:true, " -"opacity:0.5 } );

\n" +"

To include a GeoNode map layer in a Google Map, include the dataset name in the URL " +"template.

\n" +"

var tilelayer = new GTileLayer(null, null, null, {tileUrlTemplate: 'http://" +"demo.geonode.org/geoserver/gwc/service/gmaps?layers=risk:nicaragua_admin&zoom={Z}&" +"x={X}&y={Y}', isPng:true, opacity:0.5 } );

\n" "\n" "

Shapefile/GeoJSON/GML Output

\n" -"

To get data from the GeoNode web services use the WFS protocol. For " -"example, to get the full Nicaraguan admin boundaries use:

\n" -"

http://demo.geonode.org/geoserver/wfs?request=GetFeature&" -"typeName=risk:nicaragua_admin&outputformat=SHAPE-ZIP

\n" -"

Changing output format to json, GML2, " -"GML3, or csv will get data in those formats. The " -"WFS protocol also can handle more precise queries, specifying a bounding box " -"or various spatial and non-spatial filters based on the attributes of the " -"data.

\n" +"

To get data from the GeoNode web services use the WFS protocol. For example, to get " +"the full Nicaraguan admin boundaries use:

\n" +"

http://demo.geonode.org/geoserver/wfs?request=GetFeature&typeName=risk:" +"nicaragua_admin&outputformat=SHAPE-ZIP

\n" +"

Changing output format to json, GML2, GML3, or " +"csv will get data in those formats. The WFS protocol also can handle more " +"precise queries, specifying a bounding box or various spatial and non-spatial filters based " +"on the attributes of the data.

\n" "\n" "

GeoTools Example Code

\n" -"

Create a DataStore and extract a FeatureType from it, then run a " -"Query. It is all documented on the wiki at http://geotools.org/." -"

\n" +"

Create a DataStore and extract a FeatureType from it, then run a Query. It is all " +"documented on the wiki at http://geotools.org/.

\n" " " msgstr "" "\n" -"

GeoNode è un servizio aperto " -"costruito su software open source. Vi incoraggiamo a costruire nuove " -"applicazioni utilizzando i componenti e le risorse che fornisce. Questa " -"pagina è un punto di partenza per gli sviluppatori interessati a trarre il " -"massimo vantaggio da GeoNode. Include anche link al codice sorgente del " -"progetto in modo che chiunque possa costruire e personalizzare il proprio " -"GeoNode.

\n" +"

GeoNode è un servizio aperto costruito su " +"software open source. Vi incoraggiamo a costruire nuove applicazioni utilizzando i " +"componenti e le risorse che fornisce. Questa pagina è un punto di partenza per gli " +"sviluppatori interessati a trarre il massimo vantaggio da GeoNode. Include anche link al " +"codice sorgente del progetto in modo che chiunque possa costruire e personalizzare il " +"proprio GeoNode.

\n" "\n" "

GeoNode Software

\n" "\n" -"

Tutto il codice che esegue GeoNode è open source. Il codice è " -"disponibile all'indirizzo http://github.com/GeoNode/geonode/. Il issue tracker per il progetto " -"è disponibile all'indirizzo http://github.com/GeoNode/geonode/issues.

\n" -"

GeoNode è costruito utilizzando diversi progetti open source, ognuno " -"con la propria comunità. Se siete interessati a contribuire con nuove " -"funzionalità al GeoNode, vi invitiamo a farlo contribuendo ad uno dei " -"progetti su cui è costruito:

\n" +"

Tutto il codice che esegue GeoNode è open source. Il codice è disponibile " +"all'indirizzo http://github.com/GeoNode/" +"geonode/. Il issue tracker per il progetto è disponibile all'indirizzo http://github.com/GeoNode/geonode/issues.

\n" +"

GeoNode è costruito utilizzando diversi progetti open source, ognuno con la propria " +"comunità. Se siete interessati a contribuire con nuove funzionalità al GeoNode, vi invitiamo " +"a farlo contribuendo ad uno dei progetti su cui è costruito:

\n" "
    \n" -"
  • GeoServer - Server basato su " -"standard per informazioni geospaziali
  • \n" -"
  • GeoWebCache - Motore di " -"cache per WMS Tiles
  • OpenLayers " -"- Pura libreria JavaScript che alimenta le mappe di GeoExt
  • \n" -"
  • pycsw - CSW, OpenSearch e OAI-PMH " -"metadata catalogue server
  • \n" +"
  • GeoServer - Server basato su standard per " +"informazioni geospaziali
  • \n" +"
  • GeoWebCache - Motore di cache per WMS " +"Tiles
  • OpenLayers - Pura libreria JavaScript " +"che alimenta le mappe di GeoExt
  • \n" +"
  • pycsw - CSW, OpenSearch e OAI-PMH metadata " +"catalogue server
  • \n" "
\n" "\n" "

Che cosa sono i servizi OGC?

\n" -"

I dati in questa applicazione sono serviti utilizzando standard " -"aperti approvati dall'ISO e dal Open " -"Geospatial Consortium; in particolare, il WMS (Web Map Service) è " -"utilizzato per l'accesso alle mappe, il WFS (Web Feature Service) è " -"utilizzato per l'accesso ai dati vettoriali, e il WCS (Web Coverage Service) " -"è utilizzato per l'accesso ai dati raster. Il WMC (Web Map Context " -"Documents) viene utilizzato per la condivisione delle mappe. Questi servizi " -"possono essere utilizzati nelle proprie applicazioni utilizzando librerie " -"come OpenLayers, GeoTools e OGR (tutti software open-source e disponibili a " -"costo zero). Inoltre, CSW (Catalog Service for the Web) supporta l'accesso a " -"raccolte di informazioni descrittive (metadati) su dati e servizi.

\n" +"

I dati in questa applicazione sono serviti utilizzando standard aperti approvati " +"dall'ISO e dal Open Geospatial Consortium; in " +"particolare, il WMS (Web Map Service) è utilizzato per l'accesso alle mappe, il WFS (Web " +"Feature Service) è utilizzato per l'accesso ai dati vettoriali, e il WCS (Web Coverage " +"Service) è utilizzato per l'accesso ai dati raster. Il WMC (Web Map Context Documents) " +"viene utilizzato per la condivisione delle mappe. Questi servizi possono essere utilizzati " +"nelle proprie applicazioni utilizzando librerie come OpenLayers, GeoTools e OGR (tutti " +"software open-source e disponibili a costo zero). Inoltre, CSW (Catalog Service for the Web) " +"supporta l'accesso a raccolte di informazioni descrittive (metadati) su dati e servizi.

\n" "\n" "

Che cos'è GeoWebCache?

\n" -"

GeoWebCache fornisce tiles di mappatura compatibili con una serie di " -"motori di mappatura, tra cui Google Maps, Bing Maps e OpenLayers. Tutti i " -"dati ospitati da GeoNode sono disponibili anche attraverso GeoWebCache. " -"GeoWebCache migliora il WMS mettendo in cache i dati e fornendo mappe più " -"reattive.

\n" +"

GeoWebCache fornisce tiles di mappatura compatibili con una serie di motori di " +"mappatura, tra cui Google Maps, Bing Maps e OpenLayers. Tutti i dati ospitati da GeoNode " +"sono disponibili anche attraverso GeoWebCache. GeoWebCache migliora il WMS mettendo in cache " +"i dati e fornendo mappe più reattive.

\n" "\n" "

CSW Codice d'esempio

\n" -"

Per interagire con il CSW di GeoNode è possibile utilizzare qualsiasi " -"client CSW (QGIS MetaSearch, GRASS, ecc.). Il seguente esempio illustra una " -"semplice invocazione utilizzando il pacchetto Python di OWSLib:

\n" +"

Per interagire con il CSW di GeoNode è possibile utilizzare qualsiasi client CSW " +"(QGIS MetaSearch, GRASS, ecc.). Il seguente esempio illustra una semplice invocazione " +"utilizzando il pacchetto Python di OWSLib:

\n" "

from owslib.csw import CatalogueServiceWeb

\n" "

from owslib.fes import PropertyIsLike

\n" "

csw = CatalogueServiceWeb('%(CATALOGUE_BASE_URL)s')

\n" @@ -5638,39 +5298,33 @@ msgstr "" "\n" "

Codice d'esempio OpenLayers

\n" "\n" -"

Per includere un livello di mappa GeoNode in una mappa OpenLayers, " -"trovare prima il nome di quel livello. Questo si trova nel campo nome del livello (non titolo) della lista dei livelli. Per " -"questo esempio, useremo il livello di sfondo dei confini politici del " -"Nicaragua, il cui nome è risk:nicaragua_admin. Quindi, creare " -"un'istanza di OpenLayers.Layer.WMS:

\n" -"

var geonodeLayer = new OpenLayers.Layer.WMS(\"GeoNode Risk Data" -"\", \"http://demo.geonode.org/geoserver/wms\",{ strati: \"risk:" -"nicaragua_admin\" });

\n" +"

Per includere un livello di mappa GeoNode in una mappa OpenLayers, trovare prima il " +"nome di quel livello. Questo si trova nel campo nome del livello (non " +"titolo) della lista dei livelli. Per questo esempio, useremo il livello di " +"sfondo dei confini politici del Nicaragua, il cui nome è risk:nicaragua_admin. " +"Quindi, creare un'istanza di OpenLayers.Layer.WMS:

\n" +"

var geonodeLayer = new OpenLayers.Layer.WMS(\"GeoNode Risk Data\", \"http://" +"demo.geonode.org/geoserver/wms\",{ strati: \"risk:nicaragua_admin\" });

\n" "\n" "

Codice di esempio di Google Maps

\n" -"

Per includere un livello di mappa GeoNode in una Google Map, " -"includere il nome del livello nel modello URL.

\n" -"

var tilelayer = new GTileLayer(null, null, null, null, " -"{tileUrlTemplate: 'http://demo.geonode.org/geoserver/gwc/service/gmaps?" -"layers=risk:nicaragua_admin&zoom={Z}&x={X}&y={Y}', isPng:true, " -"opacity:0.5 }', isPng:true, opacity:0.5 }. );

\n" +"

Per includere un livello di mappa GeoNode in una Google Map, includere il nome del " +"livello nel modello URL.

\n" +"

var tilelayer = new GTileLayer(null, null, null, null, {tileUrlTemplate: " +"'http://demo.geonode.org/geoserver/gwc/service/gmaps?layers=risk:nicaragua_admin&zoom={Z}" +"&x={X}&y={Y}', isPng:true, opacity:0.5 }', isPng:true, opacity:0.5 }. );

\n" "\n" "

Formafile/GeoJSON/GML Output

\n" -"

Per ottenere dati dai servizi web GeoNode utilizzare il protocollo " -"WFS. Per esempio, per ottenere i confini amministrativi completi del " -"Nicaragua usare:

\n" -"

http://demo.geonode.org/geoserver/wfs?request=GetFeature&" -"typeName=rischio:nicaragua_admin&outputformat=SHAPE-ZIP

\n" -"

Cambiando il formato di uscita in json, GML2, GML3, o csv si ottengono dati in questi " -"formati. Il protocollo WFS può anche gestire query più precise, specificando " -"un bounding box o vari filtri spaziali e non spaziali in base agli attributi " -"dei dati.

\n" +"

Per ottenere dati dai servizi web GeoNode utilizzare il protocollo WFS. Per esempio, " +"per ottenere i confini amministrativi completi del Nicaragua usare:

\n" +"

http://demo.geonode.org/geoserver/wfs?request=GetFeature&typeName=rischio:" +"nicaragua_admin&outputformat=SHAPE-ZIP

\n" +"

Cambiando il formato di uscita in json, GML2, GML3, o csv si ottengono dati in questi formati. Il protocollo WFS può anche " +"gestire query più precise, specificando un bounding box o vari filtri spaziali e non " +"spaziali in base agli attributi dei dati.

\n" "

Codice d'esempio di GeoTools

\n" -"

Creare un DataStore ed estrarre un FeatureType da esso, quindi " -"eseguire una query. È tutto documentato sul wiki all'indirizzo http://" -"geotools.org/.

\n" +"

Creare un DataStore ed estrarre un FeatureType da esso, quindi eseguire una query. È " +"tutto documentato sul wiki all'indirizzo http://geotools.org/.

\n" " " msgid "GeoNode's Web Services" @@ -5707,86 +5361,79 @@ msgid "GeoNode Help" msgstr "Aiuto su GeoNode" msgid "" -"This page provides helpful information about how to use the GeoNode. You can " -"use the sidebar links to navigate to what you want to know." +"This page provides helpful information about how to use the GeoNode. You can use the sidebar " +"links to navigate to what you want to know." msgstr "" -"Questa pagina fornisce informazioni di aiuto sull'utilizzo di GeoNode. Puoi " -"utilizzare i collegamenti nella barra laterale per ricercare quello che ti " -"interessa." +"Questa pagina fornisce informazioni di aiuto sull'utilizzo di GeoNode. Puoi utilizzare i " +"collegamenti nella barra laterale per ricercare quello che ti interessa." msgid "" "\n" -"

The GeoNode provides access to data sets and a map editing " -"application allows users to browse existing maps and contribute their own.\n" +"

The GeoNode provides access to data sets and a map editing application allows users " +"to browse existing maps and contribute their own.

\n" "\n" "

Browsing Layers

\n" "

The Layers tab allows you to browse data " "uploaded to this GeoNode.

\n" -"

All data can be downloaded in a variety of formats, for use in other " -"applications.

\n" +"

All data can be downloaded in a variety of formats, for use in other applications.\n" "\n" "

Developer Access

\n" -"

The Developer page is the place for " -"developers to get started building applications against the GeoNode. It " -"includes instructions on using the web services, links to the source code of " -"the GeoNode, and information about the open source projects used to create " -"it.

\n" +"

The Developer page is the place for developers " +"to get started building applications against the GeoNode. It includes instructions on using " +"the web services, links to the source code of the GeoNode, and information about the open " +"source projects used to create it.

\n" "\n" "

Browsing Maps

\n" -"

The GeoNode allows users to create and share maps with one another.\n" +"

The GeoNode allows users to create and share maps with one another.

\n" "

The Maps tab is a gateway to map exploration on " -"GeoNode. From here you can search for a map or " -"create a map, which will open the Map " -"Composer.

\n" +"GeoNode. From here you can search for a map or create a map, which will open the Map Composer.

\n" "
Google Earth Mode
\n" -"

Any map viewed in the interactive map editor can be seen in 3D mode " -"with the Google Earth plugin. To switch to 3D mode select the Google Earth " -"globe logo, the rightmost button on top toolbar. If you do not have the " -"Google Earth plugin installed you will be prompted to install it.

\n" +"

Any map viewed in the interactive map editor can be seen in 3D mode with the Google " +"Earth plugin. To switch to 3D mode select the Google Earth globe logo, the rightmost button " +"on top toolbar. If you do not have the Google Earth plugin installed you will be prompted to " +"install it.

\n" "\n" "

Creating a Map

\n" "

To create a new map go to the Contributed Maps " "tab and click the create your own map link.

\n" -"

This will take you to the Map Composer with " -"a base layer loaded.

\n" -"

To add data layers from the GeoNode click on the green plus button " -"located below the layers tab on the left hand side of the screen. This will " -"open a dialog listing all the layers available on the GeoNode.

\n" -"

To add layers to your map select them and hit the Add Layers button. When finished you may hit Done to close the " -"dialog and go back to the map.

\n" +"

This will take you to the Map Composer with a base " +"layer loaded.

\n" +"

To add data layers from the GeoNode click on the green plus button located below the " +"layers tab on the left hand side of the screen. This will open a dialog listing all the " +"layers available on the GeoNode.

\n" +"

To add layers to your map select them and hit the Add Layers button. " +"When finished you may hit Done to close the dialog and go back to the map.\n" "\n" "

Reordering and Removing Layers
\n" -"

Change the display order of the layers listed in the data tab by " -"simply dragging and dropping their names. The order in the map will be " -"updated to reflect that. To turn a layer's visibility off simply uncheck it, " -"and to remove it entirely select it and hit the red minus button.

\n" +"

Change the display order of the layers listed in the data tab by simply dragging and " +"dropping their names. The order in the map will be updated to reflect that. To turn a " +"layer's visibility off simply uncheck it, and to remove it entirely select it and hit the " +"red minus button.

\n" "\n" "
Saving your map
\n" -"

Once a suitable set of layers and zoom level has been found it's time " -"to save it so others can see it. Click the Save button--the left most icon " -"on the top toolbar, an image of a map with a disk--on the top menu and fill " -"out the title and abstract of the map.

\n" +"

Once a suitable set of layers and zoom level has been found it's time to save it so " +"others can see it. Click the Save button--the left most icon on the top toolbar, an image " +"of a map with a disk--on the top menu and fill out the title and abstract of the map.

\n" "\n" "

Publishing a Map

\n" -"

Any map from the GeoNode can be embedded for use in another site or " -"blog. To publish a map:

\n" +"

Any map from the GeoNode can be embedded for use in another site or blog. To publish " +"a map:

\n" "
    \n" -"
  1. Select the from the list of maps on the community or map page and " -"then hit the 'Publish map' button.
  2. \n" -"
  3. Choose your desired height and width for the widget in the wizard." -"
  4. \n" -"
  5. Copy the HTML snippet provided in the wizard to any HTML page or " -"iFrame-supporting blog post.
  6. \n" +"
  7. Select the from the list of maps on the community or map page and then hit the " +"'Publish map' button.
  8. \n" +"
  9. Choose your desired height and width for the widget in the wizard.
  10. \n" +"
  11. Copy the HTML snippet provided in the wizard to any HTML page or iFrame-supporting " +"blog post.
  12. \n" "
\n" -"

This will put an interactive widget showing you map in your web page " -"or blog post.

\n" -"

Note that the Map Composer also has a " -"button to publish the map. Just be sure to save the map before publishing if " -"there are changes that you want others to see. It publishes the last saved " -"version, not the last viewed version.

\n" +"

This will put an interactive widget showing you map in your web page or blog post.\n" +"

Note that the Map Composer also has a button to " +"publish the map. Just be sure to save the map before publishing if there are changes that " +"you want others to see. It publishes the last saved version, not the last viewed version.\n" "\n" "

Remixing a Map

\n" "

Any map available can serve as a starting point for a new map.

\n" @@ -5794,86 +5441,80 @@ msgid "" "
  • Open the map in the Map Composer.
  • \n" "
  • Add and remove layers as you like.
  • \n" "
  • Pan and zoom to highlight the area of interest.
  • \n" -"
  • IMPORTANT: Update the title, abstract, contact " -"info and tags to reflect the new map.
  • \n" +"
  • IMPORTANT: Update the title, abstract, contact info and tags to " +"reflect the new map.
  • \n" "
  • Save the map.
  • \n" " \n" -"

    You will be able to see your new map when you search for it from the " -"Maps tab.

    \n" +"

    You will be able to see your new map when you search for it from the Maps tab.

    \n" " " msgstr "" "\n" -"

    The GeoNode provides access to data sets and a map editing " -"application allows users to browse existing maps and contribute their own.\n" +"

    The GeoNode provides access to data sets and a map editing application allows users " +"to browse existing maps and contribute their own.

    \n" "\n" "

    Browsing Layers

    \n" "

    The Layers tab allows you to browse data " "uploaded to this GeoNode.

    \n" -"

    All data can be downloaded in a variety of formats, for use in other " -"applications.

    \n" +"

    All data can be downloaded in a variety of formats, for use in other applications.\n" "\n" "

    Developer Access

    \n" -"

    The Developer page is the place for " -"developers to get started building applications against the GeoNode. It " -"includes instructions on using the web services, links to the source code of " -"the GeoNode, and information about the open source projects used to create " -"it.

    \n" +"

    The Developer page is the place for developers " +"to get started building applications against the GeoNode. It includes instructions on using " +"the web services, links to the source code of the GeoNode, and information about the open " +"source projects used to create it.

    \n" "\n" "

    Browsing Maps

    \n" -"

    The GeoNode allows users to create and share maps with one another.\n" +"

    The GeoNode allows users to create and share maps with one another.

    \n" "

    The Maps tab is a gateway to map exploration on " -"GeoNode. From here you can search for a map or " -"create a map, which will open the Map " -"Composer.

    \n" +"GeoNode. From here you can search for a map or create a map, which will open the Map Composer.

    \n" "
    Google Earth Mode
    \n" -"

    Any map viewed in the interactive map editor can be seen in 3D mode " -"with the Google Earth plugin. To switch to 3D mode select the Google Earth " -"globe logo, the rightmost button on top toolbar. If you do not have the " -"Google Earth plugin installed you will be prompted to install it.

    \n" +"

    Any map viewed in the interactive map editor can be seen in 3D mode with the Google " +"Earth plugin. To switch to 3D mode select the Google Earth globe logo, the rightmost button " +"on top toolbar. If you do not have the Google Earth plugin installed you will be prompted to " +"install it.

    \n" "\n" "

    Creating a Map

    \n" "

    To create a new map go to the Contributed Maps " "tab and click the create your own map link.

    \n" -"

    This will take you to the Map Composer with " -"a base layer loaded.

    \n" -"

    To add data layers from the GeoNode click on the green plus button " -"located below the layers tab on the left hand side of the screen. This will " -"open a dialog listing all the layers available on the GeoNode.

    \n" -"

    To add layers to your map select them and hit the Add Layers button. When finished you may hit Done to close the " -"dialog and go back to the map.

    \n" +"

    This will take you to the Map Composer with a base " +"layer loaded.

    \n" +"

    To add data layers from the GeoNode click on the green plus button located below the " +"layers tab on the left hand side of the screen. This will open a dialog listing all the " +"layers available on the GeoNode.

    \n" +"

    To add layers to your map select them and hit the Add Layers button. " +"When finished you may hit Done to close the dialog and go back to the map.\n" "\n" "

    Reordering and Removing Layers
    \n" -"

    Change the display order of the layers listed in the data tab by " -"simply dragging and dropping their names. The order in the map will be " -"updated to reflect that. To turn a layer's visibility off simply uncheck it, " -"and to remove it entirely select it and hit the red minus button.

    \n" +"

    Change the display order of the layers listed in the data tab by simply dragging and " +"dropping their names. The order in the map will be updated to reflect that. To turn a " +"layer's visibility off simply uncheck it, and to remove it entirely select it and hit the " +"red minus button.

    \n" "\n" "
    Saving your map
    \n" -"

    Once a suitable set of layers and zoom level has been found it's time " -"to save it so others can see it. Click the Save button--the left most icon " -"on the top toolbar, an image of a map with a disk--on the top menu and fill " -"out the title and abstract of the map.

    \n" +"

    Once a suitable set of layers and zoom level has been found it's time to save it so " +"others can see it. Click the Save button--the left most icon on the top toolbar, an image " +"of a map with a disk--on the top menu and fill out the title and abstract of the map.

    \n" "\n" "

    Publishing a Map

    \n" -"

    Any map from the GeoNode can be embedded for use in another site or " -"blog. To publish a map:

    \n" +"

    Any map from the GeoNode can be embedded for use in another site or blog. To publish " +"a map:

    \n" "
      \n" -"
    1. Select the from the list of maps on the community or map page and " -"then hit the 'Publish map' button.
    2. \n" -"
    3. Choose your desired height and width for the widget in the wizard." -"
    4. \n" -"
    5. Copy the HTML snippet provided in the wizard to any HTML page or " -"iFrame-supporting blog post.
    6. \n" +"
    7. Select the from the list of maps on the community or map page and then hit the " +"'Publish map' button.
    8. \n" +"
    9. Choose your desired height and width for the widget in the wizard.
    10. \n" +"
    11. Copy the HTML snippet provided in the wizard to any HTML page or iFrame-supporting " +"blog post.
    12. \n" "
    \n" -"

    This will put an interactive widget showing you map in your web page " -"or blog post.

    \n" -"

    Note that the Map Composer also has a " -"button to publish the map. Just be sure to save the map before publishing if " -"there are changes that you want others to see. It publishes the last saved " -"version, not the last viewed version.

    \n" +"

    This will put an interactive widget showing you map in your web page or blog post.\n" +"

    Note that the Map Composer also has a button to " +"publish the map. Just be sure to save the map before publishing if there are changes that " +"you want others to see. It publishes the last saved version, not the last viewed version.\n" "\n" "

    Remixing a Map

    \n" "

    Any map available can serve as a starting point for a new map.

    \n" @@ -5881,12 +5522,12 @@ msgstr "" "
  • Open the map in the Map Composer.
  • \n" "
  • Add and remove layers as you like.
  • \n" "
  • Pan and zoom to highlight the area of interest.
  • \n" -"
  • IMPORTANT: Update the title, abstract, contact " -"info and tags to reflect the new map.
  • \n" +"
  • IMPORTANT: Update the title, abstract, contact info and tags to " +"reflect the new map.
  • \n" "
  • Save the map.
  • \n" " \n" -"

    You will be able to see your new map when you search for it from the " -"Maps tab.

    \n" +"

    You will be able to see your new map when you search for it from the Maps tab.

    \n" " " msgid "Sections" @@ -5923,30 +5564,25 @@ msgid "Advanced Search" msgstr "Ricerca Avanzata" msgid "" -"Click to search for geospatial data published by other users, organizations " -"and public sources. Download data in standard formats." +"Click to search for geospatial data published by other users, organizations and public " +"sources. Download data in standard formats." msgstr "" -"Clicca per la ricerca di dati geospaziali pubblicati da altri utenti, " -"organizzazioni e fonti pubbliche. Scaricare i dati in formati standard." +"Clicca per la ricerca di dati geospaziali pubblicati da altri utenti, organizzazioni e fonti " +"pubbliche. Scaricare i dati in formati standard." -#, fuzzy -#| msgid "dataset" msgid "Add datasets" -msgstr "dataset" +msgstr "Aggiungi dataset" -#, fuzzy -#| msgid "Explore Layers" msgid "Explore datasetss" -msgstr "Esplora livelli" +msgstr "Esplora i dataset" msgid "" -"Data is available for browsing, aggregating and styling to generate maps " -"which can be saved, downloaded, shared publicly or restricted to specify " -"users only." +"Data is available for browsing, aggregating and styling to generate maps which can be saved, " +"downloaded, shared publicly or restricted to specify users only." msgstr "" -"I dati sono disponibili per la navigazione, l'aggregazione e lo styling per " -"generare mappe che possono essere salvate, scaricate, condivise " -"pubblicamente o limitate per specificare solo gli utenti." +"I dati sono disponibili per la navigazione, l'aggregazione e lo styling per generare mappe " +"che possono essere salvate, scaricate, condivise pubblicamente o limitate per specificare " +"solo gli utenti." msgid "Create maps" msgstr "Creare mappe" @@ -5955,11 +5591,11 @@ msgid "Explore maps" msgstr "Esplora mappe" msgid "" -"As for the layers and maps GeoNode allows to publish tabular and text data, " -"manage their metadata and associated documents." +"As for the layers and maps GeoNode allows to publish tabular and text data, manage their " +"metadata and associated documents." msgstr "" -"Per quanto riguarda i livelli e le mappe, GeoNode consente di pubblicare " -"dati tabulari e di testo, gestirne i metadati e i documenti associati." +"Per quanto riguarda i livelli e le mappe, GeoNode consente di pubblicare dati tabulari e di " +"testo, gestirne i metadati e i documenti associati." msgid "Add documents" msgstr "Aggiungi documenti" @@ -5968,11 +5604,11 @@ msgid "Explore documents" msgstr "Esplora documenti" msgid "" -"Geonode allows registered users to easily upload geospatial data and various " -"documents in several formats." +"Geonode allows registered users to easily upload geospatial data and various documents in " +"several formats." msgstr "" -"GeoNode permette agli utenti registrati di caricare facilmente i dati " -"geospaziali e vari documenti in diversi formati." +"GeoNode permette agli utenti registrati di caricare facilmente i dati geospaziali e vari " +"documenti in diversi formati." msgid "See users" msgstr "Vedi utenti" @@ -5997,19 +5633,13 @@ msgid "is inviting you to join (%(site_name)s)." msgstr "ti sta invitando a registrati su (%(site_name)s)." #, python-format -msgid "" -"To do so, please register at %(site_name)s " -"Registration." +msgid "To do so, please register at %(site_name)s Registration." msgstr "" -"A tale scopo, registrarsi all'indirizzo " -"%(site_name)s Registrazione." +"A tale scopo, registrarsi all'indirizzo %(site_name)s " +"Registrazione." -msgid "" -"Once you receive the confirmation that your account is activated, you can " -"notify" -msgstr "" -"Una volta ricevuta la conferma dell'attivazione dell'account, è possibile " -"notificare" +msgid "Once you receive the confirmation that your account is activated, you can notify" +msgstr "Una volta ricevuta la conferma dell'attivazione dell'account, è possibile notificare" msgid "that you wish to join her/his group(s) through" msgstr "che si desidera unirsi al suo gruppo/i attraverso" @@ -6018,8 +5648,7 @@ msgid "this link" msgstr "questo link" #, python-format -msgid "" -"%(inviter_name)s is a member of the following group(s):" +msgid "%(inviter_name)s is a member of the following group(s):" msgstr "%(inviter_name)s è un membro dei seguenti gruppi:" msgid "We look forward to seeing you on the platform," @@ -6136,11 +5765,9 @@ msgstr "Il tuo account è ora attivo" msgid "has requested access to the site." msgstr "ha richiesto l'accesso a questo sito." -msgid "" -"You can enable access by setting the user as active on the admin section" +msgid "You can enable access by setting the user as active on the admin section" msgstr "" -"Puoi abilitare l'accesso all'utente impostandolo come attivo nella sezione " -"di Amministrazione" +"Puoi abilitare l'accesso all'utente impostandolo come attivo nella sezione di Amministrazione" msgid "A user has requested access to the site" msgstr "Un utente ha richiesto l'accesso al sito" @@ -6176,10 +5803,10 @@ msgid "A document has been uploaded" msgstr "Un documento è stato caricato" msgid "The following document was deleted" -msgstr "Il seguente documento è stato cancellato" +msgstr "Il seguente documento è stato eliminato" msgid "A document has been deleted" -msgstr "Un documento è stato cancellato" +msgstr "Un documento è stato eliminato" msgid "The following document was published" msgstr "Il seguente documento è stato pubblicato" @@ -6314,17 +5941,16 @@ msgid "" "\n" "

    \n" " Note:\n" -" You do not have a verified email address to which notices can be " -"sent. Add one now.\n" +" You do not have a verified email address to which notices can be sent. Add one now.\n" "

    \n" " " msgstr "" "\n" "

    \n" " Nota:\n" -" Non si dispone di un indirizzo e-mail verificato a cui gli " -"avvisi possono essere inviati. Aggiungerne uno " -"now.\n" +" Non si dispone di un indirizzo e-mail verificato a cui gli avvisi possono essere " +"inviati. Aggiungerne uno now.\n" "

    \n" " " @@ -6334,11 +5960,9 @@ msgstr "Tipo di notifica" msgid "requested you to download this resource" msgstr "ti ha richiesto di scaricare questa risorsa" -msgid "" -"Please go to resource page and assign the download permissions if you wish" +msgid "Please go to resource page and assign the download permissions if you wish" msgstr "" -"Per favore vai alla pagina delle risorse ed assegna i permessi di " -"scaricamento se desideri" +"Per favore vai alla pagina delle risorse ed assegna i permessi di scaricamento se desideri" msgid "A resource's download has been requested" msgstr "Richiesto lo scaricamento di una risorsa" @@ -6419,16 +6043,13 @@ msgstr "Selezione" msgid "No list items selected. Use the selection fields to add." msgstr "" -"Nessun elemento selezionato. Usare i pulsanti di selezione per aggiungere " -"gli elementi." +"Nessun elemento selezionato. Usare i pulsanti di selezione per aggiungere gli elementi." msgid "Filters" msgstr "Filtri" -#, fuzzy -#| msgid "Layers found" msgid "Datasets found" -msgstr "Livelli trovati" +msgstr "Dataset trovati" msgid "Maps found" msgstr "Mappe trovate" @@ -6466,10 +6087,8 @@ msgstr "Più popolari" msgid "Text" msgstr "Testo" -#, fuzzy -#| msgid "Share This" msgid "Share This Dataset" -msgstr "Condividi" +msgstr "Condividi questo dataset" msgid "Share This" msgstr "Condividi" @@ -6477,11 +6096,10 @@ msgstr "Condividi" msgid "Social Network Login Failure" msgstr "Errore di accesso al social network" -msgid "" -"An error occurred while attempting to login via your social network account." +msgid "An error occurred while attempting to login via your social network account." msgstr "" -"Si è verificato un errore durante il tentativo di accesso tramite l'account " -"del social network." +"Si è verificato un errore durante il tentativo di accesso tramite l'account del social " +"network." msgid "Code" msgstr "Codice" @@ -6493,16 +6111,14 @@ msgid "Account Connections" msgstr "Connessioni account" msgid "" -"You can sign in to your account using any of the following already connected " -"third party accounts:" +"You can sign in to your account using any of the following already connected third party " +"accounts:" msgstr "" -"Puoi accedere al tuo account utilizzando uno dei seguenti account di terze " -"parti già connessi:" +"Puoi accedere al tuo account utilizzando uno dei seguenti account di terze parti già " +"connessi:" -msgid "" -"You currently have no social network accounts connected to this account." -msgstr "" -"Al momento non hai account di social network connessi a questo account." +msgid "You currently have no social network accounts connected to this account." +msgstr "Al momento non hai account di social network connessi a questo account." msgid "Add a 3rd Party Account" msgstr "Aggiungere un account di terze parti" @@ -6512,13 +6128,12 @@ msgstr "Accesso annullato" #, python-format msgid "" -"You decided to cancel logging in to our site using one of your existing " -"accounts. If this was a mistake, please proceed to sign in." +"You decided to cancel logging in to our site using one of your existing accounts. If this " +"was a mistake, please proceed to sign in." msgstr "" -"Hai deciso di annullare l'accesso al nostro sito utilizzando uno dei tuoi " -"account esistenti. Se questo è stato un errore, si prega di procedere per accedere." +"Hai deciso di annullare l'accesso al nostro sito utilizzando uno dei tuoi account esistenti. " +"Se questo è stato un errore, si prega di procedere per accedere." msgid "Signup" msgstr "Registrati" @@ -6598,9 +6213,7 @@ msgid "Check this if the jumbotron background image already contains text" msgstr "Verificare se l'immagine di sfondo jumbotron contiene già testo" msgid "Disabling this slide will hide it from the slide show" -msgstr "" -"Se si disattiva questa diapositiva, questa verrà nascondeta dalla " -"presentazione" +msgstr "Se si disattiva questa diapositiva, questa verrà nascondeta dalla presentazione" msgid "Choose between using jumbotron background and slide show" msgstr "Scegliere tra l'utilizzo dello sfondo jumbotron e della presentazione" @@ -6611,19 +6224,11 @@ msgstr "Contenuto" msgid "Could not access to uploaded data." msgstr "Impossibile accedere ai dati caricati." -msgid "" -"One or more XML files was provided, but no matching files were found for " -"them." -msgstr "" -"È stato fornito uno o più file XML, ma non sono stati trovati file " -"corrispondenti." +msgid "One or more XML files was provided, but no matching files were found for them." +msgstr "È stato fornito uno o più file XML, ma non sono stati trovati file corrispondenti." -msgid "" -"One or more SLD files was provided, but no matching files were found for " -"them." -msgstr "" -"È stato fornito uno o più file SLD, ma non sono stati trovati file " -"corrispondenti." +msgid "One or more SLD files was provided, but no matching files were found for them." +msgstr "È stato fornito uno o più file SLD, ma non sono stati trovati file corrispondenti." msgid "Layer already exists" msgstr "Il livello esiste già" @@ -6644,8 +6249,7 @@ msgstr "Previsto per trovare il livello denominato '{name}' nel geoserver" msgid "Exception occurred while parsing the provided Metadata file." msgstr "Eccezione durante l'analisi del file di metadati fornito." -msgid "" -"The UUID identifier from the XML Metadata is already in use in this system." +msgid "The UUID identifier from the XML Metadata is already in use in this system." msgstr "L'identificatore UUID dei metadati XML è già in uso in questo sistema." msgid "Error configuring Layer" @@ -6655,9 +6259,7 @@ msgid "Invalid zip file detected" msgstr "Rilevato file zip non valido" msgid "Could not find any valid spatial file inside the uploaded zip" -msgstr "" -"Impossibile trovare alcun file spaziale valido all'interno del file zip " -"caricato" +msgstr "Impossibile trovare alcun file spaziale valido all'interno del file zip caricato" msgid "Invalid kmz file detected" msgstr "Rilevato file kmz non valido" @@ -6680,12 +6282,8 @@ msgstr "È consentito un solo file kml per file zip" msgid "Only one kml file per kmz is allowed" msgstr "È consentito un solo file kml per kmz" -msgid "" -"You are trying to upload multiple GeoTIFFs without a valid 'indexer." -"properties' file." -msgstr "" -"Si sta tentando di caricare più GeoTIFF senza un file 'indexer.properties' " -"valido." +msgid "You are trying to upload multiple GeoTIFFs without a valid 'indexer.properties' file." +msgstr "Si sta tentando di caricare più GeoTIFF senza un file 'indexer.properties' valido." msgid "Only one raster file per ZIP is allowed" msgstr "È consentito un solo file raster per file zip" @@ -6693,11 +6291,9 @@ msgstr "È consentito un solo file raster per file zip" msgid "No multiple rasters allowed" msgstr "Non sono consentiti più raster" -msgid "" -"To support the time step, you must enable the OGC_SERVER DATASTORE option" +msgid "To support the time step, you must enable the OGC_SERVER DATASTORE option" msgstr "" -"Per supportare il passaggio temporale, è necessario attivare l'opzione " -"OGC_SERVER DATASTORE" +"Per supportare il passaggio temporale, è necessario attivare l'opzione OGC_SERVER DATASTORE" #, python-brace-format msgid "Unsupported file type: {e.message}" @@ -6757,9 +6353,7 @@ msgstr "Permesso negato" #~ msgstr "Carica livello" #~ msgid "You are attempting to replace a vector layer with an unknown format." -#~ msgstr "" -#~ "Stai tentando di sostituire un livello vettoriale con un formato " -#~ "sconosiuto." +#~ msgstr "Stai tentando di sostituire un livello vettoriale con un formato sconosiuto." #~ msgid "Failed to upload the layer" #~ msgstr "Impossibile caricare il livello" From b40d44d872a060a6d8321ee56b596a9b18a7c8a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jul 2023 12:51:35 +0200 Subject: [PATCH 089/330] Bump pip from 23.2 to 23.2.1 (#11286) * Bump pip from 23.2 to 23.2.1 Bumps [pip](https://github.com/pypa/pip) from 23.2 to 23.2.1. - [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/pip/compare/23.2...23.2.1) --- updated-dependencies: - dependency-name: pip dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Aligining "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index a9d73c1d309..ecea10b78ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -160,7 +160,7 @@ splinter==0.19.0 pytest-splinter==3.3.2 pytest-django==4.5.2 setuptools>=59.1.1,<68.1.0 -pip==23.2 +pip==23.2.1 Twisted==22.10.0 pixelmatch==0.3.0 factory-boy==3.3.0 diff --git a/setup.cfg b/setup.cfg index 3bbf22b9465..557414bae72 100644 --- a/setup.cfg +++ b/setup.cfg @@ -185,7 +185,7 @@ install_requires = pytest-splinter==3.3.2 pytest-django==4.5.2 setuptools>=59.1.1,<68.1.0 - pip==23.2 + pip==23.2.1 Twisted==22.10.0 pixelmatch==0.3.0 factory-boy==3.3.0 From 8744d3c0042a6e004d3362d54925a0cfe1c9e12f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jul 2023 12:51:52 +0200 Subject: [PATCH 090/330] Bump gunicorn from 21.1.0 to 21.2.0 (#11290) * Bump gunicorn from 21.1.0 to 21.2.0 Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 21.1.0 to 21.2.0. - [Release notes](https://github.com/benoitc/gunicorn/releases) - [Commits](https://github.com/benoitc/gunicorn/compare/21.1.0...21.2.0) --- updated-dependencies: - dependency-name: gunicorn dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Aligining "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index ecea10b78ba..91ffa8bd8e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -144,7 +144,7 @@ pycountry # production uWSGI==2.0.21 -gunicorn==21.1.0 +gunicorn==21.2.0 ipython==8.14.0 docker==6.1.3 invoke==2.2.0 diff --git a/setup.cfg b/setup.cfg index 557414bae72..5627f5b4574 100644 --- a/setup.cfg +++ b/setup.cfg @@ -169,7 +169,7 @@ install_requires = # production uWSGI==2.0.21 - gunicorn==21.1.0 + gunicorn==21.2.0 ipython==8.14.0 docker==6.1.3 invoke==2.2.0 From a0e88e90337ef25cb9adc56db0fdf3562e1c3759 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jul 2023 12:52:08 +0200 Subject: [PATCH 091/330] Bump drf-spectacular from 0.26.3 to 0.26.4 (#11289) * Bump drf-spectacular from 0.26.3 to 0.26.4 Bumps [drf-spectacular](https://github.com/tfranzel/drf-spectacular) from 0.26.3 to 0.26.4. - [Release notes](https://github.com/tfranzel/drf-spectacular/releases) - [Changelog](https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tfranzel/drf-spectacular/compare/0.26.3...0.26.4) --- updated-dependencies: - dependency-name: drf-spectacular dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Aligining "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 91ffa8bd8e0..ea943007e75 100644 --- a/requirements.txt +++ b/requirements.txt @@ -80,7 +80,7 @@ djangorestframework-gis==1.0 djangorestframework-guardian==0.3.0 drf-extensions==0.7.1 drf-writable-nested==0.7.0 -drf-spectacular==0.26.3 +drf-spectacular==0.26.4 dynamic-rest==2.1.2 Markdown==3.4.3 diff --git a/setup.cfg b/setup.cfg index 5627f5b4574..c197c9aa0a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -106,7 +106,7 @@ install_requires = djangorestframework-guardian==0.3.0 drf-extensions==0.7.1 drf-writable-nested==0.7.0 - drf-spectacular==0.26.3 + drf-spectacular==0.26.4 dynamic-rest==2.1.2 Markdown==3.4.3 From a2d0981bce1f1a30ad5bf6df457f21b696c4f49f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jul 2023 12:52:24 +0200 Subject: [PATCH 092/330] Bump webdriver-manager from 3.8.6 to 3.9.1 (#11288) * Bump webdriver-manager from 3.8.6 to 3.9.1 Bumps [webdriver-manager](https://github.com/SergeyPirogov/webdriver_manager) from 3.8.6 to 3.9.1. - [Release notes](https://github.com/SergeyPirogov/webdriver_manager/releases) - [Changelog](https://github.com/SergeyPirogov/webdriver_manager/blob/master/CHANGELOG.md) - [Commits](https://github.com/SergeyPirogov/webdriver_manager/compare/v3.8.6...v3.9.1) --- updated-dependencies: - dependency-name: webdriver-manager dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Aligining "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index ea943007e75..5fc7074cac2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -167,7 +167,7 @@ factory-boy==3.3.0 flaky==3.7.0 selenium>=4.1.0,<5.0.0 selenium-requests==2.0.3 -webdriver_manager==3.8.6 +webdriver_manager==3.9.1 # Security and audit mistune==3.0.1 diff --git a/setup.cfg b/setup.cfg index c197c9aa0a2..908a2277867 100644 --- a/setup.cfg +++ b/setup.cfg @@ -192,7 +192,7 @@ install_requires = flaky==3.7.0 selenium>=4.1.0,<5.0.0 selenium-requests==2.0.3 - webdriver_manager==3.8.6 + webdriver_manager==3.9.1 # Security and audit mistune==3.0.1 From d2f5d3df5436d0f49c870db85bf1088a5734e2df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jul 2023 12:52:41 +0200 Subject: [PATCH 093/330] Bump boto3 from 1.28.5 to 1.28.9 (#11287) * Bump boto3 from 1.28.5 to 1.28.9 Bumps [boto3](https://github.com/boto/boto3) from 1.28.5 to 1.28.9. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.28.5...1.28.9) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Aligining "setup.cfg" to "requirements.txt" --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5fc7074cac2..32be7fdb44d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 -boto3==1.28.5 +boto3==1.28.9 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index 908a2277867..f39b8c3d7b8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 - boto3==1.28.5 + boto3==1.28.9 # Django Caches python-memcached<=1.59 From 1b0abd9e1f8c5485ed943e8aee79cf4badf7cb57 Mon Sep 17 00:00:00 2001 From: etj Date: Tue, 25 Jul 2023 12:37:05 +0200 Subject: [PATCH 094/330] [Fixes #11265] Change the WMS URL to a more generic OWS URL --- geonode/geoserver/helpers.py | 2 +- geonode/geoserver/tests/test_helpers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/geonode/geoserver/helpers.py b/geonode/geoserver/helpers.py index d04fb3e12ff..c5d4742b6cd 100755 --- a/geonode/geoserver/helpers.py +++ b/geonode/geoserver/helpers.py @@ -2287,7 +2287,7 @@ def get_dataset_capabilities_url(layer, version="1.3.0", access_token=None): workspace_layername = layer.alternate.split(":") if ":" in layer.alternate else ("", layer.alternate) wms_url = settings.GEOSERVER_PUBLIC_LOCATION if not layer.remote_service: - wms_url = f"{wms_url}{'/'.join(workspace_layername)}/wms?service=wms&version={version}&request=GetCapabilities" # noqa + wms_url = f"{wms_url}{'/'.join(workspace_layername)}/ows?service=wms&version={version}&request=GetCapabilities" # noqa if access_token: wms_url += f"&access_token={access_token}" else: diff --git a/geonode/geoserver/tests/test_helpers.py b/geonode/geoserver/tests/test_helpers.py index 80a0823bf32..9c71711511a 100644 --- a/geonode/geoserver/tests/test_helpers.py +++ b/geonode/geoserver/tests/test_helpers.py @@ -292,6 +292,6 @@ def test_dataset_capabilties_url(self): ows_url = settings.GEOSERVER_PUBLIC_LOCATION identifier = "geonode:CA" dataset = Dataset.objects.get(alternate=identifier) - expected_url = f"{ows_url}geonode/CA/wms?service=wms&version=1.3.0&request=GetCapabilities" + expected_url = f"{ows_url}geonode/CA/ows?service=wms&version=1.3.0&request=GetCapabilities" capabilities_url = get_dataset_capabilities_url(dataset) self.assertEqual(capabilities_url, expected_url, capabilities_url) From 1ae4d1200b4384c184734e1c7f0e75f36ce824d2 Mon Sep 17 00:00:00 2001 From: etj Date: Mon, 17 Jul 2023 18:55:40 +0200 Subject: [PATCH 095/330] [Fixes #11262] Faceting: improvement: include count 0 topics --- geonode/facets/models.py | 10 +- geonode/facets/providers/baseinfo.py | 4 + geonode/facets/providers/category.py | 21 +++- geonode/facets/providers/keyword.py | 6 + geonode/facets/providers/region.py | 19 ++- geonode/facets/providers/thesaurus.py | 5 + geonode/facets/providers/users.py | 19 ++- geonode/facets/tests.py | 164 +++++++++++++++++++++++--- geonode/facets/views.py | 19 ++- 9 files changed, 236 insertions(+), 31 deletions(-) diff --git a/geonode/facets/models.py b/geonode/facets/models.py index 1b7f9964f7b..0270720e346 100644 --- a/geonode/facets/models.py +++ b/geonode/facets/models.py @@ -71,7 +71,14 @@ def get_info(self, lang="en", **kwargs) -> dict: pass def get_facet_items( - self, queryset, start: int = 0, end: int = DEFAULT_FACET_PAGE_SIZE, lang="en", topic_contains: str = None + self, + queryset, + start: int = 0, + end: int = DEFAULT_FACET_PAGE_SIZE, + lang="en", + topic_contains: str = None, + keys: set = {}, + **kwargs, ) -> (int, list): """ Return the items of the facets, in a tuple: @@ -87,6 +94,7 @@ def get_facet_items( :param end: int: pagination, the index of the last returned item :param lang: the preferred language for the labels :param topic_contains: only returns matching topics + :param keys: only returns topics with given keys, even if their count is 0 :return: a tuple int:total count of record, list of items """ pass diff --git a/geonode/facets/providers/baseinfo.py b/geonode/facets/providers/baseinfo.py index 7a3ee9b1ffb..f81e7583c66 100644 --- a/geonode/facets/providers/baseinfo.py +++ b/geonode/facets/providers/baseinfo.py @@ -52,6 +52,8 @@ def get_facet_items( end: int = DEFAULT_FACET_PAGE_SIZE, lang="en", topic_contains: str = None, + keys: set = {}, + **kwargs, ) -> (int, list): logger.debug("Retrieving facets for %s", self.name) @@ -120,6 +122,8 @@ def get_facet_items( end: int = DEFAULT_FACET_PAGE_SIZE, lang="en", topic_contains: str = None, + keys: set = {}, + **kwargs, ) -> (int, list): logger.debug("Retrieving facets for %s", self.name) diff --git a/geonode/facets/providers/category.py b/geonode/facets/providers/category.py index 9e027dec1e9..28b27749bbf 100644 --- a/geonode/facets/providers/category.py +++ b/geonode/facets/providers/category.py @@ -52,15 +52,26 @@ def get_facet_items( end: int = DEFAULT_FACET_PAGE_SIZE, lang="en", topic_contains: str = None, + keys: set = {}, + **kwargs, ) -> (int, list): logger.debug("Retrieving facets for %s", self.name) - q = queryset.values("category__identifier", "category__gn_description", "category__fa_class").filter( - category__isnull=False - ) + filters = {"category__isnull": False} + if topic_contains: - q = q.filter(category__gn_description=topic_contains) - q = q.annotate(count=Count("owner")).order_by("-count") + filters["category__gn_description"] = topic_contains + + if keys: + logger.debug("Filtering by keys %r", keys) + filters["category__identifier__in"] = keys + + q = ( + queryset.values("category__identifier", "category__gn_description", "category__fa_class") + .filter(**filters) + .annotate(count=Count("owner")) + .order_by("-count") + ) cnt = q.count() diff --git a/geonode/facets/providers/keyword.py b/geonode/facets/providers/keyword.py index 4d377538a2d..ce9e5f6f8f9 100644 --- a/geonode/facets/providers/keyword.py +++ b/geonode/facets/providers/keyword.py @@ -52,6 +52,8 @@ def get_facet_items( end: int = DEFAULT_FACET_PAGE_SIZE, lang="en", topic_contains: str = None, + keys: set = {}, + **kwargs, ) -> (int, list): logger.debug("Retrieving facets for %s", self.name) @@ -59,6 +61,10 @@ def get_facet_items( if topic_contains: filters["keywords__name__icontains"] = topic_contains + if keys: + logger.debug("Filtering by keys %r", keys) + filters["keywords__slug__in"] = keys + q = ( queryset.filter(**filters) .values("keywords__slug", "keywords__name") diff --git a/geonode/facets/providers/region.py b/geonode/facets/providers/region.py index a4935d35468..c40138e58bb 100644 --- a/geonode/facets/providers/region.py +++ b/geonode/facets/providers/region.py @@ -52,13 +52,26 @@ def get_facet_items( end: int = DEFAULT_FACET_PAGE_SIZE, lang="en", topic_contains: str = None, + keys: set = {}, + **kwargs, ) -> (int, list): logger.debug("Retrieving facets for %s", self.name) - q = queryset.filter(regions__isnull=False).values("regions__code", "regions__name") + filters = {"regions__isnull": False} + if topic_contains: - q = q.filter(regions__name=topic_contains) - q = q.annotate(count=Count("regions__code")).order_by("-count") + filters["regions__name"] = topic_contains + + if keys: + logger.debug("Filtering by keys %r", keys) + filters["regions__code__in"] = keys + + q = ( + queryset.filter(**filters) + .values("regions__code", "regions__name") + .annotate(count=Count("regions__code")) + .order_by("-count") + ) cnt = q.count() diff --git a/geonode/facets/providers/thesaurus.py b/geonode/facets/providers/thesaurus.py index 91823f00ac4..bb40f455924 100644 --- a/geonode/facets/providers/thesaurus.py +++ b/geonode/facets/providers/thesaurus.py @@ -62,6 +62,7 @@ def get_facet_items( end: int = DEFAULT_FACET_PAGE_SIZE, lang="en", topic_contains: str = None, + keys: set = {}, **kwargs, ) -> (int, list): logger.debug("Retrieving facets for %s", self._name) @@ -73,6 +74,10 @@ def get_facet_items( if topic_contains: filter["tkeywords__keyword__label__icontains"] = topic_contains + if keys: + logger.debug("Filtering by keys %r\n", keys) + filter["tkeywords__in"] = keys + q = ( queryset.filter(**filter) .values("tkeywords", "tkeywords__alt_label") diff --git a/geonode/facets/providers/users.py b/geonode/facets/providers/users.py index 80e47c50952..cf2a52cd9ba 100644 --- a/geonode/facets/providers/users.py +++ b/geonode/facets/providers/users.py @@ -52,13 +52,26 @@ def get_facet_items( end: int = DEFAULT_FACET_PAGE_SIZE, lang="en", topic_contains: str = None, + keys: set = {}, + **kwargs, ) -> (int, list): logger.debug("Retrieving facets for OWNER") - q = queryset.values("owner", "owner__username") + filters = dict() + if topic_contains: - q = q.filter(owner__username__icontains=topic_contains) - q = q.annotate(count=Count("owner")).order_by("-count") + filters["owner__username__icontains"] = topic_contains + + if keys: + logger.debug("Filtering by keys %r", keys) + filters["owner__in"] = keys + + q = ( + queryset.values("owner", "owner__username") + .filter(**filters) + .annotate(count=Count("owner")) + .order_by("-count") + ) cnt = q.count() diff --git a/geonode/facets/tests.py b/geonode/facets/tests.py index 127ada7cd7a..cf05e1e3734 100644 --- a/geonode/facets/tests.py +++ b/geonode/facets/tests.py @@ -27,9 +27,20 @@ from django.test import RequestFactory from django.urls import reverse -from geonode.base.models import Thesaurus, ThesaurusLabel, ThesaurusKeyword, ThesaurusKeywordLabel, ResourceBase, Region +from geonode.base.models import ( + Thesaurus, + ThesaurusLabel, + ThesaurusKeyword, + ThesaurusKeywordLabel, + ResourceBase, + Region, + TopicCategory, + HierarchicalKeyword, +) from geonode.facets.models import facet_registry from geonode.facets.providers.baseinfo import FeaturedFacetProvider +from geonode.facets.providers.category import CategoryFacetProvider +from geonode.facets.providers.keyword import KeywordFacetProvider from geonode.facets.providers.region import RegionFacetProvider from geonode.tests.base import GeoNodeBaseTestSupport import geonode.facets.views as views @@ -48,6 +59,8 @@ def setUpClass(cls): cls._create_thesauri() cls._create_regions() + cls._create_categories() + cls._create_keywords() cls._create_resources() cls.rf = RequestFactory() @@ -98,6 +111,28 @@ def _create_regions(cls): ): cls.regions[code] = Region.objects.create(code=code, name=name) + @classmethod + def _create_categories(cls): + cls.cats = {} + + for code, name in ( + ("C0", "Cat0"), + ("C1", "Cat1"), + ("C2", "Cat2"), + ): + cls.cats[code] = TopicCategory.objects.create(identifier=code, description=name, gn_description=name) + + @classmethod + def _create_keywords(cls): + cls.kw = {} + + for code, name in ( + ("K0", "Keyword0"), + ("K1", "Keyword1"), + ("K2", "Keyword2"), + ): + cls.kw[code] = HierarchicalKeyword.objects.create(slug=code, name=name) + @classmethod def _create_resources(self): public_perm_spec = {"users": {"AnonymousUser": ["view_resourcebase"]}, "groups": []} @@ -115,12 +150,12 @@ def _create_resources(self): # These are the assigned keywords to the Resources - # RB00 -> T1K0 R0,R1 FEAT - # RB01 -> T0K0 T1K0 R0 FEAT - # RB02 -> T1K0 R1 FEAT - # RB03 -> T0K0 T1K0 - # RB04 -> T1K0 - # RB05 -> T0K0 T1K0 + # RB00 -> T1K0 R0,R1 FEAT K0 C0 + # RB01 -> T0K0 T1K0 R0 FEAT K1 + # RB02 -> T1K0 R1 FEAT K2 C0 + # RB03 -> T0K0 T1K0 K0 + # RB04 -> T1K0 K0,K1 C0 + # RB05 -> T0K0 T1K0 K0,K2 C1 # RB06 -> T1K0 FEAT # RB07 -> T0K0 T1K0 FEAT # RB08 -> T1K0 T1K1 R1 FEAT @@ -130,11 +165,11 @@ def _create_resources(self): # RB12 -> T1K1 FEAT # RB13 -> T0K0 T0K1 R1 FEAT # RB14 -> FEAT - # RB15 -> T0K0 T0K1 - # RB16 -> + # RB15 -> T0K0 T0K1 C1 + # RB16 -> C1 # RB17 -> T0K0 T0K1 - # RB18 -> FEAT - # RB19 -> T0K0 T0K1 FEAT + # RB18 -> FEAT C2 + # RB19 -> T0K0 T0K1 FEAT C2 if x % 2 == 1: logger.debug(f"ADDING KEYWORDS {self.thesauri_k['0_0']} to RB {d}") @@ -147,13 +182,33 @@ def _create_resources(self): d.tkeywords.add(self.thesauri_k["1_0"]) if 7 < x < 13: d.tkeywords.add(self.thesauri_k["1_1"]) - if x in (0, 1): - d.regions.add(self.regions["R0"]) - if x in (0, 2, 8, 13): - d.regions.add(self.regions["R1"]) + if (x % 6) in (0, 1, 2): d.featured = True + for reg, idx in ( + ("R0", (0, 1)), + ("R1", (0, 2, 8, 13)), + ): + if x in idx: + d.regions.add(self.regions[reg]) + + for kw, idx in ( + ("K0", (0, 3, 4, 5)), + ("K1", [1, 4]), + ("K2", [2, 5]), + ): + if x in idx: + d.keywords.add(self.kw[kw]) + + for cat, idx in ( + ("C0", [0, 2, 4]), + ("C1", [5, 15, 16]), + ("C2", [18, 19]), + ): + if x in idx: + d.category = self.cats[cat] + d.save() d.set_permissions(public_perm_spec) @@ -193,7 +248,23 @@ def test_facets_rich(self): { "name": "category", "topics": { - "total": 0, + "total": 3, + "items": [ + {"label": "Cat0", "count": 3}, + {"label": "Cat1", "count": 3}, + {"label": "Cat2", "count": 2}, + ], + }, + }, + { + "name": "keyword", + "topics": { + "total": 3, + "items": [ + {"label": "Keyword0", "count": 4}, + {"label": "Keyword1", "count": 2}, + {"label": "Keyword2", "count": 2}, + ], }, }, { @@ -306,7 +377,7 @@ def test_bad_lang(self): def test_topics(self): for facet, keys, exp in ( ("t_0", [self.thesauri_k["0_0"].id, self.thesauri_k["0_1"].id, -999], 2), - ("category", ["C1", "C2", "nomatch"], 0), + ("category", ["C1", "C2", "nomatch"], 2), ("owner", [self.user.id, -100], 1), ("region", ["R0", "R1", "nomatch"], 2), ): @@ -409,6 +480,65 @@ def test_config(self): else: self.assertEqual(order, conf["order"], "Unexpected order") + def test_count0(self): + reginfo = RegionFacetProvider().get_info() + regfilter = reginfo["filter"] + regname = reginfo["name"] + + catinfo = CategoryFacetProvider().get_info() + catname = catinfo["name"] + + kwinfo = KeywordFacetProvider().get_info() + kwfilter = kwinfo["filter"] + kwname = kwinfo["name"] + + t0filter = facet_registry.get_provider("t_0").get_info()["filter"] + t1filter = facet_registry.get_provider("t_1").get_info()["filter"] + + def t(tk): + return self.thesauri_k[tk].id + + for facet, params, items in ( + # thesauri + ("t_1", {regfilter: "R0"}, {t("1_0"): 2}), + ("t_1", {regfilter: "R0", "key": [t("1_0")]}, {t("1_0"): 2}), + ("t_1", {regfilter: "R0", t0filter: t("0_1")}, {}), + ("t_1", {regfilter: "R0", t0filter: t("0_1"), "key": [t("1_0")]}, {t("1_0"): None}), + ( + "t_1", + {regfilter: "R0", t0filter: t("0_1"), "key": [t("1_1"), t("1_0")]}, + {t("1_0"): None, t("1_1"): None}, + ), + # regions + (regname, {t1filter: t("1_1")}, {"R1": 1}), + (regname, {t1filter: t("1_1"), "key": ["R0", "R1"]}, {"R1": 1, "R0": None}), + (regname, {t1filter: t("1_1"), "key": ["R0"]}, {"R0": None}), + # category + (catname, {t1filter: t("1_0")}, {"C0": 3, "C1": 1}), + (catname, {t1filter: t("1_0"), "key": ["C0", "C2"]}, {"C0": 3, "C2": None}), + (catname, {kwfilter: "K1"}, {"C0": 1}), + (catname, {kwfilter: "K1", "key": ["C0", "C2"]}, {"C0": 1, "C2": None}), + # keyword + (kwname, {t0filter: t("0_0")}, {"K0": 2, "K1": 1, "K2": 1}), + (kwname, {t0filter: t("0_0"), regfilter: "R0"}, {"K1": 1}), + (kwname, {t0filter: t("0_0"), regfilter: "R0", "key": ["K0"]}, {"K0": None}), + ): + req = self.rf.get(reverse("get_facet", args=[facet]), data=params) + res: JsonResponse = views.get_facet(req, facet) + obj = json.loads(res.content) + # self.assertEqual(totals, obj["topics"]["total"], f"Bad totals for facet '{facet} and params {params}") + + self.assertEqual( + len(items), + len(obj["topics"]["items"]), + f"Bad count for items '{facet} \n PARAMS: {params} \n RESULT: {obj} \n EXPECTED: {items}", + ) + # search item + for item in items.keys(): + found = next((i for i in obj["topics"]["items"] if i["key"] == item), None) + self.assertIsNotNone(found, f"Topic '{item}' not found in facet {facet} -- {obj}") + self.assertEqual(items[item], found.get("count", None), f"Bad count for facet '{facet}:{item}") + def test_user_auth(self): # make sure the user authorization pre-filters the visible resources # TODO test diff --git a/geonode/facets/views.py b/geonode/facets/views.py index b6e876f1a4d..a02c8aa961f 100644 --- a/geonode/facets/views.py +++ b/geonode/facets/views.py @@ -94,6 +94,8 @@ def get_facet(request, facet): include_config = _resolve_boolean(request, PARAM_INCLUDE_CONFIG, False) topic_contains = request.GET.get(PARAM_TOPIC_CONTAINS, None) + keys = set(request.query_params.getlist("key")) + page = int(request.GET.get(PARAM_PAGE, 0)) page_size = int(request.GET.get(PARAM_PAGE_SIZE, DEFAULT_FACET_PAGE_SIZE)) @@ -103,7 +105,7 @@ def get_facet(request, facet): qs = _prefilter_topics(request) topics = _get_topics( - provider, queryset=qs, page=page, page_size=page_size, lang=lang, topic_contains=topic_contains + provider, queryset=qs, page=page, page_size=page_size, lang=lang, topic_contains=topic_contains, keys=keys ) if add_link: @@ -156,11 +158,23 @@ def _get_topics( page_size: int = DEFAULT_FACET_PAGE_SIZE, lang: str = "en", topic_contains: str = None, + keys: set = {}, + **kwargs, ): start = page * page_size end = start + page_size - cnt, items = provider.get_facet_items(queryset, start=start, end=end, lang=lang, topic_contains=topic_contains) + cnt, items = provider.get_facet_items( + queryset, start=start, end=end, lang=lang, topic_contains=topic_contains, keys=keys + ) + + if keys: + keys.difference_update({str(t["key"]) for t in items}) + if keys: + ext = provider.get_topics(keys, lang) + items.extend(ext) + cnt += len(ext) + logger.debug("Extending facets to %d for %s", cnt, provider.name) return {"page": page, "page_size": page_size, "start": start, "total": cnt, "items": items} @@ -175,6 +189,7 @@ def _prefilter_topics(request): """ logger.debug("Filtering by user '%s'", request.user) filters = {k: vlist for k, vlist in request.query_params.lists() if k.startswith("filter{")} + logger.warning(f"FILTERING BY {filters}") if filters: viewset = ResourceBaseViewSet(request=request, format_kwarg={}, kwargs=filters) From 2f4e4af47a76890e79a3b1da43e6b494ecab80ab Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Thu, 27 Jul 2023 10:15:00 +0200 Subject: [PATCH 096/330] =?UTF-8?q?=20-=20Updating=20docker-compose=20tags?= =?UTF-8?q?,=20images=20versions=20and=20.envs=0B=20-=20Bump=20Po=E2=80=A6?= =?UTF-8?q?=20(#11199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * - Updating docker-compose tags, images versions and .envs - Bump Postgres Image from 13 to 15 * - Fix tests * - Upgrade "letsencrypt" to "alpine:latest" base image * Bump nginx tag to 1.25.1-alpine * - Update images versions - Enable postgresql-client-15 --- .env | 18 ++++++++++-------- .env_dev | 2 ++ .env_local | 4 +++- .env_test | 4 +++- docker-compose.yml | 24 ++++++++++++------------ scripts/docker/base/ubuntu/Dockerfile | 4 ++-- scripts/docker/letsencrypt/Dockerfile | 2 +- scripts/docker/nginx/Dockerfile | 2 +- start_django_async.sh | 2 +- 9 files changed, 35 insertions(+), 27 deletions(-) diff --git a/.env b/.env index 96b166bf2fb..492278acb55 100644 --- a/.env +++ b/.env @@ -29,8 +29,10 @@ NGINX_BASE_URL= POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres GEONODE_DATABASE=geonode +GEONODE_DATABASE_USER=geonode GEONODE_DATABASE_PASSWORD=geonode GEONODE_GEODATABASE=geonode_data +GEONODE_GEODATABASE_USER=geonode_data GEONODE_GEODATABASE_PASSWORD=geonode_data GEONODE_DATABASE_SCHEMA=public GEONODE_GEODATABASE_SCHEMA=public @@ -45,7 +47,7 @@ BROKER_URL=amqp://guest:guest@rabbitmq:5672/ CELERY_BEAT_SCHEDULER=celery.beat:PersistentScheduler ASYNC_SIGNALS=True -SITEURL=http://localhost/ +SITEURL=https://localhost/ ALLOWED_HOSTS=['django', '*'] @@ -67,8 +69,8 @@ GEONODE_LB_PORT=80 # IP or domain name and port where the server can be reached on HTTPS (leave HOST empty if you want to use HTTP only) # port where the server can be reached on HTTPS -HTTP_HOST=localhost -HTTPS_HOST= +HTTP_HOST= +HTTPS_HOST=localhost HTTP_PORT=80 HTTPS_PORT=443 @@ -78,8 +80,8 @@ HTTPS_PORT=443 # disabled : we do not get a certificate at all (a placeholder certificate will be used) # staging : we get staging certificates (are invalid, but allow to test the process completely and have much higher limit rates) # production : we get a normal certificate (default) -LETSENCRYPT_MODE=disabled -# LETSENCRYPT_MODE=staging +# LETSENCRYPT_MODE=disabled +LETSENCRYPT_MODE=staging # LETSENCRYPT_MODE=production RESOLVER=127.0.0.11 @@ -87,8 +89,8 @@ RESOLVER=127.0.0.11 # ################# # geoserver # ################# -GEOSERVER_WEB_UI_LOCATION=http://localhost/geoserver/ -GEOSERVER_PUBLIC_LOCATION=http://localhost/geoserver/ +GEOSERVER_WEB_UI_LOCATION=https://localhost/geoserver/ +GEOSERVER_PUBLIC_LOCATION=https://localhost/geoserver/ GEOSERVER_LOCATION=http://geoserver:8080/geoserver/ GEOSERVER_ADMIN_USER=admin GEOSERVER_ADMIN_PASSWORD=geoserver @@ -186,7 +188,7 @@ BING_API_KEY= GOOGLE_API_KEY= # Monitoring -MONITORING_ENABLED=True +MONITORING_ENABLED=False MONITORING_DATA_TTL=365 USER_ANALYTICS_ENABLED=True USER_ANALYTICS_GZIP=True diff --git a/.env_dev b/.env_dev index 937d630ad4d..88a2b273a4f 100644 --- a/.env_dev +++ b/.env_dev @@ -29,8 +29,10 @@ NGINX_BASE_URL= POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres GEONODE_DATABASE=geonode +GEONODE_DATABASE_USER=geonode GEONODE_DATABASE_PASSWORD=geonode GEONODE_GEODATABASE=geonode_data +GEONODE_GEODATABASE_USER=geonode GEONODE_GEODATABASE_PASSWORD=geonode GEONODE_DATABASE_SCHEMA=public GEONODE_GEODATABASE_SCHEMA=public diff --git a/.env_local b/.env_local index 4bec5dc60bc..a19213926f2 100644 --- a/.env_local +++ b/.env_local @@ -29,8 +29,10 @@ NGINX_BASE_URL= POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres GEONODE_DATABASE=geonode +GEONODE_DATABASE_USER=geonode GEONODE_DATABASE_PASSWORD=geonode GEONODE_GEODATABASE=geonode_data +GEONODE_GEODATABASE_USER=geonode GEONODE_GEODATABASE_PASSWORD=geonode GEONODE_DATABASE_SCHEMA=public GEONODE_GEODATABASE_SCHEMA=public @@ -45,7 +47,7 @@ BROKER_URL=amqp://admin:admin@localhost:5672// CELERY_BEAT_SCHEDULER=celery.beat:PersistentScheduler ASYNC_SIGNALS=False -SITEURL=http://localhost/ +SITEURL=http://localhost:8000/ ALLOWED_HOSTS="['django', '*']" diff --git a/.env_test b/.env_test index a728f8fd4d1..9eed802d452 100644 --- a/.env_test +++ b/.env_test @@ -29,8 +29,10 @@ NGINX_BASE_URL= POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres GEONODE_DATABASE=geonode +GEONODE_DATABASE_USER=geonode GEONODE_DATABASE_PASSWORD=geonode GEONODE_GEODATABASE=geonode_data +GEONODE_GEODATABASE_USER=geonode_data GEONODE_GEODATABASE_PASSWORD=geonode_data GEONODE_DATABASE_SCHEMA=public GEONODE_GEODATABASE_SCHEMA=public @@ -195,7 +197,7 @@ BING_API_KEY= GOOGLE_API_KEY= # Monitoring -MONITORING_ENABLED=True +MONITORING_ENABLED=False MONITORING_DATA_TTL=365 USER_ANALYTICS_ENABLED=True USER_ANALYTICS_GZIP=True diff --git a/docker-compose.yml b/docker-compose.yml index 3af97ca2ebc..d87342e2ad4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ version: '3.9' x-common-django: &default-common-django image: geonode/geonode:latest-ubuntu-22.10 - restart: on-failure + restart: unless-stopped env_file: - .env volumes: @@ -50,7 +50,7 @@ services: # Nginx is serving django static and media files and proxies to django and geonode geonode: - image: geonode/nginx:4.0 + image: geonode/nginx:4.1.0 build: ./scripts/docker/nginx/ container_name: nginx4${COMPOSE_PROJECT_NAME} environment: @@ -67,11 +67,11 @@ services: - nginx-confd:/etc/nginx - nginx-certificates:/geonode-certificates - statics:/mnt/volumes/statics - restart: on-failure + restart: unless-stopped # Gets and installs letsencrypt certificates letsencrypt: - image: geonode/letsencrypt:4.0 + image: geonode/letsencrypt:4.1.0 build: ./scripts/docker/letsencrypt/ container_name: letsencrypt4${COMPOSE_PROJECT_NAME} environment: @@ -81,7 +81,7 @@ services: - LETSENCRYPT_MODE=${LETSENCRYPT_MODE} volumes: - nginx-certificates:/geonode-certificates - restart: on-failure + restart: unless-stopped # Geoserver backend geoserver: @@ -101,7 +101,7 @@ services: - backup-restore:/backup_restore - data:/data - tmp:/tmp - restart: on-failure + restart: unless-stopped depends_on: db: condition: service_healthy @@ -114,14 +114,14 @@ services: entrypoint: sleep infinity volumes: - geoserver-data-dir:/geoserver_data/data - restart: on-failure + restart: unless-stopped healthcheck: test: "ls -A '/geoserver_data/data' | wc -l" # PostGIS database. db: - # use geonode official postgis 13 image - image: geonode/postgis:13 + # use geonode official postgis 15 image + image: geonode/postgis:15 command: postgres -c "max_connections=${POSTGRESQL_MAX_CONNECTIONS}" container_name: db4${COMPOSE_PROJECT_NAME} env_file: @@ -129,7 +129,7 @@ services: volumes: - dbdata:/var/lib/postgresql/data - dbbackups:/pg_backups - restart: on-failure + restart: unless-stopped healthcheck: test: "pg_isready -d postgres -U postgres" # uncomment to enable remote connections to postgres @@ -138,11 +138,11 @@ services: # Vanilla RabbitMQ service. This is needed by celery rabbitmq: - image: rabbitmq:3.7-alpine + image: rabbitmq:3-alpine container_name: rabbitmq4${COMPOSE_PROJECT_NAME} volumes: - rabbitmq:/var/lib/rabbitmq - restart: on-failure + restart: unless-stopped volumes: statics: diff --git a/scripts/docker/base/ubuntu/Dockerfile b/scripts/docker/base/ubuntu/Dockerfile index 3b36cb6236f..13ca38d20c4 100644 --- a/scripts/docker/base/ubuntu/Dockerfile +++ b/scripts/docker/base/ubuntu/Dockerfile @@ -2,7 +2,7 @@ FROM ubuntu:22.10 RUN mkdir -p /usr/src/geonode -## Enable postgresql-client-13 +## Enable postgresql-client-15 RUN apt-get update -y && apt-get install curl wget unzip gnupg2 -y RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - # will install python3.10 @@ -17,7 +17,7 @@ RUN apt-get install -y \ RUN apt-get update -y && apt-get install -y --no-install-recommends \ gcc vim zip gettext geoip-bin cron \ - postgresql-client-13 \ + postgresql-client-15 \ python3-all-dev python3-dev \ python3-gdal python3-psycopg2 python3-ldap \ python3-pip python3-pil python3-lxml \ diff --git a/scripts/docker/letsencrypt/Dockerfile b/scripts/docker/letsencrypt/Dockerfile index a77f3b0219c..1480342e8f1 100644 --- a/scripts/docker/letsencrypt/Dockerfile +++ b/scripts/docker/letsencrypt/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.16 +FROM alpine:latest RUN apk add --no-cache certbot diff --git a/scripts/docker/nginx/Dockerfile b/scripts/docker/nginx/Dockerfile index 92652f376c6..0a497ce0384 100644 --- a/scripts/docker/nginx/Dockerfile +++ b/scripts/docker/nginx/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:1-alpine +FROM nginx:1.25.1-alpine RUN apk add --no-cache openssl inotify-tools diff --git a/start_django_async.sh b/start_django_async.sh index 78930d0377b..6b2d25eb84e 100755 --- a/start_django_async.sh +++ b/start_django_async.sh @@ -4,7 +4,7 @@ set -e export RESOURCE_PUBLISHING=True export ADMIN_MODERATE_UPLOADS=True export NOTIFICATION_ENABLED=True -export MONITORING_ENABLED=True +export MONITORING_ENABLED=False export EMAIL_ENABLED=True export BROKER_URL=amqp://guest:guest@localhost:5672/ export ASYNC_SIGNALS=True From 106c42f224c604990e0b027394fd785dcd31092f Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Mon, 31 Jul 2023 14:47:51 +0200 Subject: [PATCH 097/330] [Fixes #11080] Unable to add ArcGIS REST ImageServer as Remote Service (#11309) --- geonode/harvesting/harvesters/arcgis.py | 8 ++++---- requirements.txt | 2 +- setup.cfg | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/geonode/harvesting/harvesters/arcgis.py b/geonode/harvesting/harvesters/arcgis.py index c57202cc404..f64934f5b78 100644 --- a/geonode/harvesting/harvesters/arcgis.py +++ b/geonode/harvesting/harvesters/arcgis.py @@ -205,8 +205,8 @@ def _get_resource_descriptor( epsg_code, spatial_extent = _parse_spatial_extent(layer_representation["extent"]) ows_url = harvestable_resource.unique_identifier.rpartition("/")[0] store = slugify(ows_url) - name = layer_representation["id"] - title = layer_representation["name"] + name = layer_representation.get("id", layer_representation.get("name", "Undefined")) + title = layer_representation.get("name", layer_representation.get("title", "Undefined")) workspace = "remoteWorkspace" alternate = f"{workspace}:{name}" return resourcedescriptor.RecordDescription( @@ -307,8 +307,8 @@ def _get_resource_descriptor( epsg_code, spatial_extent = _parse_spatial_extent(layer_representation["extent"]) ows_url = harvestable_resource.unique_identifier.rpartition("/")[0] store = slugify(ows_url) - name = layer_representation["id"] - title = layer_representation["name"] + name = layer_representation.get("id", layer_representation.get("name", "Undefined")) + title = layer_representation.get("name", layer_representation.get("title", "Undefined")) workspace = "remoteWorkspace" alternate = f"{workspace}:{name}" return resourcedescriptor.RecordDescription( diff --git a/requirements.txt b/requirements.txt index 32be7fdb44d..208e17c1a7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -96,7 +96,7 @@ geonode-oauth-toolkit==2.2.2 geonode-user-messages==2.0.2 geonode-announcements==2.0.2 geonode-django-activity-stream==0.10.0 -gn-arcrest==10.5.5 +gn-arcrest==10.5.6 geoserver-restconfig==2.0.9 gn-gsimporter==2.0.4 gisdata==0.5.4 diff --git a/setup.cfg b/setup.cfg index f39b8c3d7b8..280ea55eed8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -121,7 +121,7 @@ install_requires = geonode-user-messages==2.0.2 geonode-announcements==2.0.2 geonode-django-activity-stream==0.10.0 - gn-arcrest==10.5.5 + gn-arcrest==10.5.6 geoserver-restconfig==2.0.9 gn-gsimporter==2.0.4 gisdata==0.5.4 From a9d04f6e4911fdb68ac44359786ba90870ffdcc0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Jul 2023 16:04:08 +0200 Subject: [PATCH 098/330] Bump django-sequences from 2.7 to 2.8 (#11301) * Bump django-sequences from 2.7 to 2.8 Bumps [django-sequences](https://github.com/aaugustin/django-sequences) from 2.7 to 2.8. - [Commits](https://github.com/aaugustin/django-sequences/compare/2.7...2.8) --- updated-dependencies: - dependency-name: django-sequences dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 208e17c1a7f..2678b560326 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,7 +47,7 @@ django-tinymce==3.6.1 django-grappelli==3.0.6 django-uuid-upload-path==1.0.0 django-widget-tweaks==1.4.12 -django-sequences==2.7 +django-sequences==2.8 oauthlib==3.2.2 pyopenssl==23.2.0 pyjwt==2.8.0 diff --git a/setup.cfg b/setup.cfg index 280ea55eed8..49f59b5ba56 100644 --- a/setup.cfg +++ b/setup.cfg @@ -73,7 +73,7 @@ install_requires = django-grappelli==3.0.6 django-uuid-upload-path==1.0.0 django-widget-tweaks==1.4.12 - django-sequences==2.7 + django-sequences==2.8 oauthlib==3.2.2 pyopenssl==23.2.0 pyjwt==2.8.0 From bf116689a188312acba09d4f0a7d298e0bbf55bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Jul 2023 16:04:44 +0200 Subject: [PATCH 099/330] Bump boto3 from 1.28.9 to 1.28.15 (#11304) * Bump boto3 from 1.28.9 to 1.28.15 Bumps [boto3](https://github.com/boto/boto3) from 1.28.9 to 1.28.15. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.28.9...1.28.15) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2678b560326..d5f73f708cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 -boto3==1.28.9 +boto3==1.28.15 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index 49f59b5ba56..2dad8c7d0f3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 - boto3==1.28.9 + boto3==1.28.15 # Django Caches python-memcached<=1.59 From 4eb1eacb3e80e1058d4bc7ff135684029cf0307e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Jul 2023 16:05:06 +0200 Subject: [PATCH 100/330] Bump flake8 from 6.0.0 to 6.1.0 (#11307) * Bump flake8 from 6.0.0 to 6.1.0 Bumps [flake8](https://github.com/pycqa/flake8) from 6.0.0 to 6.1.0. - [Commits](https://github.com/pycqa/flake8/compare/6.0.0...6.1.0) --- updated-dependencies: - dependency-name: flake8 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index d5f73f708cf..920bd63847c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -152,7 +152,7 @@ invoke==2.2.0 # tests coverage==7.2.7 requests-toolbelt==1.0.0 -flake8==6.0.0 +flake8==6.1.0 black==23.7.0 pytest==7.4.0 pytest-bdd==6.1.1 diff --git a/setup.cfg b/setup.cfg index 2dad8c7d0f3..1a03291ef93 100644 --- a/setup.cfg +++ b/setup.cfg @@ -177,7 +177,7 @@ install_requires = # tests coverage==7.2.7 requests-toolbelt==1.0.0 - flake8==6.0.0 + flake8==6.1.0 black==23.7.0 pytest==7.4.0 pytest-bdd==6.1.1 From 32ac5ad4cf6a33594cc17fca6e18a2f3bc57b21d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Jul 2023 16:06:00 +0200 Subject: [PATCH 101/330] Bump webdriver-manager from 3.9.1 to 4.0.0 (#11306) * Bump webdriver-manager from 3.9.1 to 4.0.0 Bumps [webdriver-manager](https://github.com/SergeyPirogov/webdriver_manager) from 3.9.1 to 4.0.0. - [Release notes](https://github.com/SergeyPirogov/webdriver_manager/releases) - [Changelog](https://github.com/SergeyPirogov/webdriver_manager/blob/master/CHANGELOG.md) - [Commits](https://github.com/SergeyPirogov/webdriver_manager/compare/v3.9.1...v4.0.0) --- updated-dependencies: - dependency-name: webdriver-manager dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 920bd63847c..d59ded3314c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -167,7 +167,7 @@ factory-boy==3.3.0 flaky==3.7.0 selenium>=4.1.0,<5.0.0 selenium-requests==2.0.3 -webdriver_manager==3.9.1 +webdriver_manager==4.0.0 # Security and audit mistune==3.0.1 diff --git a/setup.cfg b/setup.cfg index 1a03291ef93..81e041141e0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -192,7 +192,7 @@ install_requires = flaky==3.7.0 selenium>=4.1.0,<5.0.0 selenium-requests==2.0.3 - webdriver_manager==3.9.1 + webdriver_manager==4.0.0 # Security and audit mistune==3.0.1 From a2e1a18567a8ae521253ef4645f69da8ca3efced Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Jul 2023 16:34:27 +0200 Subject: [PATCH 102/330] Bump uwsgi from 2.0.21 to 2.0.22 (#11305) * Bump uwsgi from 2.0.21 to 2.0.22 Bumps [uwsgi](https://github.com/unbit/uwsgi-docs) from 2.0.21 to 2.0.22. - [Commits](https://github.com/unbit/uwsgi-docs/commits) --- updated-dependencies: - dependency-name: uwsgi dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index d59ded3314c..ea94c549363 100644 --- a/requirements.txt +++ b/requirements.txt @@ -143,7 +143,7 @@ django-ipware<5.1 pycountry # production -uWSGI==2.0.21 +uWSGI==2.0.22 gunicorn==21.2.0 ipython==8.14.0 docker==6.1.3 diff --git a/setup.cfg b/setup.cfg index 81e041141e0..2d495df23bd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -168,7 +168,7 @@ install_requires = pycountry # production - uWSGI==2.0.21 + uWSGI==2.0.22 gunicorn==21.2.0 ipython==8.14.0 docker==6.1.3 From 99ced683ec679c5f1bc0c1fe101602cc9a193638 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Jul 2023 16:35:20 +0200 Subject: [PATCH 103/330] Bump markdown from 3.4.3 to 3.4.4 (#11303) * Bump markdown from 3.4.3 to 3.4.4 Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.4.3 to 3.4.4. - [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/change_log/release-2.6.md) - [Commits](https://github.com/Python-Markdown/markdown/compare/3.4.3...3.4.4) --- updated-dependencies: - dependency-name: markdown dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index ea94c549363..a8700f0a047 100644 --- a/requirements.txt +++ b/requirements.txt @@ -82,7 +82,7 @@ drf-extensions==0.7.1 drf-writable-nested==0.7.0 drf-spectacular==0.26.4 dynamic-rest==2.1.2 -Markdown==3.4.3 +Markdown==3.4.4 pinax-notifications==6.0.0 pinax-ratings==4.0.0 diff --git a/setup.cfg b/setup.cfg index 2d495df23bd..882a2e505ff 100644 --- a/setup.cfg +++ b/setup.cfg @@ -108,7 +108,7 @@ install_requires = drf-writable-nested==0.7.0 drf-spectacular==0.26.4 dynamic-rest==2.1.2 - Markdown==3.4.3 + Markdown==3.4.4 pinax-notifications==6.0.0 pinax-ratings==4.0.0 From d70b289ca37c8ff8ff275ea32e90b0f2939c9887 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Jul 2023 16:36:58 +0200 Subject: [PATCH 104/330] Bump wandb from 0.15.5 to 0.15.7 (#11302) * Bump wandb from 0.15.5 to 0.15.7 Bumps [wandb](https://github.com/wandb/wandb) from 0.15.5 to 0.15.7. - [Release notes](https://github.com/wandb/wandb/releases) - [Changelog](https://github.com/wandb/wandb/blob/main/CHANGELOG.md) - [Commits](https://github.com/wandb/wandb/compare/v0.15.5...v0.15.7) --- updated-dependencies: - dependency-name: wandb dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index a8700f0a047..10805de234a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -171,7 +171,7 @@ webdriver_manager==4.0.0 # Security and audit mistune==3.0.1 -wandb==0.15.5 +wandb==0.15.7 protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/setup.cfg b/setup.cfg index 882a2e505ff..7668acc79c2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -196,7 +196,7 @@ install_requires = # Security and audit mistune==3.0.1 - wandb==0.15.5 + wandb==0.15.7 protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability From 3c5729e24fef14c8da0dc4d9575d91a8946d2c21 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Tue, 1 Aug 2023 12:44:37 +0200 Subject: [PATCH 105/330] [Fixes #11312] Expose dataset ows url (#11313) * expose dataser ows url * Formatting --- geonode/geoserver/helpers.py | 16 ++++++++++++++++ geonode/geoserver/tests/test_helpers.py | 12 ++++++++++++ geonode/layers/api/serializers.py | 1 + geonode/layers/models.py | 6 ++++++ 4 files changed, 35 insertions(+) diff --git a/geonode/geoserver/helpers.py b/geonode/geoserver/helpers.py index c5d4742b6cd..cc9135d243f 100755 --- a/geonode/geoserver/helpers.py +++ b/geonode/geoserver/helpers.py @@ -2296,6 +2296,22 @@ def get_dataset_capabilities_url(layer, version="1.3.0", access_token=None): return wms_url +def get_layer_ows_url(layer, access_token=None): + """ + Generate the layer-specific GetCapabilities URL + """ + workspace_layername = layer.alternate.split(":") if ":" in layer.alternate else ("", layer.alternate) + ows_url = settings.GEOSERVER_PUBLIC_LOCATION + if not layer.remote_service: + ows_url = f"{ows_url}{'/'.join(workspace_layername)}/ows" # noqa + if access_token: + ows_url += f"?access_token={access_token}" + else: + ows_url = f"{layer.remote_service.service_url}" + + return ows_url + + def wps_format_is_supported(_format, dataset_type): return (_format, dataset_type) in WPS_ACCEPTABLE_FORMATS diff --git a/geonode/geoserver/tests/test_helpers.py b/geonode/geoserver/tests/test_helpers.py index 9c71711511a..cf841bdf457 100644 --- a/geonode/geoserver/tests/test_helpers.py +++ b/geonode/geoserver/tests/test_helpers.py @@ -40,6 +40,7 @@ get_dataset_storetype, extract_name_from_sld, get_dataset_capabilities_url, + get_layer_ows_url, ) from geonode.geoserver.ows import _wcs_link, _wfs_link, _wms_link @@ -295,3 +296,14 @@ def test_dataset_capabilties_url(self): expected_url = f"{ows_url}geonode/CA/ows?service=wms&version=1.3.0&request=GetCapabilities" capabilities_url = get_dataset_capabilities_url(dataset) self.assertEqual(capabilities_url, expected_url, capabilities_url) + + @on_ogc_backend(geoserver.BACKEND_PACKAGE) + def test_layer_ows_url(self): + from geonode.layers.models import Dataset + + ows_url = settings.GEOSERVER_PUBLIC_LOCATION + identifier = "geonode:CA" + dataset = Dataset.objects.get(alternate=identifier) + expected_url = f"{ows_url}geonode/CA/ows" + capabilities_url = get_layer_ows_url(dataset) + self.assertEqual(capabilities_url, expected_url, capabilities_url) diff --git a/geonode/layers/api/serializers.py b/geonode/layers/api/serializers.py index fd8736fc4eb..270c469aa82 100644 --- a/geonode/layers/api/serializers.py +++ b/geonode/layers/api/serializers.py @@ -173,6 +173,7 @@ class Meta: "featureinfo_custom_template", "ows_url", "capabilities_url", + "dataset_ows_url", "workspace", "default_style", "styles", diff --git a/geonode/layers/models.py b/geonode/layers/models.py index 11612ed8f09..0d6c425e8e0 100644 --- a/geonode/layers/models.py +++ b/geonode/layers/models.py @@ -261,6 +261,12 @@ def capabilities_url(self): return get_dataset_capabilities_url(self) + @property + def dataset_ows_url(self): + from geonode.geoserver.helpers import get_layer_ows_url + + return get_layer_ows_url(self) + @property def embed_url(self): try: From 2b09aa6df2f2f51f051a8842b50f7fe2cc120c2a Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Fri, 4 Aug 2023 18:48:02 +0200 Subject: [PATCH 106/330] [Fixes #11323] Align the Position labels and translations (#11324) * Align the Position labels and translations * Improvement to it translation --- geonode/locale/en/LC_MESSAGES/django.mo | Bin 143913 -> 143908 bytes geonode/locale/en/LC_MESSAGES/django.po | 2 +- geonode/locale/it/LC_MESSAGES/django.mo | Bin 161119 -> 161121 bytes geonode/locale/it/LC_MESSAGES/django.po | 4 ++-- .../templates/people/profile_detail.html | 16 ++++++++-------- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/geonode/locale/en/LC_MESSAGES/django.mo b/geonode/locale/en/LC_MESSAGES/django.mo index f8663d06708af49835ebb15452c0cb68aec04c63..b7be4680ab464698552a6af0a9b8ec4582b4602f 100644 GIT binary patch delta 5371 zcmXZfd309A8OQN?ZxTod2@t{-mNx;iK{j?RAWI{H98)M&Koa&%S*$G<4C%RrfW%M= z2Z2&V4T=N{q1YBsko(4OTu!57?x03`u)8#e|=`=y?2&po|&6`a4~Z8 z#mJrUL;Xl&%yVA_Ogv7&B{&bG@g6q9h|>Yn0-It(OhLVu2E=Ck5OLNH)C!wY4`L$bU;`Y9n(!DIFlG+wxy9$~ zdv9PA^*1pV-$yOv&+hmA7(x9X=lpDi6FQ1*@pIJ7 zYf(87`CmIg2W&$<6E(r7P!s$SYGPxYFQ6t=h~$cIUZ|YZ;ntVKDy@gt7KwM}@is73x)}3I7g*co;Ro8dN{up%!@08FMLMd^IFdP{+Mcp&NqQ z;}=mKEJEeLa#RG?Ip0G~U?(QyN2skh?|#3DirfQKZbV(S{d7W2FzYhsUl&Fm4VuUt z)Jhg%Dwbjx{t-3sZd7Crxca|Qr=$us0k1k>dSM!B0pn5qzJluabqwRaSdH4kbv3@7 z(Z-s9=}1F4&cvhG7kgX@m>hh`S%%%HpFm9{?5a)5=BO3r;FCB4wXk)l!r+S662mHifVI?7NT??Xl4AWp=ysOS4% zXF(qK08S?o{wv?wfzRFunASXS4V44oH|;4%!))rgs0qzOW&0x33fG`Q`6tu@K1N+E zhh6&xR1RHn*17fv$b@_oR_h{wnqeHOqc*NR4K=YG=P=YtCu0WssME6@m82iy2K*G2 zJEOm|TQv^V?<~~wg_xl8?^95gZoqWhg5B{rW@1=fzzo1{sO)|jyI?U6!yTxI)uVE# z-K~J>i~~^hsW=dqq4xeTCgOPvGQN34LCF!kZIfsaDw`*vLc9!hV{LK2|J~Iu;$+&r zJ2uItp(b94O415kk6&XSTyWPeaHsP$hQ9yd^>)A*T*QMtQ0H|Qs>5TbIJBYoky+oJ`Ts=|7=8NVF~q>_!hSQ-Y#@E_M?94d(OX-qs4uDWd>1ulZm>5yQB6r z50&L(QOPzL6}g!>4QsGB<~^{vu@s-B{w|hcJ!+idhjz34A=Y^6z#;2gK zUOeh}WuOLn9CdLFMI}uE>U8`Zb@3FUB29RE!#M4W?l^YMkS! zh+Rgu(l<9K=)v2z!9+&biNrcPqCTg+Q4z{TbuMe1o+&quvC6?JOn;2|tSW&ezZ zp7{etM0#cm?!tU!aekB+`fh)W3S|u{N$OD@hBfj+AD70c6=&cxI0zN$m8gl8VI^)s z<;uKhFO>BQQT;B(Ag)FAy9@o$UQW4+K-`wP?+yxGY1)M0-wbq{2`viq1dvSbwV@WGrB^z(x96xI?i^Sj+*IcOvA;P zh}%$C>Sw5qrZxA>JGcWi@w|Aul?zbya(o<5pmHL*g=fa%6R3!<_bF(m75D@E3R5t? zrL`}1qdplmku|8RcAsmnLS=niD=&0$Wum@{<58z%Ici~>@JZZ{>OX?pPC4UeP|)YK zJ1Uumqdq$M&V{HMZ@?V9cm6*t%~XQc$wR zCRp3!IO-XwV^@Ou-mgN1vI4oOO*QI#U&Tng<9vh~D6*}+AzPzD-4k`e4MW{G1-MXO zk)Kj{iH7T_D>c8JXFr#y5bZ>TcsJ_!oIp*i7Te%G)Lu7lZ%;{oR75AC-YZ0H*;>^5 zAG!8Z_!#4xItuzYWOT3(a|xY#w6pps=3Y63f4`$5zVS&53!X;iMakoKjh`@nRv zdz*#HR0pBHt}{@l$H%8|D{7%PJNY&=v7K$mTA}_jNkxThDkkC_)JlJYio{0L03V=M zd<^wfyo}23V6weGdZA9mL{t(_MJ4YdR0Njz6cp0sH~`C0FI1ymxQ5#M$P}AoF{o3} z2XzI%jQV{Ys^d3NIr27Y3$~*A`_%pZS5y)nKy8hGfr66ZwtL_KYGx+Ywl_p|+yr$j z<4}>A>wce)nt+e5;S$uuqtk5U(oh2q!_oKx>VEkfaw>dNML~CTSh{^-EGi;}sJ&l@ z8t^LWt5}EHiifC)H|}C{BObLS37ColQKw@jYReX*&izW%)&4Fv(D|>Z!F2Lt-FKW-@vTPEjU>5a$sN|f5`j%`)<=PkM zD|=gJ+ZQuYNihf&%2B9fdKGoU6{G%0LB9B+Im)Ej9~lDzL8Jcx$kh#IeU5c-95>p`wQ0Z zFWB7rqO>SUl1cAnlGb=N*5NI92A;-3EPOwcoQY>*K2~4>4vh8lup{*gu@lzB_F9}n z{Vtq|$FUx#?#?8RdvJFq&2@O3hTMSIf*%S$2m^J%66)nxhJ&yPPC_R!HMzji@@Gx|tW6|*@VLMD`Qt*XZ?2Sv}16$A+UPdSMCbq}- z(U~7dbE4?uFhDnKOML)3!JnWL{3#ltsnP4v38hG`q{%%L-1ALXiZ6s8l6TP+ACJHP zfE}n8e-b*bKv#G+`u=cqt1iQCI33-xI&{GM(JftvChKPG#Q4d-DA>^vbWcy9D=pfW zNvg0n`uk)wB3GjmyAkd1*XTgCXlR$C6L}Ob!Y#3W63wlq`@;l^F^}<+z80_=o#{Df zN2AcKnHbxrprQOlbO9RbrD&*EpcDQBcE%m(1dpKoe1|UZbhPDxOp@BrgMu9oLqj(X z-Qye34i=#~a4#BxRnf=M2|SJ6@i}xW_Ql`7MI)E}G~`Ax+D|WZf&)M0{CizMR6x(_J>nNC|tFbq3zyY`mE3x34OmaS+gJ$>5*aw&3Slo(6 z>=c?qU5;gva=Z|&&&J_+H@f#bunhNMXU0$Rz75GyjwaD)G@EClA-)^Eu{OltUyt?A zv4-~i;~~keLnof1NxB6e!2LKF7k(EO_;hqHrY29*hA?1Dtff8#z00?u9lnho$NUqa z-Vg7fUV~0-AG*@a_nBlImZK4whf8odK7<`lhJ|j&^QiAR$@w=q+Wio&%yM*Z2B25) zV02F>pc9>jCfh7Da&z$-Jc8%ogi|3m>Tx3V$FLqxq2nw$9k%Q~w7(BdbN=1a12nYB zaz&!&y(E*(4KxC;pgsmoo_Q{<1+tZCa*Toxejgf=qQ+q&t)e~A&*`~ngvO#BO+q6y2ko#Hy?E-;=|=h09eL6hVZ+F?OqHg_+yKv!Ii7vpF&)XUL{t;HR< z0nL?L&dBDn{tmR?dh~vI5bbvxrn$YQV8^@A>^+1g;g@IxPNC=djAq$nJodwKydCZ6 z5xf&OqF>GaMIi!nqN}kx?K`m#HpF_j;%u6`sm2y(b64$c=oP#^*8dahndag53N-mH zkM;YoAN3cbNAYaxWhL390w>}`ti=oPUAzd}w+LR{BF!dVp_^#%CM#(f2C715It6>- zVl2bW=#}~w+R=5bvdL=PicWk&>#&sz(fURlitnH~QF3NBxg0M*BmPiI!I^HsVfZ2T zz_w=vhhsJMS?EMoqF3$9vHbv=^`)iR+{HBj{VHCCo|1dfg{{L;_zK#8A-A15lU7q` zLt!wQOqZe`of*+P&>64B{&*tR`?L*vUxy~wQZ#oSimr*S#euZ1N1xk+PV7jir%9uB zA=z3*yW*8RP>mkDrRev51scjN$W5IbM$h+GSb!&^dF{hMMd%IL5e@ZF^nx3Y-ZwRP zn_rP%QMiGIuhAD~bjXI!B^siq(GYJ(kIy^k#ExTIJdN&kn~vd>j6@?k6MZj5x9mam z{pVu)9vs5>NdpBx4%MB)0~63I_a=0pdh}wt51Zos=vJ&jC$JNJ?l5{9j-jD0?;MWr z*=U4DqFXTzeLlt16)uhqOVMOmflgp+Y<~m2dUv7`+KcAON%VebRu&@F4t=i-4fSC3 zei?_C;Z(dHH=`F+=PsOoC(xryI39g*I`yBUr{YDNfQQlNhIb8-8XdhBZ>7B+E3t96 zu(t!TJN41%*L4nhdg^ctZbBFOZMQUprd4?eSqJnllfG!kW@8!7M_2kgG!kpj0se%p z_-*uJ`U1`F^6ugO7>1sTYtST~jVA9RGy->}6b$LTcs_1KUpS1ua1`D9q8=g1TB4`m zJoE~_8U1|~+VR6^jy!^H!6vl7m*Ve#N0ab1bZgRs6ikNi;{(~AVP=id_GW0ut?^1M zMI&=l{CxpBfjV4-ccBw6=@lYZi4Hg(e}>ni_sgrusYsIp6uhGgdWRRLp%F>Zy>6nXd*<$qEFGsKT$FPa#e`l_cB=5xsK0*il z293ZE=tYxP5e6FRZu}AU=^Ng^7JYvnI)Me~abJeHzyF`2;0oSC zSNJZP9G{?j_c?l@96<+cQW@%F(Sd7l626G;d1+Nh!V0XSJ_1e7dFZ!fJ(_E~F*SSJ z_X{r$K$BuL8p_FNGTn~ea7)mCB(|VQcnF<%dH)d7K4`8?MB68$E1!nu#C2#y7o+1W z>z|#L3-uZrOp+(!gKuCN_0P~33aaDh8I4dCxN3G=M diff --git a/geonode/locale/en/LC_MESSAGES/django.po b/geonode/locale/en/LC_MESSAGES/django.po index ae445ae92c7..d6ce030a65a 100644 --- a/geonode/locale/en/LC_MESSAGES/django.po +++ b/geonode/locale/en/LC_MESSAGES/django.po @@ -3982,7 +3982,7 @@ msgid "introduce yourself" msgstr "introduce yourself" msgid "Position Name" -msgstr "Position Name" +msgstr "Position" msgid "role or position of the responsible person" msgstr "role or position of the responsible person" diff --git a/geonode/locale/it/LC_MESSAGES/django.mo b/geonode/locale/it/LC_MESSAGES/django.mo index 0a9e88e7f7d43b2150075b76272bf4703f194b8f..ca8e5e1987454996ec2d980155ea22c053d2d976 100644 GIT binary patch delta 7343 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 delta 7341 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 diff --git a/geonode/locale/it/LC_MESSAGES/django.po b/geonode/locale/it/LC_MESSAGES/django.po index f698c7205b5..8791b1cfec4 100644 --- a/geonode/locale/it/LC_MESSAGES/django.po +++ b/geonode/locale/it/LC_MESSAGES/django.po @@ -786,7 +786,7 @@ msgstr "" "controllo, zone postali e servizi, nomi di luoghi." msgid "Location" -msgstr "Posizione" +msgstr "Recapito" msgid "" "Features and characteristics of salt water bodies (excluding inland waters). Examples: " @@ -3775,7 +3775,7 @@ msgid "introduce yourself" msgstr "presentarsi" msgid "Position Name" -msgstr "Ruolo" +msgstr "Incarico" msgid "role or position of the responsible person" msgstr "ruolo o posizione della persona responsabile" diff --git a/geonode/people/templates/people/profile_detail.html b/geonode/people/templates/people/profile_detail.html index 0d6a48269e8..1a3c09ef7f1 100644 --- a/geonode/people/templates/people/profile_detail.html +++ b/geonode/people/templates/people/profile_detail.html @@ -73,14 +73,18 @@

    {{ profile.first_name|default:profile.name_long }}

    {% endif %} {% endif %} - - {% trans 'Position' %} - {{ profile.position | default:_('Not provided.') }} - {% trans 'Organization' %} {{ profile.organization | default:_('Not provided.') }} + + {% trans 'Description' %} + {{ profile.profile | default:_('Not provided.') }} + + + {% trans 'Position Name' %} + {{ profile.position | default:_('Not provided.') }} + {% trans 'Location' %} {{ profile.location | default:_('Not provided.') }} @@ -97,10 +101,6 @@

    {{ profile.first_name|default:profile.name_long }}

    {% trans 'Fax' %} {{ profile.fax | default:_('Not provided.') }} - - {% trans 'Description' %} - {{ profile.profile | default:_('Not provided.') }} - {% trans 'Keywords' %} From 84b27ae1b54b3f828b1e29fdcf7750120bcf3d75 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Mon, 7 Aug 2023 17:58:17 +0200 Subject: [PATCH 107/330] use bracket notation inside the default feature template (#11343) --- geonode/layers/api/serializers.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/geonode/layers/api/serializers.py b/geonode/layers/api/serializers.py index 270c469aa82..3d39fdefe20 100644 --- a/geonode/layers/api/serializers.py +++ b/geonode/layers/api/serializers.py @@ -88,20 +88,20 @@ def get_attribute(self, instance): else: _attributes = instance.attributes.filter(visible=True).order_by("display_order") if _attributes.exists(): - _template = "
    " + _template = '
    ' for _field in _attributes: _label = _field.attribute_label or _field.attribute _template += '
    ' if _field.featureinfo_type == Attribute.TYPE_HREF: _template += ( '
    %s:
    \ -
    ' + ' % (_label, _field, _field) ) elif _field.featureinfo_type == Attribute.TYPE_IMAGE: _template += ( '
    \ - %s
    ' + %s
    ' % (_field.attribute, _field.attribute, _label, _label) ) elif _field.featureinfo_type in ( @@ -115,32 +115,32 @@ def get_attribute(self, instance): if "youtube" in _field.featureinfo_type: _template += ( '
    \ -
    ' +
    ' % (_field.attribute) ) else: _type = f"video/{_field.featureinfo_type[11:]}" _template += ( '
    \ -
    ' +
    ' % (_field.attribute, _type) ) elif _field.featureinfo_type == Attribute.TYPE_AUDIO: _template += ( '
    \ -
    ' +
    ' % (_field.attribute) ) elif _field.featureinfo_type == Attribute.TYPE_IFRAME: _template += ( '
    \ -
    ' +
    ' % (_field.attribute) ) elif _field.featureinfo_type == Attribute.TYPE_PROPERTY: _template += ( '
    %s:
    \ -
    ${properties.%s}
    ' +
    ${properties[\'%s\']}
    ' % (_label, _field.attribute) ) _template += "
    " From 1c8df230c1b44e8631dc8f6b0bec7e18e6d24685 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Mon, 7 Aug 2023 18:00:27 +0200 Subject: [PATCH 108/330] remove Django admin widgets for bbox editable fields (#11339) --- geonode/documents/admin.py | 2 ++ geonode/geoapps/admin.py | 2 ++ geonode/layers/admin.py | 3 ++- geonode/maps/admin.py | 2 ++ geonode/services/admin.py | 1 + 5 files changed, 9 insertions(+), 1 deletion(-) diff --git a/geonode/documents/admin.py b/geonode/documents/admin.py index 36511c7d331..9af881f0e6d 100644 --- a/geonode/documents/admin.py +++ b/geonode/documents/admin.py @@ -36,6 +36,7 @@ class Meta(ResourceBaseAdminForm.Meta): class DocumentAdmin(TabbedTranslationAdmin): + exclude = ("ll_bbox_polygon", "bbox_polygon", "srid") list_display = ("id", "title", "date", "category", "group", "is_approved", "is_published", "metadata_completeness") list_display_links = ("id",) list_editable = ("title", "category", "group", "is_approved", "is_published") @@ -55,6 +56,7 @@ class DocumentAdmin(TabbedTranslationAdmin): "is_approved", "is_published", ) + readonly_fields = ("geographic_bounding_box",) date_hierarchy = "date" form = DocumentAdminForm actions = [metadata_batch_edit] diff --git a/geonode/geoapps/admin.py b/geonode/geoapps/admin.py index f0217db4cb2..33c8ff2f7c6 100644 --- a/geonode/geoapps/admin.py +++ b/geonode/geoapps/admin.py @@ -32,6 +32,7 @@ class Meta(ResourceBaseAdminForm.Meta): class GeoAppAdmin(TabbedTranslationAdmin): + exclude = ("ll_bbox_polygon", "bbox_polygon", "srid") list_display_links = ("title",) list_display = ( "id", @@ -65,6 +66,7 @@ class GeoAppAdmin(TabbedTranslationAdmin): "is_approved", "is_published", ) + readonly_fields = ("geographic_bounding_box",) form = GeoAppAdminForm def delete_queryset(self, request, queryset): diff --git a/geonode/layers/admin.py b/geonode/layers/admin.py index 379d361943b..7cb712206d1 100644 --- a/geonode/layers/admin.py +++ b/geonode/layers/admin.py @@ -55,6 +55,7 @@ class Meta(ResourceBaseAdminForm.Meta): class DatasetAdmin(TabbedTranslationAdmin): + exclude = ("ll_bbox_polygon", "bbox_polygon", "srid") list_display = ( "id", "alternate", @@ -86,7 +87,7 @@ class DatasetAdmin(TabbedTranslationAdmin): search_fields = ("alternate", "title", "abstract", "purpose", "is_approved", "is_published", "state") filter_horizontal = ("contacts",) date_hierarchy = "date" - readonly_fields = ("uuid", "alternate", "workspace") + readonly_fields = ("uuid", "alternate", "workspace", "geographic_bounding_box") inlines = [AttributeInline] form = DatasetAdminForm actions = [metadata_batch_edit] diff --git a/geonode/maps/admin.py b/geonode/maps/admin.py index 6be72dd9982..da6f8bf74a1 100644 --- a/geonode/maps/admin.py +++ b/geonode/maps/admin.py @@ -44,6 +44,7 @@ class MapAdmin(TabbedTranslationAdmin): inlines = [ MapLayerInline, ] + exclude = ("ll_bbox_polygon", "bbox_polygon", "srid") list_display_links = ("title",) list_display = ( "id", @@ -78,6 +79,7 @@ class MapAdmin(TabbedTranslationAdmin): "is_approved", "is_published", ) + readonly_fields = ("geographic_bounding_box",) form = MapAdminForm actions = [metadata_batch_edit] diff --git a/geonode/services/admin.py b/geonode/services/admin.py index d447c4145f0..2e61a21ce3e 100644 --- a/geonode/services/admin.py +++ b/geonode/services/admin.py @@ -31,6 +31,7 @@ class Meta(ResourceBaseAdminForm.Meta): class ServiceAdmin(admin.ModelAdmin): + exclude = ("ll_bbox_polygon", "bbox_polygon", "srid") list_display = ("id", "name", "base_url", "type", "method") list_display_links = ("id", "name") list_filter = ("id", "name", "type", "method") From 95eb82e8c79146dbe49ca2146fa405defc012732 Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Mon, 7 Aug 2023 18:03:40 +0200 Subject: [PATCH 109/330] [Fixes #11332] Remove geoip task (#11336) * [Fixes #11332] Remove geoip task * [Fixes #11332] Rollback requirements.txt * [Fixes #11332] Rollback setuo.cfg --- entrypoint.sh | 1 - geonode/monitoring/models.py | 33 --------------------------------- pavement.py | 18 ------------------ tasks.py | 7 ------- 4 files changed, 59 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index 4e394cdc703..620976448cc 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -54,7 +54,6 @@ else invoke prepare if [ ${FORCE_REINIT} = "true" ] || [ ${FORCE_REINIT} = "True" ] || [ ! -e "/mnt/volumes/statics/geonode_init.lock" ]; then - invoke updategeoip invoke fixtures invoke monitoringfixture invoke initialized diff --git a/geonode/monitoring/models.py b/geonode/monitoring/models.py index bbef043b228..18f302c73dc 100644 --- a/geonode/monitoring/models.py +++ b/geonode/monitoring/models.py @@ -701,39 +701,6 @@ def from_geonode(cls, service, request, response): sensitive_data = cls._get_user_data_gn(request) event_type = cls._get_event_type(request) - # ua = request.META.get('HTTP_USER_AGENT') or '' - # ua_family = cls._get_ua_family(ua) - # - # ip, is_routable = get_client_ip(request) - # lat = lon = None - # country = region = city = None - # if ip and is_routable: - # ip = ip.split(':')[0] - # if settings.TEST and ip == 'testserver': - # ip = '127.0.0.1' - # try: - # ip = gethostbyname(ip) - # except Exception as err: - # pass - # - # geoip = get_geoip() - # try: - # client_loc = geoip.city(ip) - # except Exception as err: - # log.warning("Cannot resolve %s: %s", ip, err) - # client_loc = None - # - # if client_loc: - # lat, lon = client_loc['latitude'], client_loc['longitude'], - # country = client_loc.get( - # 'country_code3') or client_loc['country_code'] - # if len(country) == 2: - # _c = pycountry.countries.get(alpha_2=country) - # country = _c.alpha_3 - # - # region = client_loc['region'] - # city = client_loc['city'] - data = { "received": received, "created": created, diff --git a/pavement.py b/pavement.py index f27d9d3c5de..5a494f124f4 100644 --- a/pavement.py +++ b/pavement.py @@ -261,10 +261,6 @@ def static(options): ) def setup(options): """Get dependencies and prepare a GeoNode development environment.""" - - if MONITORING_ENABLED: - updategeoip(options) - info( "GeoNode development environment successfully set up." "If you have not set up an administrative account," @@ -334,20 +330,6 @@ def upgradedb(options): print(f"Upgrades from version {version} are not yet supported.") -@task -@cmdopts([("settings=", "s", "Specify custom DJANGO_SETTINGS_MODULE")]) -def updategeoip(options): - """ - Update geoip db - """ - if MONITORING_ENABLED: - settings = options.get("settings", "") - if settings and "DJANGO_SETTINGS_MODULE" not in settings: - settings = f"DJANGO_SETTINGS_MODULE={settings}" - - sh(f"{settings} python -W ignore manage.py updategeoip -o") - - @task @cmdopts([("settings=", "s", "Specify custom DJANGO_SETTINGS_MODULE")]) def sync(options): diff --git a/tasks.py b/tasks.py index f3d72a0fff2..5711ab991b9 100755 --- a/tasks.py +++ b/tasks.py @@ -427,13 +427,6 @@ def monitoringfixture(ctx): logger.error(f"ERROR installing monitoring fixture: {str(e)}") -@task -def updategeoip(ctx): - print("**************************update geoip*******************************") - if ast.literal_eval(os.environ.get("MONITORING_ENABLED", "False")): - ctx.run(f"django-admin.py updategeoip --settings={_localsettings()}", pty=True) - - @task def updateadmin(ctx): print("***********************update admin details**************************") From e00861b85a024b98ae05d50c4fb99811888bd06a Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Mon, 7 Aug 2023 18:06:44 +0200 Subject: [PATCH 110/330] [Fixes #11251] Extend MapLayer model with visualization properties (#11341) * [Fixes #11251] Extend MapLayer model with visualization properties * [Fixes #11251] Add test coverage --------- Co-authored-by: Giovanni Allegri --- geonode/maps/api/serializers.py | 10 +-- geonode/maps/api/tests.py | 73 +++++++++++++++++++ .../migrations/0043_auto_20230807_1234.py | 28 +++++++ geonode/maps/models.py | 5 ++ 4 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 geonode/maps/migrations/0043_auto_20230807_1234.py diff --git a/geonode/maps/api/serializers.py b/geonode/maps/api/serializers.py index 2d9c88330e3..382bdd20d0b 100644 --- a/geonode/maps/api/serializers.py +++ b/geonode/maps/api/serializers.py @@ -138,6 +138,9 @@ class Meta: "current_style", "dataset", "name", + "order", + "visibility", + "opacity", ) @@ -145,12 +148,7 @@ class SimpleMapLayerSerializer(serializers.ModelSerializer): class Meta: model = MapLayer name = "maplayer" - fields = ( - "pk", - "name", - "extra_params", - "current_style", - ) + fields = ("pk", "name", "extra_params", "current_style", "order", "visibility", "opacity") class MapSerializer(ResourceBaseSerializer): diff --git a/geonode/maps/api/tests.py b/geonode/maps/api/tests.py index d5a15323a43..8d21f79bc2b 100644 --- a/geonode/maps/api/tests.py +++ b/geonode/maps/api/tests.py @@ -111,6 +111,10 @@ def test_maps(self): self.assertTrue(len(response.data["map"]["data"]["map"]["layers"]) == 7) self.assertEqual(response.data["map"]["maplayers"][0]["extra_params"], {"foo": "bar"}) self.assertIsNotNone(response.data["map"]["maplayers"][0]["dataset"]) + self.assertEqual(response.data["map"]["maplayers"][0]["extra_params"], {"foo": "bar"}) + self.assertEqual(response.data["map"]["maplayers"][0]["visibility"], 1) + self.assertEqual(response.data["map"]["maplayers"][0]["order"], 0) + self.assertEqual(response.data["map"]["maplayers"][0]["opacity"], 1.0) def test_extra_metadata_included_with_param(self): resource = Map.objects.first() @@ -150,6 +154,36 @@ def test_patch_map(self): self.assertEqual(response_maplayer["current_style"], "some-style-first-layer") self.assertIsNotNone(response_maplayer["dataset"]) + def test_patch_map_with_extra_maplayer_info(self): + """ + Patch to maps// + """ + # Get Layers List (backgrounds) + resource = Map.objects.first() + url = reverse("maps-detail", kwargs={"pk": resource.pk}) + + data = { + "title": f"{resource.title}-edited", + "abstract": resource.abstract, + "data": DUMMY_MAPDATA, + "id": resource.id, + "maplayers": DUMMY_MAPLAYERS_DATA_WITH_EXTRA_INFO, + } + self.client.login(username="admin", password="admin") + response = self.client.patch(f"{url}?include[]=data", data=data, format="json") + + self.assertEqual(response.status_code, 200) + self.assertTrue(len(response.data) > 0) + self.assertTrue("data" in response.data["map"]) + self.assertTrue(len(response.data["map"]["data"]["map"]["layers"]) == 7) + response_maplayer = response.data["map"]["maplayers"][0] + self.assertEqual(response_maplayer["extra_params"], {"msId": "Stamen.Watercolor__0"}) + self.assertEqual(response_maplayer["current_style"], "some-style-first-layer") + self.assertEqual(response_maplayer["visibility"], False) + self.assertEqual(response_maplayer["order"], 99) + self.assertEqual(response_maplayer["opacity"], 1.3) + self.assertIsNotNone(response_maplayer["dataset"]) + @patch("geonode.maps.api.views.resolve_object") def test_patch_map_raise_exception(self, mocked_obj): """ @@ -204,6 +238,34 @@ def test_create_map(self): self.assertIsNotNone(response_maplayer["dataset"]) self.assertIsNotNone(response.data["map"]["thumbnail_url"]) + def test_create_map_with_extra_maplayer_info(self): + """ + Post to maps/ + """ + # Get Layers List (backgrounds) + url = reverse("maps-list") + + data = { + "title": "Some created map", + "data": DUMMY_MAPDATA, + "maplayers": DUMMY_MAPLAYERS_DATA_WITH_EXTRA_INFO, + } + self.client.login(username="admin", password="admin") + response = self.client.post(f"{url}?include[]=data", data=data, format="json") + + self.assertEqual(response.status_code, 201) + self.assertTrue(len(response.data) > 0) + self.assertTrue("data" in response.data["map"]) + self.assertTrue(len(response.data["map"]["data"]["map"]["layers"]) == 7) + response_maplayer = response.data["map"]["maplayers"][0] + self.assertEqual(response_maplayer["extra_params"], {"msId": "Stamen.Watercolor__0"}) + self.assertEqual(response_maplayer["current_style"], "some-style-first-layer") + self.assertIsNotNone(response_maplayer["dataset"]) + self.assertEqual(response_maplayer["visibility"], False) + self.assertEqual(response_maplayer["order"], 99) + self.assertEqual(response_maplayer["opacity"], 1.3) + self.assertIsNotNone(response.data["map"]["thumbnail_url"]) + DUMMY_MAPDATA = { "map": { @@ -365,3 +427,14 @@ def test_create_map(self): "name": "geonode:CA", } ] + +DUMMY_MAPLAYERS_DATA_WITH_EXTRA_INFO = [ + { + "extra_params": {"msId": "Stamen.Watercolor__0"}, + "current_style": "some-style-first-layer", + "name": "geonode:CA", + "opacity": 1.3, + "visibility": False, + "order": 99, + } +] diff --git a/geonode/maps/migrations/0043_auto_20230807_1234.py b/geonode/maps/migrations/0043_auto_20230807_1234.py new file mode 100644 index 00000000000..894c1e4ea5c --- /dev/null +++ b/geonode/maps/migrations/0043_auto_20230807_1234.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.19 on 2023-08-07 12:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('maps', '0042_remove_maplayer_styles'), + ] + + operations = [ + migrations.AddField( + model_name='maplayer', + name='opacity', + field=models.FloatField(default=1.0), + ), + migrations.AddField( + model_name='maplayer', + name='order', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='maplayer', + name='visibility', + field=models.BooleanField(default=True), + ), + ] diff --git a/geonode/maps/models.py b/geonode/maps/models.py index 650417aebd8..90b18bc98ab 100644 --- a/geonode/maps/models.py +++ b/geonode/maps/models.py @@ -262,6 +262,11 @@ class MapLayer(models.Model): local = models.BooleanField(default=False, blank=True) # True if this layer is served by the local geoserver + # Extend MapLayer model with visualization properties #11251 + order = models.IntegerField(default=0) + visibility = models.BooleanField(default=True) + opacity = models.FloatField(default=1.0) + @property def dataset_title(self): """ From 48613757ca48d92f49191fce6ba4632d91c4949b Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Tue, 8 Aug 2023 12:09:08 +0200 Subject: [PATCH 111/330] fix feature template test (#11351) --- geonode/layers/api/tests.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/geonode/layers/api/tests.py b/geonode/layers/api/tests.py index dc6475bfdcc..7361e5c744a 100644 --- a/geonode/layers/api/tests.py +++ b/geonode/layers/api/tests.py @@ -93,8 +93,8 @@ def test_datasets(self): self.assertIsNotNone(response.data["dataset"].get("featureinfo_custom_template")) self.assertEqual( response.data["dataset"].get("featureinfo_custom_template"), - '
    Name:
    \ -
    ${properties.name}
    ', + '
    Name:
    \ +
    ${properties[\'name\']}
    ', ) _dataset.featureinfo_custom_template = "
    Foo Bar
    " @@ -104,8 +104,8 @@ def test_datasets(self): self.assertIsNotNone(response.data["dataset"].get("featureinfo_custom_template")) self.assertEqual( response.data["dataset"].get("featureinfo_custom_template"), - '
    Name:
    \ -
    ${properties.name}
    ', + '
    Name:
    \ +
    ${properties[\'name\']}
    ', ) _dataset.use_featureinfo_custom_template = True From c24458220aa1301c446fa2454b435e525cf8dcde Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Tue, 8 Aug 2023 23:43:26 +0200 Subject: [PATCH 112/330] Bump version (#11359) * Bump version * set python 3.10 as minimum version --- geonode/__init__.py | 2 +- setup.cfg | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/geonode/__init__.py b/geonode/__init__.py index e221b120dab..9b1e32fbb4d 100644 --- a/geonode/__init__.py +++ b/geonode/__init__.py @@ -19,7 +19,7 @@ import os -__version__ = (4, 1, 0, "dev", 0) +__version__ = (4, 2, 0, "dev", 0) default_app_config = "geonode.apps.AppConfig" diff --git a/setup.cfg b/setup.cfg index 7668acc79c2..04f081e7ac5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,12 +14,12 @@ classifiers = Intended Audience :: Developers Operating System :: OS Independent Topic :: Internet :: WWW/HTTP - Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.10 [options] zip_safe = False include_package_data = True -python_requires = >= 3.7 +python_requires = >= 3.10 packages = find: setup_requires = setuptools From 3fa9a8690f4aa27282cd815224014bb6cbc6fb00 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 10 Aug 2023 18:19:45 +0200 Subject: [PATCH 113/330] [Fixes #11368] Fix spatial representation type parsing (#11369) * Fix spatial representation type parsing * improved code * fixed tests * fixed formatting --- geonode/base/fixtures/test_xml.xml | 2 +- geonode/catalogue/templates/catalogue/full_metadata.xml | 2 ++ geonode/layers/metadata.py | 4 +++- geonode/layers/tests.py | 4 ++-- geonode/resource/utils.py | 8 +------- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/geonode/base/fixtures/test_xml.xml b/geonode/base/fixtures/test_xml.xml index 34a2af08443..1b990340631 100755 --- a/geonode/base/fixtures/test_xml.xml +++ b/geonode/base/fixtures/test_xml.xml @@ -406,7 +406,7 @@ - + vector eng diff --git a/geonode/catalogue/templates/catalogue/full_metadata.xml b/geonode/catalogue/templates/catalogue/full_metadata.xml index 39bffae1d3c..955f81c3e55 100644 --- a/geonode/catalogue/templates/catalogue/full_metadata.xml +++ b/geonode/catalogue/templates/catalogue/full_metadata.xml @@ -436,9 +436,11 @@ + {% if layer.spatial_representation_type %} {{layer.spatial_representation_type.identifier}} + {% endif %} {{layer.language}} diff --git a/geonode/layers/metadata.py b/geonode/layers/metadata.py index e61577ff5b5..74aab2ce8a9 100644 --- a/geonode/layers/metadata.py +++ b/geonode/layers/metadata.py @@ -79,7 +79,9 @@ def iso2dict(exml): mdata = MD_Metadata(exml) identifier = mdata.identifier vals["language"] = mdata.language or mdata.languagecode or "eng" - vals["spatial_representation_type"] = mdata.hierarchy + if mdata.identification[0].spatialrepresentationtype: + vals["spatial_representation_type"] = mdata.identification[0].spatialrepresentationtype[0] + vals["date"] = sniff_date(mdata.datestamp) if hasattr(mdata, "identification"): diff --git a/geonode/layers/tests.py b/geonode/layers/tests.py index fbe14c340ba..d7420fc2a84 100644 --- a/geonode/layers/tests.py +++ b/geonode/layers/tests.py @@ -1631,7 +1631,7 @@ def test_set_metadata_return_expected_values_from_xml(self): "date": datetime.datetime(2021, 4, 9, 9, 0, 46), "language": "eng", "purpose": None, - "spatial_representation_type": "dataset", + "spatial_representation_type": "vector", "supplemental_information": "No information provided", "temporal_extent_end": None, "temporal_extent_start": None, @@ -1691,7 +1691,7 @@ def setUp(self): "date": datetime.datetime(2021, 4, 9, 9, 0, 46), "language": "eng", "purpose": None, - "spatial_representation_type": "dataset", + "spatial_representation_type": "vector", "supplemental_information": "No information provided", "temporal_extent_end": None, "temporal_extent_start": None, diff --git a/geonode/resource/utils.py b/geonode/resource/utils.py index ca7175c799b..b8e70a362cb 100644 --- a/geonode/resource/utils.py +++ b/geonode/resource/utils.py @@ -167,13 +167,7 @@ def update_resource( if vals: for key, value in vals.items(): if key == "spatial_representation_type": - spatial_repr = SpatialRepresentationType.objects.filter(identifier=value) - if value is not None and spatial_repr.exists(): - value = SpatialRepresentationType(identifier=value) - # if the SpatialRepresentationType is not available in the DB, we just set it as None - elif value is not None and not spatial_repr.exists(): - value = None - defaults[key] = value + defaults[key] = SpatialRepresentationType.objects.filter(identifier=value).first() if value else None elif key == "topic_category": value, created = TopicCategory.objects.get_or_create( identifier=value, defaults={"description": "", "gn_description": value} From c84f55ef2143b962729bda3b85f749ce15c2afdf Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Wed, 16 Aug 2023 11:32:36 +0200 Subject: [PATCH 114/330] =?UTF-8?q?[Fixes=20#11320]=20API=20V1=20delivers?= =?UTF-8?q?=20information=20on=20users=20that=20shouldn't=20be=E2=80=A6=20?= =?UTF-8?q?(#11321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fixes #11320] API V1 delivers information on users that shouldn't be visible * Fix black and flake8 * Fix black and flake8 * Fix black and flake8 * Fix black and flake8 * [Fixes #11320] API V1 delivers information on users that shouldn't be visible --------- Co-authored-by: Giovanni Allegri --- geonode/api/api.py | 10 ++++++++++ geonode/api/tests.py | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/geonode/api/api.py b/geonode/api/api.py index 0663176dec9..4303c0caa4b 100644 --- a/geonode/api/api.py +++ b/geonode/api/api.py @@ -584,6 +584,16 @@ class OwnersResource(TypeFilteredResource): full_name = fields.CharField(null=True) + def apply_filters(self, request, applicable_filters): + """filter by group if applicable by group functionality""" + + semi_filtered = super().apply_filters(request, applicable_filters) + + if request.user and not request.user.is_superuser: + semi_filtered = get_available_users(request.user) + + return semi_filtered + def dehydrate_full_name(self, bundle): return bundle.obj.get_full_name() or bundle.obj.username diff --git a/geonode/api/tests.py b/geonode/api/tests.py index e8fb96626bb..39a428c21fb 100644 --- a/geonode/api/tests.py +++ b/geonode/api/tests.py @@ -321,7 +321,7 @@ def test_owners_lockdown(self): self.api_client.client.login(username="bobby", password="bob") resp = self.api_client.get(filter_url) self.assertValidJSONResponse(resp) - self.assertEqual(len(self.deserialize(resp)["objects"]), 9) + self.assertEqual(len(self.deserialize(resp)["objects"]), 6) # Returns limitted info about other users bobby = get_user_model().objects.get(username="bobby") owners = self.deserialize(resp)["objects"] @@ -332,6 +332,12 @@ def test_owners_lockdown(self): self.assertIsNone(owner.get("email")) self.assertIsNone(owner.get("first_name")) + # now test with logged in admin + self.api_client.client.login(username="admin", password="admin") + resp = self.api_client.get(filter_url) + self.assertValidJSONResponse(resp) + self.assertEqual(len(self.deserialize(resp)["objects"]), 9) + @override_settings(API_LOCKDOWN=True) def test_groups_lockdown(self): groups_list_url = reverse("api_dispatch_list", kwargs={"api_name": "api", "resource_name": "groups"}) From c7d496462de7d67f2f3ee14db9e55bc392e1b9ce Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Wed, 16 Aug 2023 11:35:52 +0200 Subject: [PATCH 115/330] Fixes #11296 Implement custom download URLs (#11298) * Fixes #11296 Implement custom download URLs * Fixes #11296 Fix tests * Fixes #11296 Let the download manager being overridable * Fixes #11296 rename dataset in resource * Fixes #11296 black and flake run * Fixes #11296 perform_last_step removed * Fixes #11296 file rename * Fixes #11296 file rename * Fixes #11296 file rename * Fixes #11296 fix codeql warning * Fixes #11296 fix flake8 and test * Fixes #11296 fix flake8 and test --------- Co-authored-by: Giovanni Allegri --- geonode/layers/tests.py | 32 +++---- geonode/layers/views.py | 76 ++------------- geonode/resource/download_handler.py | 133 +++++++++++++++++++++++++++ geonode/settings.py | 2 + geonode/tests/smoke.py | 21 +++++ 5 files changed, 177 insertions(+), 87 deletions(-) create mode 100644 geonode/resource/download_handler.py diff --git a/geonode/layers/tests.py b/geonode/layers/tests.py index d7420fc2a84..2deb3ca0a21 100644 --- a/geonode/layers/tests.py +++ b/geonode/layers/tests.py @@ -1210,7 +1210,7 @@ def test_dataset_download_invalid_wps_format(self): self.assertEqual(500, response.status_code) self.assertDictEqual({"error": "The format provided is not valid for the selected resource"}, response.json()) - @patch("geonode.layers.views.HttpClient.request") + @patch("geonode.resource.download_handler.HttpClient.request") def test_dataset_download_call_the_catalog_raise_error_for_no_200(self, mocked_catalog): _response = MagicMock(status_code=500, content="foo-bar") mocked_catalog.return_value = _response, "foo-bar" @@ -1220,12 +1220,9 @@ def test_dataset_download_call_the_catalog_raise_error_for_no_200(self, mocked_c url = reverse("dataset_download", args=[dataset.alternate]) response = self.client.get(url) self.assertEqual(500, response.status_code) - self.assertDictEqual( - {"error": "Download dataset exception: error during call with GeoServer: foo-bar"}, response.json() - ) + self.assertDictEqual({"error": "Download dataset exception: error during call with GeoServer"}, response.json()) - @patch("geonode.layers.views.HttpClient.request") - def test_dataset_download_call_the_catalog_raise_error_for_error_content(self, mocked_catalog): + def test_dataset_download_call_the_catalog_raise_error_for_error_content(self): content = """ @@ -1234,14 +1231,15 @@ def test_dataset_download_call_the_catalog_raise_error_for_error_content(self, m """ # noqa _response = MagicMock(status_code=200, text=content, headers={"Content-Type": "text/xml"}) - mocked_catalog.return_value = _response, content # if settings.USE_GEOSERVER is false, the URL must be redirected self.client.login(username="admin", password="admin") dataset = Dataset.objects.first() - url = reverse("dataset_download", args=[dataset.alternate]) - response = self.client.get(url) - self.assertEqual(500, response.status_code) - self.assertDictEqual({"error": "InvalidParameterValue: Foo Bar Exception"}, response.json()) + with patch("geonode.resource.download_handler.HttpClient.request") as mocked_catalog: + mocked_catalog.return_value = _response, content + url = reverse("dataset_download", args=[dataset.alternate]) + response = self.client.get(url) + self.assertEqual(500, response.status_code) + self.assertDictEqual({"error": "InvalidParameterValue: Foo Bar Exception"}, response.json()) def test_dataset_download_call_the_catalog_works(self): # if settings.USE_GEOSERVER is false, the URL must be redirected @@ -1249,7 +1247,7 @@ def test_dataset_download_call_the_catalog_works(self): self.client.login(username="admin", password="admin") dataset = Dataset.objects.first() layer = create_dataset(dataset.title, dataset.title, dataset.owner, "Point") - with patch("geonode.layers.views.HttpClient.request") as mocked_catalog: + with patch("geonode.resource.download_handler.HttpClient.request") as mocked_catalog: mocked_catalog.return_value = _response, "" url = reverse("dataset_download", args=[layer.alternate]) response = self.client.get(url) @@ -1268,21 +1266,21 @@ def test_dataset_download_call_the_catalog_work_anonymous(self): _response = MagicMock(status_code=200, text="", headers={"Content-Type": ""}) # noqa dataset = Dataset.objects.first() layer = create_dataset(dataset.title, dataset.title, dataset.owner, "Point") - with patch("geonode.layers.views.HttpClient.request") as mocked_catalog: + with patch("geonode.resource.download_handler.HttpClient.request") as mocked_catalog: mocked_catalog.return_value = _response, "" url = reverse("dataset_download", args=[layer.alternate]) response = self.client.get(url) self.assertTrue(response.status_code == 200) @override_settings(USE_GEOSERVER=True) - @patch("geonode.layers.views.get_template") + @patch("geonode.resource.download_handler.get_template") def test_dataset_download_call_the_catalog_work_for_raster(self, pathed_template): # if settings.USE_GEOSERVER is false, the URL must be redirected _response = MagicMock(status_code=200, text="", headers={"Content-Type": ""}) # noqa dataset = Dataset.objects.filter(subtype="raster").first() layer = create_dataset(dataset.title, dataset.title, dataset.owner, "Point") Dataset.objects.filter(alternate=layer.alternate).update(subtype="raster") - with patch("geonode.layers.views.HttpClient.request") as mocked_catalog: + with patch("geonode.resource.download_handler.HttpClient.request") as mocked_catalog: mocked_catalog.return_value = _response, "" url = reverse("dataset_download", args=[layer.alternate]) response = self.client.get(url) @@ -1295,13 +1293,13 @@ def test_dataset_download_call_the_catalog_work_for_raster(self, pathed_template ) @override_settings(USE_GEOSERVER=True) - @patch("geonode.layers.views.get_template") + @patch("geonode.resource.download_handler.get_template") def test_dataset_download_call_the_catalog_work_for_vector(self, pathed_template): # if settings.USE_GEOSERVER is false, the URL must be redirected _response = MagicMock(status_code=200, text="", headers={"Content-Type": ""}) # noqa dataset = Dataset.objects.filter(subtype="vector").first() layer = create_dataset(dataset.title, dataset.title, dataset.owner, "Point") - with patch("geonode.layers.views.HttpClient.request") as mocked_catalog: + with patch("geonode.resource.download_handler.HttpClient.request") as mocked_catalog: mocked_catalog.return_value = _response, "" url = reverse("dataset_download", args=[layer.alternate]) response = self.client.get(url) diff --git a/geonode/layers/views.py b/geonode/layers/views.py index 609a7f0c839..d9b08da7f97 100644 --- a/geonode/layers/views.py +++ b/geonode/layers/views.py @@ -23,21 +23,18 @@ import logging import warnings import traceback -from django.urls import reverse from owslib.wfs import WebFeatureService -import xml.etree.ElementTree as ET from django.conf import settings from django.db.models import F -from django.http import Http404, JsonResponse +from django.http import Http404 from django.contrib import messages from django.shortcuts import render from django.utils.html import escape from django.forms.utils import ErrorList from django.contrib.auth import get_user_model -from django.template.loader import get_template from django.utils.translation import ugettext as _ from django.core.exceptions import PermissionDenied from django.forms.models import inlineformset_factory @@ -50,9 +47,8 @@ from geonode import geoserver from geonode.layers.metadata import parse_metadata -from geonode.proxy.views import fetch_response_headers from geonode.resource.manager import resource_manager -from geonode.geoserver.helpers import set_dataset_style, wps_format_is_supported +from geonode.geoserver.helpers import set_dataset_style from geonode.resource.utils import update_resource from geonode.base.auth import get_or_create_token @@ -70,9 +66,10 @@ from geonode.groups.models import GroupProfile from geonode.security.utils import get_user_visible_groups, AdvancedSecurityWorkflowManager from geonode.people.forms import ProfileForm -from geonode.utils import HttpClient, check_ogc_backend, llbbox_to_mercator, resolve_object, mkdtemp +from geonode.utils import check_ogc_backend, llbbox_to_mercator, resolve_object, mkdtemp from geonode.geoserver.helpers import ogc_server_settings, select_relevant_files, write_uploaded_files_to_disk from geonode.geoserver.security import set_geowebcache_invalidate_cache +from django.utils.module_loading import import_string if check_ogc_backend(geoserver.BACKEND_PACKAGE): from geonode.geoserver.helpers import gs_catalog @@ -736,69 +733,8 @@ def dataset_metadata_advanced(request, layername): @csrf_exempt def dataset_download(request, layername): - try: - dataset = _resolve_dataset(request, layername, "base.download_resourcebase", _PERMISSION_MSG_GENERIC) - except Exception as e: - raise Http404(Exception(_("Not found"), e)) - - if not settings.USE_GEOSERVER: - # if GeoServer is not used, we redirect to the proxy download - return HttpResponseRedirect(reverse("download", args=[dataset.id])) - - download_format = request.GET.get("export_format") - - if download_format and not wps_format_is_supported(download_format, dataset.subtype): - logger.error("The format provided is not valid for the selected resource") - return JsonResponse({"error": "The format provided is not valid for the selected resource"}, status=500) - - _format = "application/zip" if dataset.is_vector() else "image/tiff" - # getting default payload - tpl = get_template("geoserver/dataset_download.xml") - ctx = {"alternate": dataset.alternate, "download_format": download_format or _format} - # applying context for the payload - payload = tpl.render(ctx) - - # init of Client - client = HttpClient() - - headers = {"Content-type": "application/xml", "Accept": "application/xml"} - - # defining the URL needed fr the download - url = f"{settings.OGC_SERVER['default']['LOCATION']}ows?service=WPS&version=1.0.0&REQUEST=Execute" - if not request.user.is_anonymous: - # define access token for the user - access_token = get_or_create_token(request.user) - url += f"&access_token={access_token}" - - # request to geoserver - response, content = client.request(url=url, data=payload, method="post", headers=headers) - - if response.status_code != 200: - logger.error(f"Download dataset exception: error during call with GeoServer: {response.content}") - return JsonResponse( - {"error": f"Download dataset exception: error during call with GeoServer: {response.content}"}, status=500 - ) - - # error handling - namespaces = {"ows": "http://www.opengis.net/ows/1.1", "wps": "http://www.opengis.net/wps/1.0.0"} - response_type = response.headers.get("Content-Type") - if response_type == "text/xml": - # parsing XML for get exception - content = ET.fromstring(response.text) - exc = content.find("*//ows:Exception", namespaces=namespaces) or content.find( - "ows:Exception", namespaces=namespaces - ) - if exc: - exc_text = exc.find("ows:ExceptionText", namespaces=namespaces) - logger.error(f"{exc.attrib.get('exceptionCode')} {exc_text.text}") - return JsonResponse({"error": f"{exc.attrib.get('exceptionCode')}: {exc_text.text}"}, status=500) - - return_response = fetch_response_headers( - HttpResponse(content=response.content, status=response.status_code, content_type=download_format), - response.headers, - ) - return_response.headers["Content-Type"] = download_format or _format - return return_response + DownloadHandler = import_string(settings.DATASET_DOWNLOAD_HANDLER) + return DownloadHandler(request, layername).get_download_response() @login_required diff --git a/geonode/resource/download_handler.py b/geonode/resource/download_handler.py new file mode 100644 index 00000000000..ae21e4e23a9 --- /dev/null +++ b/geonode/resource/download_handler.py @@ -0,0 +1,133 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### + +import logging +import xml.etree.ElementTree as ET + +from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse +from django.template.loader import get_template +from django.urls import reverse +from django.utils.translation import ugettext as _ +from django.conf import settings +from geonode.base.auth import get_or_create_token +from geonode.geoserver.helpers import wps_format_is_supported +from geonode.layers.views import _resolve_dataset +from geonode.proxy.views import fetch_response_headers +from geonode.utils import HttpClient + +logger = logging.getLogger("geonode.resource.download_handler") + + +class DownloadHandler: + def __str__(self): + return f"{self.__module__}.{self.__class__.__name__}" + + def __repr__(self): + return self.__str__() + + def __init__(self, request, resource_name) -> None: + self.request = request + self.resource_name = resource_name + + def get_download_response(self): + """ + Basic method. Should return the Response object + that allow the resource download + """ + resource = self.get_resource() + response = self.process_dowload(resource) + return response + + def get_resource(self): + """ + Returnt the object needed + """ + try: + return _resolve_dataset( + self.request, + self.resource_name, + "base.download_resourcebase", + _("You do not have permissions for this dataset."), + ) + except Exception as e: + raise Http404(Exception(_("Not found"), e)) + + def process_dowload(self, resource): + """ + Generate the response object + """ + if not settings.USE_GEOSERVER: + # if GeoServer is not used, we redirect to the proxy download + return HttpResponseRedirect(reverse("download", args=[resource.id])) + + download_format = self.request.GET.get("export_format") + + if download_format and not wps_format_is_supported(download_format, resource.subtype): + logger.error("The format provided is not valid for the selected resource") + return JsonResponse({"error": "The format provided is not valid for the selected resource"}, status=500) + + _format = "application/zip" if resource.is_vector() else "image/tiff" + # getting default payload + tpl = get_template("geoserver/dataset_download.xml") + ctx = {"alternate": resource.alternate, "download_format": download_format or _format} + # applying context for the payload + payload = tpl.render(ctx) + + # init of Client + client = HttpClient() + + headers = {"Content-type": "application/xml", "Accept": "application/xml"} + + # defining the URL needed fr the download + url = f"{settings.OGC_SERVER['default']['LOCATION']}ows?service=WPS&version=1.0.0&REQUEST=Execute" + if not self.request.user.is_anonymous: + # define access token for the user + access_token = get_or_create_token(self.request.user) + url += f"&access_token={access_token}" + + # request to geoserver + response, content = client.request(url=url, data=payload, method="post", headers=headers) + + if not response or response.status_code != 200: + logger.error(f"Download dataset exception: error during call with GeoServer: {content}") + return JsonResponse( + {"error": "Download dataset exception: error during call with GeoServer"}, + status=500, + ) + + # error handling + namespaces = {"ows": "http://www.opengis.net/ows/1.1", "wps": "http://www.opengis.net/wps/1.0.0"} + response_type = response.headers.get("Content-Type") + if response_type == "text/xml": + # parsing XML for get exception + content = ET.fromstring(response.text) + exc = content.find("*//ows:Exception", namespaces=namespaces) or content.find( + "ows:Exception", namespaces=namespaces + ) + if exc: + exc_text = exc.find("ows:ExceptionText", namespaces=namespaces) + logger.error(f"{exc.attrib.get('exceptionCode')} {exc_text.text}") + return JsonResponse({"error": f"{exc.attrib.get('exceptionCode')}: {exc_text.text}"}, status=500) + + return_response = fetch_response_headers( + HttpResponse(content=response.content, status=response.status_code, content_type=download_format), + response.headers, + ) + return_response.headers["Content-Type"] = download_format or _format + return return_response diff --git a/geonode/settings.py b/geonode/settings.py index 4a90f085e68..72f37a84b6f 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -2344,3 +2344,5 @@ def get_geonode_catalogue_service(): {"class": "geonode.facets.providers.users.OwnerFacetProvider", "config": {"order": 8, "type": "select"}}, {"class": "geonode.facets.providers.thesaurus.ThesaurusFacetProvider", "config": {"type": "select"}}, ] + +DATASET_DOWNLOAD_HANDLER = os.getenv("DATASET_DOWNLOAD_HANDLER", "geonode.resource.download_handler.DownloadHandler") diff --git a/geonode/tests/smoke.py b/geonode/tests/smoke.py index 7ae4a37c1b9..bda90ec0e16 100644 --- a/geonode/tests/smoke.py +++ b/geonode/tests/smoke.py @@ -18,7 +18,10 @@ ######################################################################### from unittest import TestCase + +from django.http import HttpResponse from geonode.base.populate_test_data import create_single_dataset +from geonode.resource.download_handler import DownloadHandler from geonode.resource.utils import metadata_storers from geonode.tests.base import GeoNodeBaseTestSupport @@ -333,3 +336,21 @@ def dummy_metadata_storer2(dataset, custom): if custom.get("second-stage", None): for key, value in custom["second-stage"].items(): setattr(dataset, key, value) + + +class DummyDownloadManager(DownloadHandler): + def get_download_response(self): + return HttpResponse(content=b"abcsfd2") + + +class TestDownloadManager(GeoNodeBaseTestSupport): + def setUp(self): + self.sut = DownloadHandler + + @override_settings(DATASET_DOWNLOAD_HANDLER="geonode.tests.smoke.DummyDownloadManager") + def test_download_handler(self): + dataset = create_single_dataset("test_dataset") + url = reverse("dataset_download", args=[dataset.alternate]) + response = self.client.get(url) + self.assertTrue(response.status_code == 200) + self.assertEqual(response.content, b"abcsfd2") From 05b1b08af665e200efe4f8c49c6b78f33b1b1d33 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 17 Aug 2023 11:51:33 +0200 Subject: [PATCH 116/330] [Fixes #11386] Configure Django apps (#11387) * Configure Django apps * fix typos * fix * removed deprectaed default_app_config * missing conifg for goenode openid provider app * formatting --- geonode/api/apps.py | 24 + geonode/catalogue/apps.py | 24 + geonode/catalogue/metadataxsl/apps.py | 24 + geonode/documents/exif/apps.py | 24 + geonode/favorite/apps.py | 24 + geonode/geoserver/createlayer/apps.py | 24 + geonode/geoserver/processing/apps.py | 24 + geonode/local_settings.py.geoserver.sample | 442 +++++++++--------- .../providers/geonode_openid_connect/apps.py | 24 + geonode/proxy/__init__.py | 2 +- geonode/proxy/apps.py | 24 + geonode/security/__init__.py | 2 +- geonode/security/apps.py | 24 + geonode/tasks/apps.py | 24 + 14 files changed, 476 insertions(+), 234 deletions(-) create mode 100644 geonode/api/apps.py create mode 100644 geonode/catalogue/apps.py create mode 100644 geonode/catalogue/metadataxsl/apps.py create mode 100644 geonode/documents/exif/apps.py create mode 100644 geonode/favorite/apps.py create mode 100644 geonode/geoserver/createlayer/apps.py create mode 100644 geonode/geoserver/processing/apps.py create mode 100644 geonode/people/socialaccount/providers/geonode_openid_connect/apps.py create mode 100644 geonode/proxy/apps.py create mode 100644 geonode/security/apps.py create mode 100644 geonode/tasks/apps.py diff --git a/geonode/api/apps.py b/geonode/api/apps.py new file mode 100644 index 00000000000..bf3d85231a9 --- /dev/null +++ b/geonode/api/apps.py @@ -0,0 +1,24 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from django.apps import AppConfig + + +class GeoNodeApiAppConfig(AppConfig): + name = "geonode.api" + verbose_name = "GeoNode API v1" diff --git a/geonode/catalogue/apps.py b/geonode/catalogue/apps.py new file mode 100644 index 00000000000..961648a7945 --- /dev/null +++ b/geonode/catalogue/apps.py @@ -0,0 +1,24 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from django.apps import AppConfig + + +class GeoNodeCatalogueAppConfig(AppConfig): + name = "geonode.catalogue" + verbose_name = "GeoNode CSW Catalogue" diff --git a/geonode/catalogue/metadataxsl/apps.py b/geonode/catalogue/metadataxsl/apps.py new file mode 100644 index 00000000000..af8e4d6001f --- /dev/null +++ b/geonode/catalogue/metadataxsl/apps.py @@ -0,0 +1,24 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from django.apps import AppConfig + + +class GeoNodeCatalogueMetadataxslAppConfig(AppConfig): + name = "geonode.catalogue.metadataxsl" + verbose_name = "GeoNode Catalogue metadataxsl" diff --git a/geonode/documents/exif/apps.py b/geonode/documents/exif/apps.py new file mode 100644 index 00000000000..eaeb3e2a031 --- /dev/null +++ b/geonode/documents/exif/apps.py @@ -0,0 +1,24 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from django.apps import AppConfig + + +class GeoNodeDocumentsExifAppConfig(AppConfig): + name = "geonode.documents.exif" + verbose_name = "GeoNode Documents Exif" diff --git a/geonode/favorite/apps.py b/geonode/favorite/apps.py new file mode 100644 index 00000000000..0b27c6eb7e3 --- /dev/null +++ b/geonode/favorite/apps.py @@ -0,0 +1,24 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from django.apps import AppConfig + + +class GeoNodeFavoriteAppConfig(AppConfig): + name = "geonode.favorite" + verbose_name = "GeoNode Favorite" diff --git a/geonode/geoserver/createlayer/apps.py b/geonode/geoserver/createlayer/apps.py new file mode 100644 index 00000000000..a3b9434b8a4 --- /dev/null +++ b/geonode/geoserver/createlayer/apps.py @@ -0,0 +1,24 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from django.apps import AppConfig + + +class GeoNodeGeoserverCreatelayerAppConfig(AppConfig): + name = "geonode.geoserver.createlayer" + verbose_name = "GeoNode Geoserver Createlayer" diff --git a/geonode/geoserver/processing/apps.py b/geonode/geoserver/processing/apps.py new file mode 100644 index 00000000000..8b5d10c06cb --- /dev/null +++ b/geonode/geoserver/processing/apps.py @@ -0,0 +1,24 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from django.apps import AppConfig + + +class GeoNodeGeoserverProcessingAppConfig(AppConfig): + name = "geonode.geoserver.processing" + verbose_name = "GeoNode Geoserver Processing" diff --git a/geonode/local_settings.py.geoserver.sample b/geonode/local_settings.py.geoserver.sample index 733589d6d36..18aa492c76f 100644 --- a/geonode/local_settings.py.geoserver.sample +++ b/geonode/local_settings.py.geoserver.sample @@ -26,6 +26,7 @@ import ast import os + try: # python2 from urlparse import urlparse, urlunparse, urlsplit, urljoin except ImportError: @@ -35,159 +36,129 @@ from geonode.settings import * PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) -MEDIA_ROOT = os.getenv('MEDIA_ROOT', os.path.join(PROJECT_ROOT, "uploaded")) +MEDIA_ROOT = os.getenv("MEDIA_ROOT", os.path.join(PROJECT_ROOT, "uploaded")) -STATIC_ROOT = os.getenv('STATIC_ROOT', - os.path.join(PROJECT_ROOT, "static_root") - ) +STATIC_ROOT = os.getenv("STATIC_ROOT", os.path.join(PROJECT_ROOT, "static_root")) -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" # Login and logout urls override -LOGIN_URL = os.getenv('LOGIN_URL', '{}account/login/'.format(SITEURL)) -LOGOUT_URL = os.getenv('LOGOUT_URL', '{}account/logout/'.format(SITEURL)) +LOGIN_URL = os.getenv("LOGIN_URL", "{}account/login/".format(SITEURL)) +LOGOUT_URL = os.getenv("LOGOUT_URL", "{}account/logout/".format(SITEURL)) -ACCOUNT_LOGIN_REDIRECT_URL = os.getenv('LOGIN_REDIRECT_URL', SITEURL) -ACCOUNT_LOGOUT_REDIRECT_URL = os.getenv('LOGOUT_REDIRECT_URL', SITEURL) +ACCOUNT_LOGIN_REDIRECT_URL = os.getenv("LOGIN_REDIRECT_URL", SITEURL) +ACCOUNT_LOGOUT_REDIRECT_URL = os.getenv("LOGOUT_REDIRECT_URL", SITEURL) -AVATAR_GRAVATAR_SSL = ast.literal_eval(os.getenv('AVATAR_GRAVATAR_SSL', 'True')) +AVATAR_GRAVATAR_SSL = ast.literal_eval(os.getenv("AVATAR_GRAVATAR_SSL", "True")) # Backend DATABASES = { - 'default': { - 'ENGINE': 'django.contrib.gis.db.backends.postgis', - 'NAME': 'geonode', - 'USER': 'geonode', - 'PASSWORD': 'geonode', - 'HOST': 'localhost', - 'PORT': '5432', - 'CONN_MAX_AGE': 0, - 'CONN_TOUT': 5, - 'OPTIONS': { - 'connect_timeout': 5, - } + "default": { + "ENGINE": "django.contrib.gis.db.backends.postgis", + "NAME": "geonode", + "USER": "geonode", + "PASSWORD": "geonode", + "HOST": "localhost", + "PORT": "5432", + "CONN_MAX_AGE": 0, + "CONN_TOUT": 5, + "OPTIONS": { + "connect_timeout": 5, + }, }, # vector datastore for uploads - 'datastore': { - 'ENGINE': 'django.contrib.gis.db.backends.postgis', + "datastore": { + "ENGINE": "django.contrib.gis.db.backends.postgis", # 'ENGINE': '', # Empty ENGINE name disables - 'NAME': 'geonode_data', - 'USER': 'geonode', - 'PASSWORD': 'geonode', - 'HOST': 'localhost', - 'PORT': '5432', - 'CONN_MAX_AGE': 0, - 'CONN_TOUT': 5, - 'OPTIONS': { - 'connect_timeout': 5, - } - } + "NAME": "geonode_data", + "USER": "geonode", + "PASSWORD": "geonode", + "HOST": "localhost", + "PORT": "5432", + "CONN_MAX_AGE": 0, + "CONN_TOUT": 5, + "OPTIONS": { + "connect_timeout": 5, + }, + }, } -GEOSERVER_LOCATION = os.getenv( - 'GEOSERVER_LOCATION', 'http://localhost:8080/geoserver/' -) +GEOSERVER_LOCATION = os.getenv("GEOSERVER_LOCATION", "http://localhost:8080/geoserver/") -GEOSERVER_PUBLIC_HOST = os.getenv( - 'GEOSERVER_PUBLIC_HOST', SITE_HOST_NAME -) +GEOSERVER_PUBLIC_HOST = os.getenv("GEOSERVER_PUBLIC_HOST", SITE_HOST_NAME) -GEOSERVER_PUBLIC_PORT = os.getenv( - 'GEOSERVER_PUBLIC_PORT', 8080 -) +GEOSERVER_PUBLIC_PORT = os.getenv("GEOSERVER_PUBLIC_PORT", 8080) -_default_public_location = 'http://{}:{}/geoserver/'.format(GEOSERVER_PUBLIC_HOST, GEOSERVER_PUBLIC_PORT) if GEOSERVER_PUBLIC_PORT else 'http://{}/geoserver/'.format(GEOSERVER_PUBLIC_HOST) - -GEOSERVER_WEB_UI_LOCATION = os.getenv( - 'GEOSERVER_WEB_UI_LOCATION', GEOSERVER_LOCATION +_default_public_location = ( + "http://{}:{}/geoserver/".format(GEOSERVER_PUBLIC_HOST, GEOSERVER_PUBLIC_PORT) + if GEOSERVER_PUBLIC_PORT + else "http://{}/geoserver/".format(GEOSERVER_PUBLIC_HOST) ) -GEOSERVER_PUBLIC_LOCATION = os.getenv( - 'GEOSERVER_PUBLIC_LOCATION', _default_public_location -) +GEOSERVER_WEB_UI_LOCATION = os.getenv("GEOSERVER_WEB_UI_LOCATION", GEOSERVER_LOCATION) -OGC_SERVER_DEFAULT_USER = os.getenv( - 'GEOSERVER_ADMIN_USER', 'admin' -) +GEOSERVER_PUBLIC_LOCATION = os.getenv("GEOSERVER_PUBLIC_LOCATION", _default_public_location) -OGC_SERVER_DEFAULT_PASSWORD = os.getenv( - 'GEOSERVER_ADMIN_PASSWORD', 'geoserver' -) +OGC_SERVER_DEFAULT_USER = os.getenv("GEOSERVER_ADMIN_USER", "admin") + +OGC_SERVER_DEFAULT_PASSWORD = os.getenv("GEOSERVER_ADMIN_PASSWORD", "geoserver") # OGC (WMS/WFS/WCS) Server Settings OGC_SERVER = { - 'default': { - 'BACKEND': 'geonode.geoserver', - 'LOCATION': GEOSERVER_LOCATION, - 'WEB_UI_LOCATION': GEOSERVER_WEB_UI_LOCATION, - 'LOGIN_ENDPOINT': 'j_spring_oauth2_geonode_login', - 'LOGOUT_ENDPOINT': 'j_spring_oauth2_geonode_logout', + "default": { + "BACKEND": "geonode.geoserver", + "LOCATION": GEOSERVER_LOCATION, + "WEB_UI_LOCATION": GEOSERVER_WEB_UI_LOCATION, + "LOGIN_ENDPOINT": "j_spring_oauth2_geonode_login", + "LOGOUT_ENDPOINT": "j_spring_oauth2_geonode_logout", # PUBLIC_LOCATION needs to be kept like this because in dev mode # the proxy won't work and the integration tests will fail # the entire block has to be overridden in the local_settings - 'PUBLIC_LOCATION': GEOSERVER_PUBLIC_LOCATION, - 'USER': OGC_SERVER_DEFAULT_USER, - 'PASSWORD': OGC_SERVER_DEFAULT_PASSWORD, - 'MAPFISH_PRINT_ENABLED': True, - 'PRINT_NG_ENABLED': True, - 'GEONODE_SECURITY_ENABLED': True, - 'GEOFENCE_SECURITY_ENABLED': True, - 'GEOFENCE_TIMEOUT': int(os.getenv('GEOFENCE_TIMEOUT', os.getenv('OGC_REQUEST_TIMEOUT', '60'))), - 'WMST_ENABLED': False, - 'BACKEND_WRITE_ENABLED': True, - 'WPS_ENABLED': False, - 'LOG_FILE': '%s/geoserver/data/logs/geoserver.log' % os.path.abspath(os.path.join(PROJECT_ROOT, os.pardir)), + "PUBLIC_LOCATION": GEOSERVER_PUBLIC_LOCATION, + "USER": OGC_SERVER_DEFAULT_USER, + "PASSWORD": OGC_SERVER_DEFAULT_PASSWORD, + "MAPFISH_PRINT_ENABLED": True, + "PRINT_NG_ENABLED": True, + "GEONODE_SECURITY_ENABLED": True, + "GEOFENCE_SECURITY_ENABLED": True, + "GEOFENCE_TIMEOUT": int(os.getenv("GEOFENCE_TIMEOUT", os.getenv("OGC_REQUEST_TIMEOUT", "60"))), + "WMST_ENABLED": False, + "BACKEND_WRITE_ENABLED": True, + "WPS_ENABLED": False, + "LOG_FILE": "%s/geoserver/data/logs/geoserver.log" % os.path.abspath(os.path.join(PROJECT_ROOT, os.pardir)), # Set to dictionary identifier of database containing spatial data in DATABASES dictionary to enable - 'DATASTORE': 'datastore', - 'TIMEOUT': int(os.getenv('OGC_REQUEST_TIMEOUT', '60')), - 'MAX_RETRIES': int(os.getenv('OGC_REQUEST_MAX_RETRIES', '5')), - 'BACKOFF_FACTOR': float(os.getenv('OGC_REQUEST_BACKOFF_FACTOR', '0.3')), - 'POOL_MAXSIZE': int(os.getenv('OGC_REQUEST_POOL_MAXSIZE', '10')), - 'POOL_CONNECTIONS': int(os.getenv('OGC_REQUEST_POOL_CONNECTIONS', '10')), + "DATASTORE": "datastore", + "TIMEOUT": int(os.getenv("OGC_REQUEST_TIMEOUT", "60")), + "MAX_RETRIES": int(os.getenv("OGC_REQUEST_MAX_RETRIES", "5")), + "BACKOFF_FACTOR": float(os.getenv("OGC_REQUEST_BACKOFF_FACTOR", "0.3")), + "POOL_MAXSIZE": int(os.getenv("OGC_REQUEST_POOL_MAXSIZE", "10")), + "POOL_CONNECTIONS": int(os.getenv("OGC_REQUEST_POOL_CONNECTIONS", "10")), } } # If you want to enable Mosaics use the following configuration UPLOADER = { - 'BACKEND': 'geonode.importer', - 'OPTIONS': { - 'TIME_ENABLED': True, - 'MOSAIC_ENABLED': False, + "BACKEND": "geonode.importer", + "OPTIONS": { + "TIME_ENABLED": True, + "MOSAIC_ENABLED": False, }, - 'SUPPORTED_CRS': [ - 'EPSG:4326', - 'EPSG:3785', - 'EPSG:3857', - 'EPSG:32647', - 'EPSG:32736' - ], - 'SUPPORTED_EXT': [ - '.shp', - '.csv', - '.kml', - '.kmz', - '.json', - '.geojson', - '.tif', - '.tiff', - '.geotiff', - '.gml', - '.xml' - ] + "SUPPORTED_CRS": ["EPSG:4326", "EPSG:3785", "EPSG:3857", "EPSG:32647", "EPSG:32736"], + "SUPPORTED_EXT": [".shp", ".csv", ".kml", ".kmz", ".json", ".geojson", ".tif", ".tiff", ".geotiff", ".gml", ".xml"], } # CSW settings CATALOGUE = { - 'default': { + "default": { # The underlying CSW implementation # default is pycsw in local mode (tied directly to GeoNode Django DB) - 'ENGINE': 'geonode.catalogue.backends.pycsw_local', + "ENGINE": "geonode.catalogue.backends.pycsw_local", # pycsw in non-local mode # 'ENGINE': 'geonode.catalogue.backends.pycsw_http', # deegree and others # 'ENGINE': 'geonode.catalogue.backends.generic', # The FULLY QUALIFIED base url to the CSW instance for this GeoNode - 'URL': urljoin(SITEURL, '/catalogue/csw'), + "URL": urljoin(SITEURL, "/catalogue/csw"), # 'URL': 'http://localhost:8080/deegree-csw-demo-3.0.4/services', # 'ALTERNATES_ONLY': True, } @@ -196,69 +167,67 @@ CATALOGUE = { # pycsw settings PYCSW = { # pycsw configuration - 'CONFIGURATION': { + "CONFIGURATION": { # uncomment / adjust to override server config system defaults # 'server': { # 'maxrecords': '10', # 'pretty_print': 'true', # 'federatedcatalogues': 'http://catalog.data.gov/csw' # }, - 'server': { - 'home': '.', - 'url': CATALOGUE['default']['URL'], - 'encoding': 'UTF-8', - 'language': LANGUAGE_CODE, - 'maxrecords': '20', - 'pretty_print': 'true', + "server": { + "home": ".", + "url": CATALOGUE["default"]["URL"], + "encoding": "UTF-8", + "language": LANGUAGE_CODE, + "maxrecords": "20", + "pretty_print": "true", # 'domainquerytype': 'range', - 'domaincounts': 'true', - 'profiles': 'apiso,ebrim', + "domaincounts": "true", + "profiles": "apiso,ebrim", }, - 'manager': { + "manager": { # authentication/authorization is handled by Django - 'transactions': 'false', - 'allowed_ips': '*', + "transactions": "false", + "allowed_ips": "*", # 'csw_harvest_pagesize': '10', }, - 'metadata:main': { - 'identification_title': 'GeoNode Catalogue', - 'identification_abstract': 'GeoNode is an open source platform' \ - ' that facilitates the creation, sharing, and collaborative use' \ - ' of geospatial data', - 'identification_keywords': 'sdi, catalogue, discovery, metadata,' \ - ' GeoNode', - 'identification_keywords_type': 'theme', - 'identification_fees': 'None', - 'identification_accessconstraints': 'None', - 'provider_name': 'Organization Name', - 'provider_url': SITEURL, - 'contact_name': 'Lastname, Firstname', - 'contact_position': 'Position Title', - 'contact_address': 'Mailing Address', - 'contact_city': 'City', - 'contact_stateorprovince': 'Administrative Area', - 'contact_postalcode': 'Zip or Postal Code', - 'contact_country': 'Country', - 'contact_phone': '+xx-xxx-xxx-xxxx', - 'contact_fax': '+xx-xxx-xxx-xxxx', - 'contact_email': 'Email Address', - 'contact_url': 'Contact URL', - 'contact_hours': 'Hours of Service', - 'contact_instructions': 'During hours of service. Off on ' \ - 'weekends.', - 'contact_role': 'pointOfContact', + "metadata:main": { + "identification_title": "GeoNode Catalogue", + "identification_abstract": "GeoNode is an open source platform" + " that facilitates the creation, sharing, and collaborative use" + " of geospatial data", + "identification_keywords": "sdi, catalogue, discovery, metadata," " GeoNode", + "identification_keywords_type": "theme", + "identification_fees": "None", + "identification_accessconstraints": "None", + "provider_name": "Organization Name", + "provider_url": SITEURL, + "contact_name": "Lastname, Firstname", + "contact_position": "Position Title", + "contact_address": "Mailing Address", + "contact_city": "City", + "contact_stateorprovince": "Administrative Area", + "contact_postalcode": "Zip or Postal Code", + "contact_country": "Country", + "contact_phone": "+xx-xxx-xxx-xxxx", + "contact_fax": "+xx-xxx-xxx-xxxx", + "contact_email": "Email Address", + "contact_url": "Contact URL", + "contact_hours": "Hours of Service", + "contact_instructions": "During hours of service. Off on " "weekends.", + "contact_role": "pointOfContact", + }, + "metadata:inspire": { + "enabled": "true", + "languages_supported": "eng,gre", + "default_language": "eng", + "date": "YYYY-MM-DD", + "gemet_keywords": "Utility and governmental services", + "conformity_service": "notEvaluated", + "contact_name": "Organization Name", + "contact_email": "Email Address", + "temp_extent": "YYYY-MM-DD/YYYY-MM-DD", }, - 'metadata:inspire': { - 'enabled': 'true', - 'languages_supported': 'eng,gre', - 'default_language': 'eng', - 'date': 'YYYY-MM-DD', - 'gemet_keywords': 'Utility and governmental services', - 'conformity_service': 'notEvaluated', - 'contact_name': 'Organization Name', - 'contact_email': 'Email Address', - 'temp_extent': 'YYYY-MM-DD/YYYY-MM-DD', - } } } @@ -268,50 +237,51 @@ PYCSW = { # default map projection # Note: If set to EPSG:4326, then only EPSG:4326 basemaps will work. -DEFAULT_MAP_CRS = os.environ.get('DEFAULT_MAP_CRS', "EPSG:3857") +DEFAULT_MAP_CRS = os.environ.get("DEFAULT_MAP_CRS", "EPSG:3857") -DEFAULT_LAYER_FORMAT = os.environ.get('DEFAULT_LAYER_FORMAT', "image/png") +DEFAULT_LAYER_FORMAT = os.environ.get("DEFAULT_LAYER_FORMAT", "image/png") # Where should newly created maps be focused? -DEFAULT_MAP_CENTER = (os.environ.get('DEFAULT_MAP_CENTER_X', 0), os.environ.get('DEFAULT_MAP_CENTER_Y', 0)) +DEFAULT_MAP_CENTER = (os.environ.get("DEFAULT_MAP_CENTER_X", 0), os.environ.get("DEFAULT_MAP_CENTER_Y", 0)) # How tightly zoomed should newly created maps be? # 0 = entire world; # maximum zoom is between 12 and 15 (for Google Maps, coverage varies by area) -DEFAULT_MAP_ZOOM = int(os.environ.get('DEFAULT_MAP_ZOOM', 3)) +DEFAULT_MAP_ZOOM = int(os.environ.get("DEFAULT_MAP_ZOOM", 3)) -MAPBOX_ACCESS_TOKEN = os.environ.get('MAPBOX_ACCESS_TOKEN', None) -BING_API_KEY = os.environ.get('BING_API_KEY', None) -GOOGLE_API_KEY = os.environ.get('GOOGLE_API_KEY', None) +MAPBOX_ACCESS_TOKEN = os.environ.get("MAPBOX_ACCESS_TOKEN", None) +BING_API_KEY = os.environ.get("BING_API_KEY", None) +GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", None) -GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY = os.getenv('GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY', 'mapstore') +GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY = os.getenv("GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY", "mapstore") MAP_BASELAYERS = [{}] """ MapStore2 REACT based Client parameters """ -if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': - GEONODE_CLIENT_HOOKSET = os.getenv('GEONODE_CLIENT_HOOKSET', 'geonode_mapstore_client.hooksets.MapStoreHookSet') +if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == "mapstore": + GEONODE_CLIENT_HOOKSET = os.getenv("GEONODE_CLIENT_HOOKSET", "geonode_mapstore_client.hooksets.MapStoreHookSet") - if 'geonode_mapstore_client' not in INSTALLED_APPS: + if "geonode_mapstore_client" not in INSTALLED_APPS: INSTALLED_APPS += ( - 'mapstore2_adapter', - 'geonode_mapstore_client',) + "mapstore2_adapter", + "geonode_mapstore_client", + ) def get_geonode_catalogue_service(): if PYCSW: pycsw_config = PYCSW["CONFIGURATION"] if pycsw_config: - pycsw_catalogue = { - ("%s" % pycsw_config['metadata:main']['identification_title']): { - "url": CATALOGUE['default']['URL'], - "type": "csw", - "title": pycsw_config['metadata:main']['identification_title'], - "autoload": True - } + pycsw_catalogue = { + ("%s" % pycsw_config["metadata:main"]["identification_title"]): { + "url": CATALOGUE["default"]["URL"], + "type": "csw", + "title": pycsw_config["metadata:main"]["identification_title"], + "autoload": True, } - return pycsw_catalogue + } + return pycsw_catalogue return None GEONODE_CATALOGUE_SERVICE = get_geonode_catalogue_service() @@ -321,7 +291,9 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': MAPSTORE_CATALOGUE_SELECTED_SERVICE = "" if GEONODE_CATALOGUE_SERVICE: - MAPSTORE_CATALOGUE_SERVICES[list(GEONODE_CATALOGUE_SERVICE.keys())[0]] = GEONODE_CATALOGUE_SERVICE[list(GEONODE_CATALOGUE_SERVICE.keys())[0]] + MAPSTORE_CATALOGUE_SERVICES[list(GEONODE_CATALOGUE_SERVICE.keys())[0]] = GEONODE_CATALOGUE_SERVICE[ + list(GEONODE_CATALOGUE_SERVICE.keys())[0] + ] MAPSTORE_CATALOGUE_SELECTED_SERVICE = list(GEONODE_CATALOGUE_SERVICE.keys())[0] DEFAULT_MS2_BACKGROUNDS = [ @@ -333,7 +305,7 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': "source": "Stamen", "group": "background", "thumbURL": "https://stamen-tiles-c.a.ssl.fastly.net/watercolor/0/0/0.jpg", - "visibility": False + "visibility": False, }, { "type": "tileprovider", @@ -343,7 +315,7 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': "source": "Stamen", "group": "background", "thumbURL": "https://stamen-tiles-d.a.ssl.fastly.net/terrain/0/0/0.png", - "visibility": False + "visibility": False, }, { "type": "tileprovider", @@ -353,7 +325,7 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': "source": "Stamen", "group": "background", "thumbURL": "https://stamen-tiles-d.a.ssl.fastly.net/toner/0/0/0.png", - "visibility": False + "visibility": False, }, { "type": "osm", @@ -361,7 +333,7 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': "name": "mapnik", "source": "osm", "group": "background", - "visibility": True + "visibility": True, }, { "type": "tileprovider", @@ -370,7 +342,7 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': "name": "OpenTopoMap", "source": "OpenTopoMap", "group": "background", - "visibility": False + "visibility": False, }, { "type": "wms", @@ -384,14 +356,14 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': "https://maps3.geosolutionsgroup.com/geoserver/wms", "https://maps4.geosolutionsgroup.com/geoserver/wms", "https://maps5.geosolutionsgroup.com/geoserver/wms", - "https://maps6.geosolutionsgroup.com/geoserver/wms" + "https://maps6.geosolutionsgroup.com/geoserver/wms", ], "group": "background", "thumbURL": f"{SITEURL}static/mapstorestyle/img/s2cloudless-s2cloudless.png", "visibility": False, "credits": { - "title": "Sentinel-2 cloudless 2016 by EOX IT Services GmbH" - } + "title": 'Sentinel-2 cloudless 2016 by EOX IT Services GmbH' + }, }, { "source": "ol", @@ -401,8 +373,8 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': "title": "Empty Background", "type": "empty", "visibility": False, - "args": ["Empty Background", {"visibility": False}] - } + "args": ["Empty Background", {"visibility": False}], + }, ] if MAPBOX_ACCESS_TOKEN: @@ -413,11 +385,14 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': "name": "MapBox streets-v11", "accessToken": "%s" % MAPBOX_ACCESS_TOKEN, "source": "streets-v11", - "thumbURL": "https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/6/33/23?access_token=%s" % MAPBOX_ACCESS_TOKEN, + "thumbURL": "https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/6/33/23?access_token=%s" + % MAPBOX_ACCESS_TOKEN, "group": "background", - "visibility": True + "visibility": True, } - DEFAULT_MS2_BACKGROUNDS = [MAPBOX_BASEMAPS,] + DEFAULT_MS2_BACKGROUNDS + DEFAULT_MS2_BACKGROUNDS = [ + MAPBOX_BASEMAPS, + ] + DEFAULT_MS2_BACKGROUNDS if BING_API_KEY: BING_BASEMAPS = [ @@ -428,7 +403,7 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': "source": "bing", "group": "background", "apiKey": "{{apiKey}}", - "visibility": True + "visibility": True, }, { "type": "bing", @@ -438,7 +413,7 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': "group": "background", "apiKey": "{{apiKey}}", "thumbURL": "%sstatic/mapstorestyle/img/bing_road_on_demand.png" % SITEURL, - "visibility": False + "visibility": False, }, { "type": "bing", @@ -448,7 +423,7 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': "group": "background", "apiKey": "{{apiKey}}", "thumbURL": "%sstatic/mapstorestyle/img/bing_aerial_w_labels.png" % SITEURL, - "visibility": False + "visibility": False, }, { "type": "bing", @@ -458,64 +433,67 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': "group": "background", "apiKey": "{{apiKey}}", "thumbURL": "%sstatic/mapstorestyle/img/bing_canvas_dark.png" % SITEURL, - "visibility": False - } + "visibility": False, + }, ] - DEFAULT_MS2_BACKGROUNDS = [BING_BASEMAPS, ] + DEFAULT_MS2_BACKGROUNDS + DEFAULT_MS2_BACKGROUNDS = [ + BING_BASEMAPS, + ] + DEFAULT_MS2_BACKGROUNDS MAPSTORE_BASELAYERS = DEFAULT_MS2_BACKGROUNDS # MAPSTORE_BASELAYERS_SOURCES allow to configure tilematrix sets for wmts layers - MAPSTORE_BASELAYERS_SOURCES = os.environ.get('MAPSTORE_BASELAYERS_SOURCES', {}) + MAPSTORE_BASELAYERS_SOURCES = os.environ.get("MAPSTORE_BASELAYERS_SOURCES", {}) # -- END Client Hooksets Setup LOGGING = { - 'version': 1, - 'disable_existing_loggers': True, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s %(asctime)s %(module)s %(process)d ' - '%(thread)d %(message)s' - }, - 'simple': { - 'format': '%(message)s', + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "verbose": {"format": "%(levelname)s %(asctime)s %(module)s %(process)d " "%(thread)d %(message)s"}, + "simple": { + "format": "%(message)s", }, }, - 'filters': { - 'require_debug_false': { - '()': 'django.utils.log.RequireDebugFalse' - } - }, - 'handlers': { - 'console': { - 'level': 'INFO', - 'class': 'logging.StreamHandler', - 'formatter': 'simple' + "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, + "handlers": { + "console": {"level": "INFO", "class": "logging.StreamHandler", "formatter": "simple"}, + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler", }, - 'mail_admins': { - 'level': 'ERROR', - 'filters': ['require_debug_false'], - 'class': 'django.utils.log.AdminEmailHandler', - } }, "loggers": { "django": { - "handlers": ["console"], "level": "ERROR", }, + "handlers": ["console"], + "level": "ERROR", + }, "geonode": { - "handlers": ["console"], "level": "INFO", }, + "handlers": ["console"], + "level": "INFO", + }, "geoserver-restconfig.catalog": { - "handlers": ["console"], "level": "ERROR", }, + "handlers": ["console"], + "level": "ERROR", + }, "owslib": { - "handlers": ["console"], "level": "ERROR", }, + "handlers": ["console"], + "level": "ERROR", + }, "pycsw": { - "handlers": ["console"], "level": "INFO", }, + "handlers": ["console"], + "level": "INFO", + }, "celery": { - 'handlers': ["console"], 'level': 'ERROR', }, + "handlers": ["console"], + "level": "ERROR", + }, }, } # Additional settings -X_FRAME_OPTIONS = 'ALLOW-FROM %s' % SITEURL +X_FRAME_OPTIONS = "ALLOW-FROM %s" % SITEURL CORS_ALLOW_ALL_ORIGINS = True GEOIP_PATH = "/usr/local/share/GeoIP" diff --git a/geonode/people/socialaccount/providers/geonode_openid_connect/apps.py b/geonode/people/socialaccount/providers/geonode_openid_connect/apps.py new file mode 100644 index 00000000000..ce9d3da1ad3 --- /dev/null +++ b/geonode/people/socialaccount/providers/geonode_openid_connect/apps.py @@ -0,0 +1,24 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from django.apps import AppConfig + + +class GeoNodeOpenIdConnectAppConfig(AppConfig): + name = "geonode.people.socialaccount.providers.geonode_openid_connect" + verbose_name = "GeoNode OpenId Connect" diff --git a/geonode/proxy/__init__.py b/geonode/proxy/__init__.py index 79177e00bdd..da86ef5219a 100644 --- a/geonode/proxy/__init__.py +++ b/geonode/proxy/__init__.py @@ -1,6 +1,6 @@ ######################################################################### # -# Copyright (C) 2016 OSGeo +# Copyright (C) 2023 OSGeo # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/geonode/proxy/apps.py b/geonode/proxy/apps.py new file mode 100644 index 00000000000..9901d333798 --- /dev/null +++ b/geonode/proxy/apps.py @@ -0,0 +1,24 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from django.apps import AppConfig + + +class GeoNodeProxyAppConfig(AppConfig): + name = "geonode.proxy" + verbose_name = "GeoNode Proxy" diff --git a/geonode/security/__init__.py b/geonode/security/__init__.py index 79177e00bdd..da86ef5219a 100644 --- a/geonode/security/__init__.py +++ b/geonode/security/__init__.py @@ -1,6 +1,6 @@ ######################################################################### # -# Copyright (C) 2016 OSGeo +# Copyright (C) 2023 OSGeo # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/geonode/security/apps.py b/geonode/security/apps.py new file mode 100644 index 00000000000..3475f4d05f1 --- /dev/null +++ b/geonode/security/apps.py @@ -0,0 +1,24 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from django.apps import AppConfig + + +class GeoNodeSecurityAppConfig(AppConfig): + name = "geonode.security" + verbose_name = "GeoNode Security" diff --git a/geonode/tasks/apps.py b/geonode/tasks/apps.py new file mode 100644 index 00000000000..9b49856a418 --- /dev/null +++ b/geonode/tasks/apps.py @@ -0,0 +1,24 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +from django.apps import AppConfig + + +class GeoNodeTasksAppConfig(AppConfig): + name = "geonode.tasks" + verbose_name = "GeoNode Tasks" From d72cd4f07de4a5912517f72a27a09074432c44d8 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 17 Aug 2023 12:33:25 +0200 Subject: [PATCH 117/330] [Fixes #11383] Remove explicit dependency from wandb (#11382) * Remove explicit dependency from wandb * removed wandb from setup.cfg --- requirements.txt | 1 - setup.cfg | 1 - 2 files changed, 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 10805de234a..4c40148f408 100644 --- a/requirements.txt +++ b/requirements.txt @@ -171,7 +171,6 @@ webdriver_manager==4.0.0 # Security and audit mistune==3.0.1 -wandb==0.15.7 protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/setup.cfg b/setup.cfg index 04f081e7ac5..516ea95be42 100644 --- a/setup.cfg +++ b/setup.cfg @@ -196,7 +196,6 @@ install_requires = # Security and audit mistune==3.0.1 - wandb==0.15.7 protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability From 0f874707946b0a0dfe6df47a272b8cffc593f602 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 17 Aug 2023 13:06:02 +0200 Subject: [PATCH 118/330] Enable IFC format (#11395) --- geonode/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/geonode/settings.py b/geonode/settings.py index 72f37a84b6f..73e6fea14da 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -652,6 +652,7 @@ "glb", "pcd", "gltf", + "ifc", ] if os.getenv("ALLOWED_DOCUMENT_TYPES") is None else re.split(r" *[,|:;] *", os.getenv("ALLOWED_DOCUMENT_TYPES")) From d95db9f511849c7369fc19f88d0af4cc488be6df Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Fri, 18 Aug 2023 12:12:32 +0200 Subject: [PATCH 119/330] [Fixes #11347] Import resources page displays service name instead of title (#11385) * Resolve issue 11347 by showing more informative and readable service title instead of name * fix: #11347 by giving better description of service * small improvements --------- Co-authored-by: sahilsekr42 Co-authored-by: Alessio Fabiani --- geonode/services/templates/services/service_detail.html | 2 +- geonode/services/templates/services/service_remove.html | 4 ++-- .../templates/services/service_resources_harvest.html | 4 +--- geonode/services/views.py | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/geonode/services/templates/services/service_detail.html b/geonode/services/templates/services/service_detail.html index beb8ac84b12..08c1ef70042 100644 --- a/geonode/services/templates/services/service_detail.html +++ b/geonode/services/templates/services/service_detail.html @@ -4,7 +4,7 @@ {% block body %}
    -

    {{service.title|default:service.name}}

    +

    {{service.title|default:service.name}}

    {% trans "Type" %}: {{service.service_type}}

    {% trans "URL" %}: {{service.base_url}}

    {% trans "Abstract" %}: {{service.abstract}}

    diff --git a/geonode/services/templates/services/service_remove.html b/geonode/services/templates/services/service_remove.html index 90a2f8fa9e9..3ce551b58b6 100644 --- a/geonode/services/templates/services/service_remove.html +++ b/geonode/services/templates/services/service_remove.html @@ -1,7 +1,7 @@ {% extends "services/services_base.html" %} {% load i18n %} -{% block title %} {{ service.name }} - {{ block.super }} {% endblock %} +{% block title %} {{service.title|default:service.name}} - {{ block.super }} {% endblock %} {% block body %}
    -

    {% trans "Are you sure you want to remove" %} {{ service.name }}?

    +

    {% trans "Are you sure you want to remove" %} {{service.title|default:service.name}}?

    {% csrf_token %} diff --git a/geonode/services/templates/services/service_resources_harvest.html b/geonode/services/templates/services/service_resources_harvest.html index 4541223354e..8de1f454f43 100644 --- a/geonode/services/templates/services/service_resources_harvest.html +++ b/geonode/services/templates/services/service_resources_harvest.html @@ -8,9 +8,7 @@ {% block body %}
    {% if resources %} diff --git a/geonode/services/views.py b/geonode/services/views.py index 12edf78e71a..dc67c212c67 100644 --- a/geonode/services/views.py +++ b/geonode/services/views.py @@ -352,5 +352,5 @@ def remove_service(request, service_id): elif request.method == "POST": service.dataset_set.all().delete() service.delete() - messages.add_message(request, messages.INFO, _(f"Service {service.name} has been deleted")) + messages.add_message(request, messages.INFO, _(f"Service {service.title} has been deleted")) return HttpResponseRedirect(reverse("services")) From dc6268c11643d713884d4f43132061b97bd68d0c Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Tue, 22 Aug 2023 19:07:26 +0200 Subject: [PATCH 120/330] Align to GeoNode master (4.2.0) and improve/fix docker initialization (#11308) * - Externalizing "UID" filed from the "settings.py" * - Pep8 issues * Align to GeoNode master (4.2.0) and improve/fix docker initialization * - Update test docker-docker-compose-test * Update .env_test test file * Bump to 4.2.0dev0 * Update .circleci config.yml file * Increase the Django healthcheck timeout time * Typo on GEOSERVER_JAVA_OPTS * Update docker-compose-test django healthcheck with retries * Update docker-compose-test django healthcheck with retries * Fix test cases * - Removing the fixed "IS_MANAGER" field in favor of a pluggable generic "OpenIDGroupRoleMapper" class * - Fix docker compose structure and .env.sample labels * Configure Django apps * fix typos * fix * - Update docker-compose-test.yml image names * - Black formatting issues * removed Azure ID env vars * dropped PUBLIC_PORT * gitignore .env file * disable abbreviated params * renamed OGC credential vars and introduced OGC_SERVER_FACTORY_PASSWORD * removed useless OGC_ADMIN_* vars * renamved to GEOSERVER_FACTORY_PASSWORD * use django-admin instead of django-admin.py * fixed monitoring fixtures * fixes blowfish deprecation warning * fix to update method * break loop if succeeding * fix formmatting * - Alingning docker-compose-dev to docker-compose * set nginx image version * remove wrong folder pushed by error * set nginx image version in all compose files * Improvements to create-envfile documentation * move location of Geoserver LB host variables * Remove geoserver_ui variable from .env.sample --------- Co-authored-by: afabiani Co-authored-by: Giovanni Allegri --- .circleci/config.yml | 24 +- .env => .env.sample | 93 ++++---- .env_dev | 49 ++-- .env_local | 47 ++-- .env_test | 53 +++-- .gitignore | 2 + Dockerfile | 9 +- README.md | 39 +++- create-envfile.py | 185 +++++++++++++++ docker-compose-dev.yml | 62 +++--- docker-compose-test.yml | 60 +++-- docker-compose.yml | 38 ++-- entrypoint.sh | 4 +- geonode/api/__init__.py | 1 + geonode/base/api/tests.py | 8 +- geonode/catalogue/__init__.py | 1 + geonode/catalogue/metadataxsl/__init__.py | 1 + geonode/celery_app.py | 4 +- geonode/documents/exif/__init__.py | 1 + geonode/favorite/__init__.py | 1 + geonode/geoserver/createlayer/__init__.py | 1 + geonode/geoserver/processing/__init__.py | 1 + geonode/local_settings.py.geoserver.sample | 8 +- geonode/people/adapters.py | 33 ++- geonode/people/profileextractors.py | 20 +- .../geonode_openid_connect/provider.py | 6 +- .../providers/geonode_openid_connect/tests.py | 1 - geonode/proxy/__init__.py | 1 + geonode/security/__init__.py | 1 + geonode/settings.py | 35 +-- geonode/tasks/__init__.py | 1 + geonode/upload/tests/test_settings.py | 10 +- geonode/utils.py | 4 +- package/support/geonode.local_settings | 12 +- pavement.py | 4 +- requirements.txt | 1 + scripts/docker/nginx/Dockerfile | 5 +- scripts/docker/nginx/docker-entrypoint.sh | 23 +- scripts/docker/nginx/geonode.conf.envsubst | 90 +++----- scripts/docker/nginx/nginx.conf.envsubst | 2 +- tasks.py | 210 ++++++++---------- uwsgi.ini | 14 +- 42 files changed, 716 insertions(+), 449 deletions(-) rename .env => .env.sample (73%) create mode 100644 create-envfile.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 60cfd1c921e..9babc1a6978 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -23,12 +23,12 @@ jobs: - run: name: Build the stack - command: docker-compose -f docker-compose-test.yml build --no-cache + command: docker-compose --env-file .env_test -f docker-compose-test.yml build --no-cache working_directory: ./ - run: name: Start the stack - command: docker-compose -f docker-compose-test.yml up -d + command: docker-compose --env-file .env_test -f docker-compose-test.yml up -d working_directory: ./ - run: @@ -78,21 +78,21 @@ jobs: - run: name: Run test suite command: | - docker-compose -f docker-compose-test.yml exec db psql -U postgres -c 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE pid <> pg_backend_pid();' - docker-compose -f docker-compose-test.yml exec db createdb -U postgres -T postgres test_postgres - docker-compose -f docker-compose-test.yml exec db createdb -U postgres -T postgres test_geonode - docker-compose -f docker-compose-test.yml exec db createdb -U postgres -T postgres test_geonode_data - docker-compose -f docker-compose-test.yml exec db psql -U postgres -d test_geonode -c 'CREATE EXTENSION IF NOT EXISTS postgis;' - docker-compose -f docker-compose-test.yml exec db psql -U postgres -d test_geonode_data -c 'CREATE EXTENSION IF NOT EXISTS postgis;' - docker-compose -f docker-compose-test.yml exec django bash -c '<>' + docker-compose --env-file .env_test -f docker-compose-test.yml exec db psql -U postgres -c 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE pid <> pg_backend_pid();' + docker-compose --env-file .env_test -f docker-compose-test.yml exec db createdb -U postgres -T postgres test_postgres + docker-compose --env-file .env_test -f docker-compose-test.yml exec db createdb -U postgres -T postgres test_geonode + docker-compose --env-file .env_test -f docker-compose-test.yml exec db createdb -U postgres -T postgres test_geonode_data + docker-compose --env-file .env_test -f docker-compose-test.yml exec db psql -U postgres -d test_geonode -c 'CREATE EXTENSION IF NOT EXISTS postgis;' + docker-compose --env-file .env_test -f docker-compose-test.yml exec db psql -U postgres -d test_geonode_data -c 'CREATE EXTENSION IF NOT EXISTS postgis;' + docker-compose --env-file .env_test -f docker-compose-test.yml exec django bash -c '<>' working_directory: ./ no_output_timeout: 10m - run: name: Run code quality checks command: | - docker-compose -f docker-compose-test.yml exec django bash -c 'black --check geonode' - docker-compose -f docker-compose-test.yml exec django bash -c 'flake8 geonode' - docker-compose -f docker-compose-test.yml exec django bash -c 'codecov; bash <(curl -s https://codecov.io/bash) -t 2c0e7780-1640-45f0-93a3-e103b057d8c8' + docker-compose --env-file .env_test -f docker-compose-test.yml exec django bash -c 'black --check geonode' + docker-compose --env-file .env_test -f docker-compose-test.yml exec django bash -c 'flake8 geonode' + docker-compose --env-file .env_test -f docker-compose-test.yml exec django bash -c 'codecov; bash <(curl -s https://codecov.io/bash) -t 2c0e7780-1640-45f0-93a3-e103b057d8c8' working_directory: ./ workflows: diff --git a/.env b/.env.sample similarity index 73% rename from .env rename to .env.sample index 492278acb55..b74e48d371c 100644 --- a/.env +++ b/.env.sample @@ -1,6 +1,4 @@ COMPOSE_PROJECT_NAME=geonode -DOCKERHOST= -DOCKER_HOST_IP= # See https://github.com/containers/podman/issues/13889 # DOCKER_BUILDKIT=0 DOCKER_ENV=production @@ -13,33 +11,29 @@ C_FORCE_ROOT=1 FORCE_REINIT=false INVOKE_LOG_STDOUT=true -# LANGUAGE_CODE=pt +# LANGUAGE_CODE=it-it # LANGUAGES=(('en-us','English'),('it-it','Italiano')) DJANGO_SETTINGS_MODULE=geonode.settings GEONODE_INSTANCE_NAME=geonode -GEONODE_LB_HOST_IP= -GEONODE_LB_PORT= -PUBLIC_PORT=80 -NGINX_BASE_URL= # ################# # backend # ################# POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres +POSTGRES_PASSWORD={pgpwd} GEONODE_DATABASE=geonode GEONODE_DATABASE_USER=geonode -GEONODE_DATABASE_PASSWORD=geonode +GEONODE_DATABASE_PASSWORD={dbpwd} GEONODE_GEODATABASE=geonode_data GEONODE_GEODATABASE_USER=geonode_data -GEONODE_GEODATABASE_PASSWORD=geonode_data +GEONODE_GEODATABASE_PASSWORD={geodbpwd} GEONODE_DATABASE_SCHEMA=public GEONODE_GEODATABASE_SCHEMA=public DATABASE_HOST=db DATABASE_PORT=5432 -DATABASE_URL=postgis://geonode:geonode@db:5432/geonode -GEODATABASE_URL=postgis://geonode_data:geonode_data@db:5432/geonode_data +DATABASE_URL=postgis://geonode:{dbpwd}@db:5432/geonode +GEODATABASE_URL=postgis://geonode_data:{geodbpwd}@db:5432/geonode_data GEONODE_DB_CONN_MAX_AGE=0 GEONODE_DB_CONN_TOUT=5 DEFAULT_BACKEND_DATASTORE=datastore @@ -47,9 +41,9 @@ BROKER_URL=amqp://guest:guest@rabbitmq:5672/ CELERY_BEAT_SCHEDULER=celery.beat:PersistentScheduler ASYNC_SIGNALS=True -SITEURL=https://localhost/ +SITEURL={siteurl}/ -ALLOWED_HOSTS=['django', '*'] +ALLOWED_HOSTS="['django', '{hostname}']" # Data Uploader DEFAULT_BACKEND_UPLOADER=geonode.importer @@ -64,13 +58,14 @@ HAYSTACK_SEARCH_RESULTS_PER_PAGE=200 # nginx # HTTPD Server # ################# -GEONODE_LB_HOST_IP=localhost -GEONODE_LB_PORT=80 +GEONODE_LB_HOST_IP=django +GEONODE_LB_PORT=8000 +NGINX_BASE_URL={siteurl} # IP or domain name and port where the server can be reached on HTTPS (leave HOST empty if you want to use HTTP only) # port where the server can be reached on HTTPS -HTTP_HOST= -HTTPS_HOST=localhost +HTTP_HOST={http_host} +HTTPS_HOST={https_host} HTTP_PORT=80 HTTPS_PORT=443 @@ -80,8 +75,8 @@ HTTPS_PORT=443 # disabled : we do not get a certificate at all (a placeholder certificate will be used) # staging : we get staging certificates (are invalid, but allow to test the process completely and have much higher limit rates) # production : we get a normal certificate (default) -# LETSENCRYPT_MODE=disabled -LETSENCRYPT_MODE=staging +LETSENCRYPT_MODE={letsencrypt_mode} +# LETSENCRYPT_MODE=staging # LETSENCRYPT_MODE=production RESOLVER=127.0.0.11 @@ -89,11 +84,13 @@ RESOLVER=127.0.0.11 # ################# # geoserver # ################# -GEOSERVER_WEB_UI_LOCATION=https://localhost/geoserver/ -GEOSERVER_PUBLIC_LOCATION=https://localhost/geoserver/ -GEOSERVER_LOCATION=http://geoserver:8080/geoserver/ +GEOSERVER_LB_HOST_IP=geoserver +GEOSERVER_LB_PORT=8080 +GEOSERVER_WEB_UI_LOCATION={siteurl}/geoserver/ +GEOSERVER_PUBLIC_LOCATION={siteurl}/geoserver/ +GEOSERVER_LOCATION=http://${GEOSERVER_LB_HOST_IP}:${GEOSERVER_LB_PORT}/geoserver/ GEOSERVER_ADMIN_USER=admin -GEOSERVER_ADMIN_PASSWORD=geoserver +GEOSERVER_ADMIN_PASSWORD={geoserverpwd} OGC_REQUEST_TIMEOUT=30 OGC_REQUEST_MAX_RETRIES=1 @@ -104,7 +101,7 @@ OGC_REQUEST_POOL_CONNECTIONS=10 # Java Options & Memory ENABLE_JSONP=true outFormat=text/javascript -GEOSERVER_JAVA_OPTS="-Djava.awt.headless=true -Xms2G -Xmx4G -Dgwc.context.suffix=gwc -XX:+UnlockDiagnosticVMOptions -XX:+LogVMOutput -XX:LogFile=/var/log/jvm.log -XX:PerfDataSamplingInterval=500 -XX:SoftRefLRUPolicyMSPerMB=36000 -XX:-UseGCOverheadLimit -XX:+UseConcMarkSweepGC -XX:ParallelGCThreads=4 -Dfile.encoding=UTF8 -Djavax.servlet.request.encoding=UTF-8 -Djavax.servlet.response.encoding=UTF-8 -Duser.timezone=GMT -Dorg.geotools.shapefile.datetime=false -DGS-SHAPEFILE-CHARSET=UTF-8 -DGEOSERVER_CSRF_DISABLED=true -DPRINT_BASE_URL=http://geoserver:8080/geoserver/pdf -DALLOW_ENV_PARAMETRIZATION=true -Xbootclasspath/a:/usr/local/tomcat/webapps/geoserver/WEB-INF/lib/marlin-0.9.3-Unsafe.jar -Dsun.java2d.renderer=org.marlin.pisces.MarlinRenderingEngine" +GEOSERVER_JAVA_OPTS='-Djava.awt.headless=true -Xms4G -Xmx4G -Dgwc.context.suffix=gwc -XX:+UnlockDiagnosticVMOptions -XX:+LogVMOutput -XX:LogFile=/var/log/jvm.log -XX:PerfDataSamplingInterval=500 -XX:SoftRefLRUPolicyMSPerMB=36000 -XX:-UseGCOverheadLimit -XX:ParallelGCThreads=4 -Dfile.encoding=UTF8 -Djavax.servlet.request.encoding=UTF-8 -Djavax.servlet.response.encoding=UTF-8 -Duser.timezone=GMT -Dorg.geotools.shapefile.datetime=false -DGS-SHAPEFILE-CHARSET=UTF-8 -DGEOSERVER_CSRF_DISABLED=true -DPRINT_BASE_URL={geoserver_ui}/geoserver/pdf -DALLOW_ENV_PARAMETRIZATION=true -Xbootclasspath/a:/usr/local/tomcat/webapps/geoserver/WEB-INF/lib/marlin-0.9.3-Unsafe.jar -Dsun.java2d.renderer=org.marlin.pisces.MarlinRenderingEngine' # ################# # Security @@ -117,8 +114,8 @@ GEOSERVER_JAVA_OPTS="-Djava.awt.headless=true -Xms2G -Xmx4G -Dgwc.context.suffix # in DB will honored. ADMIN_USERNAME=admin -ADMIN_PASSWORD=admin -ADMIN_EMAIL=admin@localhost +ADMIN_PASSWORD={geonodepwd} +ADMIN_EMAIL={email} # EMAIL Notifications EMAIL_ENABLE=False @@ -129,29 +126,36 @@ DJANGO_EMAIL_HOST_USER= DJANGO_EMAIL_HOST_PASSWORD= DJANGO_EMAIL_USE_TLS=False DJANGO_EMAIL_USE_SSL=False -DEFAULT_FROM_EMAIL='GeoNode ' +DEFAULT_FROM_EMAIL='{email}' # eg Company # Session/Access Control LOCKDOWN_GEONODE=False -CORS_ALLOW_ALL_ORIGINS=True X_FRAME_OPTIONS="SAMEORIGIN" SESSION_EXPIRED_CONTROL_ENABLED=True DEFAULT_ANONYMOUS_VIEW_PERMISSION=True DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION=True +CORS_ALLOW_ALL_ORIGINS=True +GEOSERVER_CORS_ENABLED=True +GEOSERVER_CORS_ALLOWED_ORIGINS=* +GEOSERVER_CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,HEAD,OPTIONS +GEOSERVER_CORS_ALLOWED_HEADERS=* + # Users Registration ACCOUNT_OPEN_SIGNUP=True ACCOUNT_EMAIL_REQUIRED=True ACCOUNT_APPROVAL_REQUIRED=False ACCOUNT_CONFIRM_EMAIL_ON_GET=False ACCOUNT_EMAIL_VERIFICATION=none +ACCOUNT_EMAIL_CONFIRMATION_EMAIL=False +ACCOUNT_EMAIL_CONFIRMATION_REQUIRED=False ACCOUNT_AUTHENTICATION_METHOD=username_email AUTO_ASSIGN_REGISTERED_MEMBERS_TO_REGISTERED_MEMBERS_GROUP_NAME=True # OAuth2 OAUTH2_API_KEY= -OAUTH2_CLIENT_ID=Jrchz2oPY3akmzndmgUTYrs9gczlgoV20YPSvqaV -OAUTH2_CLIENT_SECRET=rCnp5txobUo83EpQEblM8fVj3QT5zb5qRfxNsuPzCqZaiRyIoxM4jdgMiZKFfePBHYXCLd7B8NlkfDBY9HKeIQPcy5Cp08KQNpRHQbjpLItDHv12GvkSeXp6OxaUETv3 +OAUTH2_CLIENT_ID={clientid} +OAUTH2_CLIENT_SECRET={clientsecret} # GeoNode APIs API_LOCKDOWN=False @@ -161,9 +165,9 @@ TASTYPIE_APIKEY= # Production and # Monitoring # ################# -DEBUG=False +DEBUG={debug} -SECRET_KEY='myv-y4#7j-d*p-__@j#*3z@!y24fz8%^z2v6atuy4bo9vqr1_a' +SECRET_KEY='{secret_key}' STATIC_ROOT=/mnt/volumes/statics/static/ MEDIA_ROOT=/mnt/volumes/statics/uploaded/ @@ -177,7 +181,7 @@ MEMCACHED_LOCATION=127.0.0.1:11211 MEMCACHED_LOCK_EXPIRE=3600 MEMCACHED_LOCK_TIMEOUT=10 -MAX_DOCUMENT_SIZE=2 +MAX_DOCUMENT_SIZE=200 CLIENT_RESULTS_LIMIT=5 API_LIMIT_PER_PAGE=1000 @@ -207,13 +211,6 @@ FAVORITE_ENABLED=True RESOURCE_PUBLISHING=False ADMIN_MODERATE_UPLOADS=False -# PostgreSQL -POSTGRESQL_MAX_CONNECTIONS=200 - -# Upload Size Limiting -DEFAULT_MAX_UPLOAD_SIZE=5368709120 -DEFAULT_MAX_PARALLEL_UPLOADS_PER_USER=100 - # LDAP LDAP_ENABLED=False LDAP_SERVER_URL=ldap:// @@ -232,10 +229,22 @@ LDAP_GROUP_PROFILE_MEMBER_ATTR=uniqueMember # ## # Note right autoscale value must coincide with worker concurrency value # CELERY__AUTOSCALE_VALUES="15,10" -# CELERY__WORKER_CONCURRENCY="4" +# CELERY__WORKER_CONCURRENCY="10" # ## # CELERY__OPTS="--without-gossip --without-mingle -Ofair -B -E" # CELERY__BEAT_SCHEDULE="/mnt/volumes/statics/celerybeat-schedule" # CELERY__LOG_LEVEL="INFO" # CELERY__LOG_FILE="/var/log/celery.log" # CELERY__WORKER_NAME="worker1@%h" + +# PostgreSQL +POSTGRESQL_MAX_CONNECTIONS=200 + +# Common containers restart policy +RESTART_POLICY_CONDITION="on-failure" +RESTART_POLICY_DELAY="5s" +RESTART_POLICY_MAX_ATTEMPTS="3" +RESTART_POLICY_WINDOW=120s + +DEFAULT_MAX_UPLOAD_SIZE=5368709120 +DEFAULT_MAX_PARALLEL_UPLOADS_PER_USER=5 diff --git a/.env_dev b/.env_dev index 88a2b273a4f..6119f68ebd6 100644 --- a/.env_dev +++ b/.env_dev @@ -1,6 +1,4 @@ COMPOSE_PROJECT_NAME=geonode -DOCKERHOST= -DOCKER_HOST_IP= # See https://github.com/containers/podman/issues/13889 # DOCKER_BUILDKIT=0 DOCKER_ENV=production @@ -13,15 +11,11 @@ C_FORCE_ROOT=1 FORCE_REINIT=false INVOKE_LOG_STDOUT=true -# LANGUAGE_CODE=pt -# LANGUAGES=(('en','English'),('pt','Portuguese')) +# LANGUAGE_CODE=it-it +# LANGUAGES=(('en-us','English'),('it-it','Italiano')) DJANGO_SETTINGS_MODULE=geonode.settings GEONODE_INSTANCE_NAME=geonode -GEONODE_LB_HOST_IP= -GEONODE_LB_PORT= -PUBLIC_PORT=80 -NGINX_BASE_URL= # ################# # backend @@ -64,8 +58,11 @@ HAYSTACK_SEARCH_RESULTS_PER_PAGE=200 # nginx # HTTPD Server # ################# -GEONODE_LB_HOST_IP=localhost -GEONODE_LB_PORT=80 +GEONODE_LB_HOST_IP=django +GEONODE_LB_PORT=8000 +GEOSERVER_LB_HOST_IP=geoserver +GEOSERVER_LB_PORT=8080 +NGINX_BASE_URL=http://localhost # IP or domain name and port where the server can be reached on HTTPS (leave HOST empty if you want to use HTTP only) # port where the server can be reached on HTTPS @@ -95,8 +92,8 @@ GEOSERVER_LOCATION=http://localhost:8080/geoserver/ GEOSERVER_ADMIN_USER=admin GEOSERVER_ADMIN_PASSWORD=geoserver -OGC_REQUEST_TIMEOUT=60 -OGC_REQUEST_MAX_RETRIES=0 +OGC_REQUEST_TIMEOUT=30 +OGC_REQUEST_MAX_RETRIES=1 OGC_REQUEST_BACKOFF_FACTOR=0.3 OGC_REQUEST_POOL_MAXSIZE=10 OGC_REQUEST_POOL_CONNECTIONS=10 @@ -104,12 +101,18 @@ OGC_REQUEST_POOL_CONNECTIONS=10 # Java Options & Memory ENABLE_JSONP=true outFormat=text/javascript -GEOSERVER_JAVA_OPTS="-Djava.awt.headless=true -Xms2G -Xmx4G -Dgwc.context.suffix=gwc -XX:+UnlockDiagnosticVMOptions -XX:+LogVMOutput -XX:LogFile=/var/log/jvm.log -XX:PerfDataSamplingInterval=500 -XX:SoftRefLRUPolicyMSPerMB=36000 -XX:-UseGCOverheadLimit -XX:+UseConcMarkSweepGC -XX:ParallelGCThreads=4 -Dfile.encoding=UTF8 -Djavax.servlet.request.encoding=UTF-8 -Djavax.servlet.response.encoding=UTF-8 -Duser.timezone=GMT -Dorg.geotools.shapefile.datetime=false -DGS-SHAPEFILE-CHARSET=UTF-8 -DGEOSERVER_CSRF_DISABLED=true -DPRINT_BASE_URL=http://localhost:8080/geoserver/pdf -DALLOW_ENV_PARAMETRIZATION=true -Xbootclasspath/a:/usr/local/tomcat/webapps/geoserver/WEB-INF/lib/marlin-0.9.3-Unsafe.jar -Dsun.java2d.renderer=org.marlin.pisces.MarlinRenderingEngine" +GEOSERVER_JAVA_OPTS='-Djava.awt.headless=true -Xms4G -Xmx4G -Dgwc.context.suffix=gwc -XX:+UnlockDiagnosticVMOptions -XX:+LogVMOutput -XX:LogFile=/var/log/jvm.log -XX:PerfDataSamplingInterval=500 -XX:SoftRefLRUPolicyMSPerMB=36000 -XX:-UseGCOverheadLimit -XX:ParallelGCThreads=4 -Dfile.encoding=UTF8 -Djavax.servlet.request.encoding=UTF-8 -Djavax.servlet.response.encoding=UTF-8 -Duser.timezone=GMT -Dorg.geotools.shapefile.datetime=false -DGS-SHAPEFILE-CHARSET=UTF-8 -DGEOSERVER_CSRF_DISABLED=true -DPRINT_BASE_URL=http://localhost:8080/geoserver/pdf -DALLOW_ENV_PARAMETRIZATION=true -Xbootclasspath/a:/usr/local/tomcat/webapps/geoserver/WEB-INF/lib/marlin-0.9.3-Unsafe.jar -Dsun.java2d.renderer=org.marlin.pisces.MarlinRenderingEngine' # ################# # Security # ################# # Admin Settings +# +# ADMIN_PASSWORD is used to overwrite the GeoNode admin password **ONLY** the first time +# GeoNode is run. If you need to overwrite it again, you need to set the env var FORCE_REINIT, +# otherwise the invoke updateadmin task will be skipped and the current password already stored +# in DB will honored. + ADMIN_USERNAME=admin ADMIN_PASSWORD=admin ADMIN_EMAIL=admin@localhost @@ -127,18 +130,25 @@ DEFAULT_FROM_EMAIL='GeoNode ' # Session/Access Control LOCKDOWN_GEONODE=False -CORS_ALLOW_ALL_ORIGINS=True X_FRAME_OPTIONS="SAMEORIGIN" SESSION_EXPIRED_CONTROL_ENABLED=True DEFAULT_ANONYMOUS_VIEW_PERMISSION=True DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION=True +CORS_ALLOW_ALL_ORIGINS=True +GEOSERVER_CORS_ENABLED=True +GEOSERVER_CORS_ALLOWED_ORIGINS=* +GEOSERVER_CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,HEAD,OPTIONS +GEOSERVER_CORS_ALLOWED_HEADERS=* + # Users Registration ACCOUNT_OPEN_SIGNUP=True ACCOUNT_EMAIL_REQUIRED=True ACCOUNT_APPROVAL_REQUIRED=False ACCOUNT_CONFIRM_EMAIL_ON_GET=False ACCOUNT_EMAIL_VERIFICATION=none +ACCOUNT_EMAIL_CONFIRMATION_EMAIL=False +ACCOUNT_EMAIL_CONFIRMATION_REQUIRED=False ACCOUNT_AUTHENTICATION_METHOD=username_email AUTO_ASSIGN_REGISTERED_MEMBERS_TO_REGISTERED_MEMBERS_GROUP_NAME=True @@ -171,7 +181,7 @@ MEMCACHED_LOCATION=127.0.0.1:11211 MEMCACHED_LOCK_EXPIRE=3600 MEMCACHED_LOCK_TIMEOUT=10 -MAX_DOCUMENT_SIZE=2 +MAX_DOCUMENT_SIZE=200 CLIENT_RESULTS_LIMIT=5 API_LIMIT_PER_PAGE=1000 @@ -204,6 +214,11 @@ ADMIN_MODERATE_UPLOADS=False # PostgreSQL POSTGRESQL_MAX_CONNECTIONS=200 -# Upload Size Limiting +# Common containers restart policy +RESTART_POLICY_CONDITION="on-failure" +RESTART_POLICY_DELAY="5s" +RESTART_POLICY_MAX_ATTEMPTS="3" +RESTART_POLICY_WINDOW=120s + DEFAULT_MAX_UPLOAD_SIZE=5368709120 -DEFAULT_MAX_PARALLEL_UPLOADS_PER_USER=100 \ No newline at end of file +DEFAULT_MAX_PARALLEL_UPLOADS_PER_USER=5 diff --git a/.env_local b/.env_local index a19213926f2..48cba183d81 100644 --- a/.env_local +++ b/.env_local @@ -1,6 +1,4 @@ COMPOSE_PROJECT_NAME=geonode -DOCKERHOST= -DOCKER_HOST_IP= # See https://github.com/containers/podman/issues/13889 # DOCKER_BUILDKIT=0 DOCKER_ENV=production @@ -13,15 +11,11 @@ C_FORCE_ROOT=1 FORCE_REINIT=false INVOKE_LOG_STDOUT=true -# LANGUAGE_CODE=pt -# LANGUAGES=(('en','English'),('pt','Portuguese')) +# LANGUAGE_CODE=it-it +# LANGUAGES=(('en-us','English'),('it-it','Italiano')) DJANGO_SETTINGS_MODULE=geonode.settings GEONODE_INSTANCE_NAME=geonode -GEONODE_LB_HOST_IP= -GEONODE_LB_PORT= -PUBLIC_PORT=80 -NGINX_BASE_URL= # ################# # backend @@ -64,8 +58,11 @@ HAYSTACK_SEARCH_RESULTS_PER_PAGE=200 # nginx # HTTPD Server # ################# -GEONODE_LB_HOST_IP=localhost -GEONODE_LB_PORT=80 +GEONODE_LB_HOST_IP=django +GEONODE_LB_PORT=8000 +GEOSERVER_LB_HOST_IP=geoserver +GEOSERVER_LB_PORT=8080 +NGINX_BASE_URL=https://localhost # IP or domain name and port where the server can be reached on HTTPS (leave HOST empty if you want to use HTTP only) # port where the server can be reached on HTTPS @@ -104,12 +101,18 @@ OGC_REQUEST_POOL_CONNECTIONS=10 # Java Options & Memory ENABLE_JSONP=true outFormat=text/javascript -GEOSERVER_JAVA_OPTS="-Djava.awt.headless=true -Xms2G -Xmx4G -Dgwc.context.suffix=gwc -XX:+UnlockDiagnosticVMOptions -XX:+LogVMOutput -XX:LogFile=/var/log/jvm.log -XX:PerfDataSamplingInterval=500 -XX:SoftRefLRUPolicyMSPerMB=36000 -XX:-UseGCOverheadLimit -XX:+UseConcMarkSweepGC -XX:ParallelGCThreads=4 -Dfile.encoding=UTF8 -Djavax.servlet.request.encoding=UTF-8 -Djavax.servlet.response.encoding=UTF-8 -Duser.timezone=GMT -Dorg.geotools.shapefile.datetime=false -DGS-SHAPEFILE-CHARSET=UTF-8 -DGEOSERVER_CSRF_DISABLED=true -DPRINT_BASE_URL=http://localhost:8080/geoserver/pdf -DALLOW_ENV_PARAMETRIZATION=true -Xbootclasspath/a:/usr/local/tomcat/webapps/geoserver/WEB-INF/lib/marlin-0.9.3-Unsafe.jar -Dsun.java2d.renderer=org.marlin.pisces.MarlinRenderingEngine" +GEOSERVER_JAVA_OPTS='-Djava.awt.headless=true -Xms4G -Xmx4G -Dgwc.context.suffix=gwc -XX:+UnlockDiagnosticVMOptions -XX:+LogVMOutput -XX:LogFile=/var/log/jvm.log -XX:PerfDataSamplingInterval=500 -XX:SoftRefLRUPolicyMSPerMB=36000 -XX:-UseGCOverheadLimit -XX:ParallelGCThreads=4 -Dfile.encoding=UTF8 -Djavax.servlet.request.encoding=UTF-8 -Djavax.servlet.response.encoding=UTF-8 -Duser.timezone=GMT -Dorg.geotools.shapefile.datetime=false -DGS-SHAPEFILE-CHARSET=UTF-8 -DGEOSERVER_CSRF_DISABLED=true -DPRINT_BASE_URL=http://localhost:8080/geoserver/pdf -DALLOW_ENV_PARAMETRIZATION=true -Xbootclasspath/a:/usr/local/tomcat/webapps/geoserver/WEB-INF/lib/marlin-0.9.3-Unsafe.jar -Dsun.java2d.renderer=org.marlin.pisces.MarlinRenderingEngine' # ################# # Security # ################# # Admin Settings +# +# ADMIN_PASSWORD is used to overwrite the GeoNode admin password **ONLY** the first time +# GeoNode is run. If you need to overwrite it again, you need to set the env var FORCE_REINIT, +# otherwise the invoke updateadmin task will be skipped and the current password already stored +# in DB will honored. + ADMIN_USERNAME=admin ADMIN_PASSWORD=admin ADMIN_EMAIL=admin@localhost @@ -127,18 +130,25 @@ DEFAULT_FROM_EMAIL='GeoNode ' # Session/Access Control LOCKDOWN_GEONODE=False -CORS_ALLOW_ALL_ORIGINS=True X_FRAME_OPTIONS="SAMEORIGIN" SESSION_EXPIRED_CONTROL_ENABLED=True DEFAULT_ANONYMOUS_VIEW_PERMISSION=True DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION=True +CORS_ALLOW_ALL_ORIGINS=True +GEOSERVER_CORS_ENABLED=True +GEOSERVER_CORS_ALLOWED_ORIGINS=* +GEOSERVER_CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,HEAD,OPTIONS +GEOSERVER_CORS_ALLOWED_HEADERS=* + # Users Registration ACCOUNT_OPEN_SIGNUP=True ACCOUNT_EMAIL_REQUIRED=True ACCOUNT_APPROVAL_REQUIRED=False ACCOUNT_CONFIRM_EMAIL_ON_GET=False ACCOUNT_EMAIL_VERIFICATION=none +ACCOUNT_EMAIL_CONFIRMATION_EMAIL=False +ACCOUNT_EMAIL_CONFIRMATION_REQUIRED=False ACCOUNT_AUTHENTICATION_METHOD=username_email AUTO_ASSIGN_REGISTERED_MEMBERS_TO_REGISTERED_MEMBERS_GROUP_NAME=True @@ -171,7 +181,7 @@ MEMCACHED_LOCATION=127.0.0.1:11211 MEMCACHED_LOCK_EXPIRE=3600 MEMCACHED_LOCK_TIMEOUT=10 -MAX_DOCUMENT_SIZE=2 +MAX_DOCUMENT_SIZE=200 CLIENT_RESULTS_LIMIT=5 API_LIMIT_PER_PAGE=1000 @@ -182,7 +192,7 @@ BING_API_KEY= GOOGLE_API_KEY= # Monitoring -MONITORING_ENABLED=True +MONITORING_ENABLED=False MONITORING_DATA_TTL=365 USER_ANALYTICS_ENABLED=True USER_ANALYTICS_GZIP=True @@ -204,6 +214,11 @@ ADMIN_MODERATE_UPLOADS=False # PostgreSQL POSTGRESQL_MAX_CONNECTIONS=200 -# Upload Size Limiting +# Common containers restart policy +RESTART_POLICY_CONDITION="on-failure" +RESTART_POLICY_DELAY="5s" +RESTART_POLICY_MAX_ATTEMPTS="3" +RESTART_POLICY_WINDOW=120s + DEFAULT_MAX_UPLOAD_SIZE=5368709120 -DEFAULT_MAX_PARALLEL_UPLOADS_PER_USER=100 \ No newline at end of file +DEFAULT_MAX_PARALLEL_UPLOADS_PER_USER=5 diff --git a/.env_test b/.env_test index 9eed802d452..fb1910d57b1 100644 --- a/.env_test +++ b/.env_test @@ -1,6 +1,4 @@ COMPOSE_PROJECT_NAME=geonode -DOCKERHOST= -DOCKER_HOST_IP= # See https://github.com/containers/podman/issues/13889 # DOCKER_BUILDKIT=0 DOCKER_ENV=production @@ -13,15 +11,11 @@ C_FORCE_ROOT=1 FORCE_REINIT=false INVOKE_LOG_STDOUT=true -# LANGUAGE_CODE=pt -# LANGUAGES=(('en','English'),('pt','Portuguese')) +# LANGUAGE_CODE=it-it +# LANGUAGES=(('en-us','English'),('it-it','Italiano')) DJANGO_SETTINGS_MODULE=geonode.settings GEONODE_INSTANCE_NAME=geonode -GEONODE_LB_HOST_IP= -GEONODE_LB_PORT= -PUBLIC_PORT=80 -NGINX_BASE_URL= # ################# # backend @@ -47,7 +41,7 @@ BROKER_URL=amqp://guest:guest@rabbitmq:5672/ CELERY_BEAT_SCHEDULER=celery.beat:PersistentScheduler ASYNC_SIGNALS=True -SITEURL=http://localhost:8001/ +SITEURL=http://localhost:8000/ ALLOWED_HOSTS="['django', '*']" @@ -64,15 +58,18 @@ HAYSTACK_SEARCH_RESULTS_PER_PAGE=200 # nginx # HTTPD Server # ################# -GEONODE_LB_HOST_IP=localhost -GEONODE_LB_PORT=80 +GEONODE_LB_HOST_IP=django +GEONODE_LB_PORT=8000 +GEOSERVER_LB_HOST_IP=geoserver +GEOSERVER_LB_PORT=8080 +NGINX_BASE_URL=http://localhost # IP or domain name and port where the server can be reached on HTTPS (leave HOST empty if you want to use HTTP only) # port where the server can be reached on HTTPS HTTP_HOST=localhost HTTPS_HOST= -HTTP_PORT=8001 +HTTP_PORT=8000 HTTPS_PORT=443 # Let's Encrypt certificates for https encryption. You must have a domain name as HTTPS_HOST (doesn't work @@ -89,7 +86,7 @@ RESOLVER=127.0.0.11 # ################# # geoserver # ################# -GEOSERVER_WEB_UI_LOCATION=http://localhost:8001/geoserver/ +GEOSERVER_WEB_UI_LOCATION=http://localhost:8000/geoserver/ GEOSERVER_PUBLIC_LOCATION=http://localhost/geoserver/ GEOSERVER_LOCATION=http://geoserver:8080/geoserver/ GEOSERVER_ADMIN_USER=admin @@ -105,12 +102,12 @@ OGC_REQUEST_POOL_CONNECTIONS=10 # catalogue # ################# CATALOGUE_ENGINE=geonode.catalogue.backends.pycsw_local -CATALOGUE_URL=http://localhost:8001/catalogue/csw +CATALOGUE_URL=http://localhost:8000/catalogue/csw # Java Options & Memory ENABLE_JSONP=true outFormat=text/javascript -GEOSERVER_JAVA_OPTS="-Djava.awt.headless=true -Xms2G -Xmx4G -Dgwc.context.suffix=gwc -XX:+UnlockDiagnosticVMOptions -XX:+LogVMOutput -XX:LogFile=/var/log/jvm.log -XX:PerfDataSamplingInterval=500 -XX:SoftRefLRUPolicyMSPerMB=36000 -XX:-UseGCOverheadLimit -XX:+UseConcMarkSweepGC -XX:ParallelGCThreads=4 -Dfile.encoding=UTF8 -Djavax.servlet.request.encoding=UTF-8 -Djavax.servlet.response.encoding=UTF-8 -Duser.timezone=GMT -Dorg.geotools.shapefile.datetime=false -DGS-SHAPEFILE-CHARSET=UTF-8 -DGEOSERVER_CSRF_DISABLED=true -DPRINT_BASE_URL=http://localhost:8001/geoserver/pdf -DALLOW_ENV_PARAMETRIZATION=true -Xbootclasspath/a:/usr/local/tomcat/webapps/geoserver/WEB-INF/lib/marlin-0.9.3-Unsafe.jar -Dsun.java2d.renderer=org.marlin.pisces.MarlinRenderingEngine" +GEOSERVER_JAVA_OPTS='-Djava.awt.headless=true -Xms4G -Xmx4G -Dgwc.context.suffix=gwc -XX:+UnlockDiagnosticVMOptions -XX:+LogVMOutput -XX:LogFile=/var/log/jvm.log -XX:PerfDataSamplingInterval=500 -XX:SoftRefLRUPolicyMSPerMB=36000 -XX:-UseGCOverheadLimit -XX:ParallelGCThreads=4 -Dfile.encoding=UTF8 -Djavax.servlet.request.encoding=UTF-8 -Djavax.servlet.response.encoding=UTF-8 -Duser.timezone=GMT -Dorg.geotools.shapefile.datetime=false -DGS-SHAPEFILE-CHARSET=UTF-8 -DGEOSERVER_CSRF_DISABLED=true -DPRINT_BASE_URL=http://localhost/geoserver/pdf -DALLOW_ENV_PARAMETRIZATION=true -Xbootclasspath/a:/usr/local/tomcat/webapps/geoserver/WEB-INF/lib/marlin-0.9.3-Unsafe.jar -Dsun.java2d.renderer=org.marlin.pisces.MarlinRenderingEngine' # ################# # Security @@ -139,18 +136,25 @@ DEFAULT_FROM_EMAIL='GeoNode ' # Session/Access Control LOCKDOWN_GEONODE=False -CORS_ALLOW_ALL_ORIGINS=True X_FRAME_OPTIONS="SAMEORIGIN" SESSION_EXPIRED_CONTROL_ENABLED=True DEFAULT_ANONYMOUS_VIEW_PERMISSION=True DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION=True +CORS_ALLOW_ALL_ORIGINS=True +GEOSERVER_CORS_ENABLED=True +GEOSERVER_CORS_ALLOWED_ORIGINS=* +GEOSERVER_CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,HEAD,OPTIONS +GEOSERVER_CORS_ALLOWED_HEADERS=* + # Users Registration ACCOUNT_OPEN_SIGNUP=True ACCOUNT_EMAIL_REQUIRED=True ACCOUNT_APPROVAL_REQUIRED=False ACCOUNT_CONFIRM_EMAIL_ON_GET=False ACCOUNT_EMAIL_VERIFICATION=none +ACCOUNT_EMAIL_CONFIRMATION_EMAIL=False +ACCOUNT_EMAIL_CONFIRMATION_REQUIRED=False ACCOUNT_AUTHENTICATION_METHOD=username_email AUTO_ASSIGN_REGISTERED_MEMBERS_TO_REGISTERED_MEMBERS_GROUP_NAME=True @@ -186,7 +190,7 @@ MEMCACHED_LOCATION=127.0.0.1:11211 MEMCACHED_LOCK_EXPIRE=3600 MEMCACHED_LOCK_TIMEOUT=10 -MAX_DOCUMENT_SIZE=2 +MAX_DOCUMENT_SIZE=200 CLIENT_RESULTS_LIMIT=5 API_LIMIT_PER_PAGE=1000 @@ -219,6 +223,17 @@ ADMIN_MODERATE_UPLOADS=False # PostgreSQL POSTGRESQL_MAX_CONNECTIONS=200 -# Upload Size Limiting +# Common containers restart policy +RESTART_POLICY_CONDITION="on-failure" +RESTART_POLICY_DELAY="5s" +RESTART_POLICY_MAX_ATTEMPTS="3" +RESTART_POLICY_WINDOW=120s + DEFAULT_MAX_UPLOAD_SIZE=5368709120 -DEFAULT_MAX_PARALLEL_UPLOADS_PER_USER=100 \ No newline at end of file +DEFAULT_MAX_PARALLEL_UPLOADS_PER_USER=100 + +# Azure AD +MICROSOFT_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_SECRET_KEY= +AZURE_KEY= diff --git a/.gitignore b/.gitignore index bfe33426c44..7a455624878 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,5 @@ scripts/spcgeonode/_volume_* # Docker Hub hooks !hooks/* +.env + diff --git a/Dockerfile b/Dockerfile index c09545fcf3b..0805c6494d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,8 +28,13 @@ RUN chmod +x /usr/bin/celery-cmd # RUN cd /usr/src/geonode-contribs/geonode-logstash; pip install --upgrade -e . \ # cd /usr/src/geonode-contribs/ldap; pip install --upgrade -e . -RUN pip install --upgrade --no-cache-dir --src /usr/src -r requirements.txt -RUN pip install --upgrade -e . +RUN yes w | pip install --src /usr/src -Ur requirements.txt +RUN yes w | pip install --upgrade -e . + +# Cleanup apt update lists +RUN apt-get autoremove --purge +RUN apt-get clean +RUN rm -rf /var/lib/apt/lists/* # Export ports EXPOSE 8000 diff --git a/README.md b/README.md index db5ca65a4b3..8ec744e587e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Table of Contents - [Table of Contents](#table-of-contents) - [What is GeoNode?](#what-is-geonode) - [Try out GeoNode](#try-out-geonode) + - [Quick Docker Start](#quick-docker-start) - [Install](#install) - [Learn GeoNode](#learn-geonode) - [Development](#development) @@ -47,17 +48,51 @@ maps, editing metadata, styles and much more. To get an overview what GeoNode can do we recommend to have a look at the [Users Workshop](https://docs.geonode.org/en/master/usage/index.html). +Quick Docker Start +------------------ + + ```bash + python3.10 -m venv ~/.venvs/geonode + source ~/.venvs/geonode/bin/activate + + pip install Django==3.2.* + ``` + ```bash + python create-envfile.py + ``` +`create-envfile.py` accepts the following arguments: + +- `--https`: Enable SSL. It's disabled by default +- `--env_type`: + - When set to `prod` `DEBUG` is disabled and the creation of a valid `SSL` is requested to Letsencrypt's ACME server + - When set to `test` `DEBUG` is disabled and a test `SSL` certificate is generated for local testing + - When set to `dev` `DEBUG` is enabled and no `SSL` certificate is generated +- `--hostname`: The URL that whill serve GeoNode (`localhost` by default) +- `--email`: The administrator's email. Notice that a real email and a valid SMPT configurations are required if `--env_type` is seto to `prod`. Letsencrypt uses to email for issuing the SSL certificate +- `--geonodepwd`: GeoNode's administrator password. A random value is set if left empty +- `--geoserverpwd`: GeoNode's administrator password. A random value is set if left empty +- `--pgpwd`: PostgreSQL's administrator password. A random value is set if left empty +- `--dbpwd`: GeoNode DB user role's password. A random value is set if left empty +- `--geodbpwd`: GeoNode data DB user role's password. A random value is set if left empty +- `--clientid`: Client id of Geoserver's GeoNode Oauth2 client. A random value is set if left empty +- `--clientsecret`: Client secret of Geoserver's GeoNode Oauth2 client. A random value is set if left empty + +```bash + docker compose build + docker compose up -d +``` + Install ------- - The latest official release is 4.0.2! + The latest official release is 4.1.0! GeoNode can be setup in different ways, flavors and plattforms. If you´re planning to do development or install for production please visit the offical GeoNode installation documentation: - [Docker](https://docs.geonode.org/en/master/install/advanced/core/index.html#docker) -- [Ubuntu 20.04 LTS](https://docs.geonode.org/en/master/install/advanced/core/index.html#ubuntu-20-04lts) +- [Ubuntu 22.04](https://docs.geonode.org/en/master/install/advanced/core/index.html#ubuntu-22-04) Learn GeoNode ------------- diff --git a/create-envfile.py b/create-envfile.py new file mode 100644 index 00000000000..c910e912618 --- /dev/null +++ b/create-envfile.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +######################################################################### +# +# Copyright (C) 2022 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +import argparse +import json +import logging +import os +import random +import re +import string +import sys +import ast + +dir_path = os.path.dirname(os.path.realpath(__file__)) + +logger = logging.getLogger() +handler = logging.StreamHandler(sys.stdout) +logger.setLevel(logging.INFO) +formatter = logging.Formatter("%(levelname)s - %(message)s") +handler.setFormatter(formatter) +logger.addHandler(handler) + + +def shuffle(chars): + chars_as_list = list(chars) + random.shuffle(chars_as_list) + return "".join(chars_as_list) + + +_simple_chars = shuffle(string.ascii_letters + string.digits) +_strong_chars = shuffle( + string.ascii_letters + string.digits + string.punctuation.replace('"', "").replace("'", "").replace("`", "") +) + + +def generate_env_file(args): + # validity checks + if not os.path.exists(args.sample_file): + logger.error(f"File does not exists {args.sample_file}") + raise FileNotFoundError + + if args.file and not os.path.isfile(args.file): + logger.error(f"File does not exists: {args.file}") + raise FileNotFoundError + + if args.https and not args.email: + raise Exception("With HTTPS enabled, the email parameter is required") + + _sample_file = None + with open(args.sample_file, "r+") as sample_file: + _sample_file = sample_file.read() + + if not _sample_file: + raise Exception("Sample file is empty!") + + def _get_vals_to_replace(args): + _config = ["sample_file", "file", "env_type", "https", "email"] + _jsfile = {} + if args.file: + with open(args.file) as _json_file: + _jsfile = json.load(_json_file) + + _vals_to_replace = {key: _jsfile.get(key, val) for key, val in vars(args).items() if key not in _config} + tcp = "https" if ast.literal_eval(f"{_jsfile.get('https', args.https)}".capitalize()) else "http" + + _vals_to_replace["public_port"] = ( + "443" if ast.literal_eval(f"{_jsfile.get('https', args.https)}".capitalize()) else "80" + ) + _vals_to_replace["http_host"] = _jsfile.get("hostname", args.hostname) if tcp == "http" else "" + _vals_to_replace["https_host"] = _jsfile.get("hostname", args.hostname) if tcp == "https" else "" + + _vals_to_replace["siteurl"] = f"{tcp}://{_jsfile.get('hostname', args.hostname)}" + _vals_to_replace["secret_key"] = _jsfile.get("secret_key", args.secret_key) or "".join( + random.choice(_strong_chars) for _ in range(50) + ) + _vals_to_replace["letsencrypt_mode"] = ( + "disabled" + if not _vals_to_replace.get("https_host") + else "staging" + if _jsfile.get("env_type", args.env_type) in ["test"] + else "production" + ) + _vals_to_replace["debug"] = False if _jsfile.get("env_type", args.env_type) in ["prod", "test"] else True + _vals_to_replace["email"] = _jsfile.get("email", args.email) + + if tcp == "https" and not _vals_to_replace["email"]: + raise Exception("With HTTPS enabled, the email parameter is required") + + return {**_jsfile, **_vals_to_replace} + + for key, val in _get_vals_to_replace(args).items(): + _val = val or "".join(random.choice(_simple_chars) for _ in range(15)) + if isinstance(val, bool) or key in ["email", "http_host", "https_host"]: + _val = str(val) + _sample_file = re.sub( + "{" + key + "}", + lambda _: _val, + _sample_file, + ) + + with open(f"{dir_path}/.env", "w+") as output_env: + output_env.write(_sample_file) + logger.info(f".env file created: {dir_path}/.env") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + prog="ENV file builder", + description="Tool for generate environment file automatically. The information can be passed or via CLI or via JSON file ( --file /path/env.json)", + usage="python create-envfile.py localhost -f /path/to/json/file.json", + allow_abbrev=False + ) + parser.add_argument( + "--noinput", + "--no-input", + action="store_false", + dest="confirmation", + help=("skips prompting for confirmation."), + ) + parser.add_argument( + "-hn", + "--hostname", + help=f"Host name, default localhost", + default="localhost", + ) + + # expected path as a value + parser.add_argument( + "-sf", + "--sample_file", + help=f"Path of the sample file to use as a template. Default is: {dir_path}/.env.sample", + default=f"{dir_path}/.env.sample", + ) + parser.add_argument( + "-f", + "--file", + help="absolute path of the file with the configuration. Note: we expect that the keys of the dictionary have the same name as the CLI params", + ) + # booleans + parser.add_argument("--https", action="store_true", default=False, help="If provided, https is used") + # strings + parser.add_argument("--email", help="Admin email, this field is required if https is enabled") + + parser.add_argument("--geonodepwd", help="GeoNode admin password") + parser.add_argument("--geoserverpwd", help="Geoserver admin password") + parser.add_argument("--pgpwd", help="PostgreSQL password") + parser.add_argument("--dbpwd", help="GeoNode DB user password") + parser.add_argument("--geodbpwd", help="Geodatabase user password") + parser.add_argument("--clientid", help="Oauth2 client id") + parser.add_argument("--clientsecret", help="Oauth2 client secret") + parser.add_argument("--secret_key", help="Django Secret Key") + + parser.add_argument( + "--env_type", + help="Development/production or test", + choices=["prod", "test", "dev"], + default="prod", + ) + + args = parser.parse_args() + + if not args.confirmation: + generate_env_file(args) + else: + overwrite_env = input("This action will overwrite any existing .env file. Do you wish to continue? (y/n)") + if overwrite_env not in ["y", "n"]: + logger.error("Please enter a valid response") + if overwrite_env == "y": + generate_env_file(args) diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 1ba2fec619f..0ac801a9621 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -3,12 +3,12 @@ version: '3.9' # Common Django template for GeoNode and Celery services below x-common-django: &default-common-django - image: geonode:local - restart: on-failure + image: geonode/geonode:local + restart: unless-stopped env_file: - .env volumes: - # - '.:/usr/src/geonode' + - '.:/usr/src/geonode' - statics:/mnt/volumes/statics - geoserver-data-dir:/geoserver_data/data - backup-restore:/backup_restore @@ -17,8 +17,6 @@ x-common-django: depends_on: db: condition: service_healthy - geoserver: - condition: service_healthy services: @@ -30,11 +28,11 @@ services: dockerfile: Dockerfile container_name: django4${COMPOSE_PROJECT_NAME} healthcheck: - test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8001/" + test: "curl -m 10 --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://django:8000/" start_period: 60s interval: 60s timeout: 10s - retries: 10 + retries: 2 environment: - IS_CELERY=False entrypoint: ["/usr/src/geonode/entrypoint.sh"] @@ -43,10 +41,10 @@ services: # Celery worker that executes celery tasks created by Django. celery: << : *default-common-django - image: geonode:local container_name: celery4${COMPOSE_PROJECT_NAME} depends_on: - - django + django: + condition: service_healthy environment: - IS_CELERY=True entrypoint: ["/usr/src/geonode/entrypoint.sh"] @@ -54,15 +52,12 @@ services: # Nginx is serving django static and media files and proxies to django and geonode geonode: - image: geonode/nginx:4.0 + image: geonode/nginx:1.25.1 build: ./scripts/docker/nginx/ container_name: nginx4${COMPOSE_PROJECT_NAME} + env_file: + - .env environment: - - HTTPS_HOST=${HTTPS_HOST} - - HTTP_HOST=${HTTP_HOST} - - HTTPS_PORT=${HTTPS_PORT} - - HTTP_PORT=${HTTP_PORT} - - LETSENCRYPT_MODE=${LETSENCRYPT_MODE} - RESOLVER=127.0.0.11 ports: - "${HTTP_PORT}:80" @@ -71,46 +66,45 @@ services: - nginx-confd:/etc/nginx - nginx-certificates:/geonode-certificates - statics:/mnt/volumes/statics - restart: on-failure + restart: unless-stopped # Gets and installs letsencrypt certificates letsencrypt: - image: geonode/letsencrypt:4.0 + image: geonode/letsencrypt:latest build: ./scripts/docker/letsencrypt/ container_name: letsencrypt4${COMPOSE_PROJECT_NAME} - environment: - - HTTPS_HOST=${HTTPS_HOST} - - HTTP_HOST=${HTTP_HOST} - - ADMIN_EMAIL=${ADMIN_EMAIL} - - LETSENCRYPT_MODE=${LETSENCRYPT_MODE} + env_file: + - .env volumes: - nginx-certificates:/geonode-certificates - restart: on-failure + restart: unless-stopped # Geoserver backend geoserver: image: geonode/geoserver:2.23.0 container_name: geoserver4${COMPOSE_PROJECT_NAME} healthcheck: - test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8080/geoserver/ows" + test: "curl -m 10 --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://geoserver:8080/geoserver/ows" start_period: 60s interval: 60s timeout: 10s - retries: 10 + retries: 2 env_file: - .env + ports: + - "8080:8080" volumes: - statics:/mnt/volumes/statics - geoserver-data-dir:/geoserver_data/data - backup-restore:/backup_restore - data:/data - tmp:/tmp - restart: on-failure + restart: unless-stopped depends_on: - db: - condition: service_healthy data-dir-conf: condition: service_healthy + django: + condition: service_healthy data-dir-conf: image: geonode/geoserver_data:2.23.0 @@ -118,14 +112,14 @@ services: entrypoint: sleep infinity volumes: - geoserver-data-dir:/geoserver_data/data - restart: on-failure + restart: unless-stopped healthcheck: test: "ls -A '/geoserver_data/data' | wc -l" # PostGIS database. db: - # use geonode official postgis 13 image - image: geonode/postgis:13 + # use geonode official postgis 15 image + image: geonode/postgis:15 command: postgres -c "max_connections=${POSTGRESQL_MAX_CONNECTIONS}" container_name: db4${COMPOSE_PROJECT_NAME} env_file: @@ -133,7 +127,7 @@ services: volumes: - dbdata:/var/lib/postgresql/data - dbbackups:/pg_backups - restart: on-failure + restart: unless-stopped healthcheck: test: "pg_isready -d postgres -U postgres" # uncomment to enable remote connections to postgres @@ -142,11 +136,11 @@ services: # Vanilla RabbitMQ service. This is needed by celery rabbitmq: - image: rabbitmq:3.7-alpine + image: rabbitmq:3-alpine container_name: rabbitmq4${COMPOSE_PROJECT_NAME} volumes: - rabbitmq:/var/lib/rabbitmq - restart: on-failure + restart: unless-stopped volumes: statics: diff --git a/docker-compose-test.yml b/docker-compose-test.yml index de398f099a7..762183a5eac 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -3,8 +3,8 @@ version: '3.9' # Common Django template for GeoNode and Celery services below x-common-django: &default-common-django - image: geonode/geonode:4.0 - restart: on-failure + image: geonode/geonode:latest-ubuntu-22.10 + restart: unless-stopped env_file: - .env_test volumes: @@ -17,8 +17,6 @@ x-common-django: depends_on: db: condition: service_healthy - geoserver: - condition: service_healthy services: @@ -30,11 +28,11 @@ services: dockerfile: Dockerfile container_name: django4${COMPOSE_PROJECT_NAME} healthcheck: - test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8001/" + test: "curl -m 10 --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://django:8000/" start_period: 60s interval: 60s timeout: 10s - retries: 10 + retries: 2 environment: - IS_CELERY=False entrypoint: ["/usr/src/geonode/entrypoint.sh"] @@ -43,10 +41,10 @@ services: # Celery worker that executes celery tasks created by Django. celery: << : *default-common-django - image: geonode/geonode:4.0 container_name: celery4${COMPOSE_PROJECT_NAME} depends_on: - - django + django: + condition: service_healthy environment: - IS_CELERY=True entrypoint: ["/usr/src/geonode/entrypoint.sh"] @@ -54,15 +52,12 @@ services: # Nginx is serving django static and media files and proxies to django and geonode geonode: - image: geonode/nginx:4.0 + image: geonode/nginx:1.25.1 build: ./scripts/docker/nginx/ container_name: nginx4${COMPOSE_PROJECT_NAME} + env_file: + - .env_test environment: - - HTTPS_HOST=${HTTPS_HOST} - - HTTP_HOST=${HTTP_HOST} - - HTTPS_PORT=${HTTPS_PORT} - - HTTP_PORT=${HTTP_PORT} - - LETSENCRYPT_MODE=${LETSENCRYPT_MODE} - RESOLVER=127.0.0.11 ports: - "${HTTP_PORT}:80" @@ -71,46 +66,45 @@ services: - nginx-confd:/etc/nginx - nginx-certificates:/geonode-certificates - statics:/mnt/volumes/statics - restart: on-failure + restart: unless-stopped # Gets and installs letsencrypt certificates letsencrypt: - image: geonode/letsencrypt:4.0 + image: geonode/letsencrypt:latest build: ./scripts/docker/letsencrypt/ container_name: letsencrypt4${COMPOSE_PROJECT_NAME} - environment: - - HTTPS_HOST=${HTTPS_HOST} - - HTTP_HOST=${HTTP_HOST} - - ADMIN_EMAIL=${ADMIN_EMAIL} - - LETSENCRYPT_MODE=${LETSENCRYPT_MODE} + env_file: + - .env_test volumes: - nginx-certificates:/geonode-certificates - restart: on-failure + restart: unless-stopped # Geoserver backend geoserver: image: geonode/geoserver:2.23.0 container_name: geoserver4${COMPOSE_PROJECT_NAME} healthcheck: - test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8080/geoserver/ows" + test: "curl -m 10 --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://geoserver:8080/geoserver/ows" start_period: 60s interval: 60s timeout: 10s - retries: 10 + retries: 2 env_file: - .env_test + ports: + - "8080:8080" volumes: - statics:/mnt/volumes/statics - geoserver-data-dir:/geoserver_data/data - backup-restore:/backup_restore - data:/data - tmp:/tmp - restart: on-failure + restart: unless-stopped depends_on: - db: - condition: service_healthy data-dir-conf: condition: service_healthy + django: + condition: service_healthy data-dir-conf: image: geonode/geoserver_data:2.23.0 @@ -118,14 +112,14 @@ services: entrypoint: sleep infinity volumes: - geoserver-data-dir:/geoserver_data/data - restart: on-failure + restart: unless-stopped healthcheck: test: "ls -A '/geoserver_data/data' | wc -l" # PostGIS database. db: - # use geonode official postgis 13 image - image: geonode/postgis:13 + # use geonode official postgis 15 image + image: geonode/postgis:15 command: postgres -c "max_connections=${POSTGRESQL_MAX_CONNECTIONS}" container_name: db4${COMPOSE_PROJECT_NAME} env_file: @@ -133,7 +127,7 @@ services: volumes: - dbdata:/var/lib/postgresql/data - dbbackups:/pg_backups - restart: on-failure + restart: unless-stopped healthcheck: test: "pg_isready -d postgres -U postgres" # uncomment to enable remote connections to postgres @@ -142,11 +136,11 @@ services: # Vanilla RabbitMQ service. This is needed by celery rabbitmq: - image: rabbitmq:3.7-alpine + image: rabbitmq:3-alpine container_name: rabbitmq4${COMPOSE_PROJECT_NAME} volumes: - rabbitmq:/var/lib/rabbitmq - restart: on-failure + restart: unless-stopped volumes: statics: diff --git a/docker-compose.yml b/docker-compose.yml index d87342e2ad4..853231d824a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,6 @@ x-common-django: env_file: - .env volumes: - # - '.:/usr/src/geonode' - statics:/mnt/volumes/statics - geoserver-data-dir:/geoserver_data/data - backup-restore:/backup_restore @@ -17,8 +16,6 @@ x-common-django: depends_on: db: condition: service_healthy - geoserver: - condition: service_healthy services: @@ -27,11 +24,11 @@ services: << : *default-common-django container_name: django4${COMPOSE_PROJECT_NAME} healthcheck: - test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8001/" + test: "curl -m 10 --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://django:8000/" start_period: 60s interval: 60s timeout: 10s - retries: 10 + retries: 2 environment: - IS_CELERY=False entrypoint: ["/usr/src/geonode/entrypoint.sh"] @@ -42,7 +39,8 @@ services: << : *default-common-django container_name: celery4${COMPOSE_PROJECT_NAME} depends_on: - - django + django: + condition: service_healthy environment: - IS_CELERY=True entrypoint: ["/usr/src/geonode/entrypoint.sh"] @@ -50,15 +48,12 @@ services: # Nginx is serving django static and media files and proxies to django and geonode geonode: - image: geonode/nginx:4.1.0 + image: geonode/nginx:1.25.1 build: ./scripts/docker/nginx/ container_name: nginx4${COMPOSE_PROJECT_NAME} + env_file: + - .env environment: - - HTTPS_HOST=${HTTPS_HOST} - - HTTP_HOST=${HTTP_HOST} - - HTTPS_PORT=${HTTPS_PORT} - - HTTP_PORT=${HTTP_PORT} - - LETSENCRYPT_MODE=${LETSENCRYPT_MODE} - RESOLVER=127.0.0.11 ports: - "${HTTP_PORT}:80" @@ -71,14 +66,11 @@ services: # Gets and installs letsencrypt certificates letsencrypt: - image: geonode/letsencrypt:4.1.0 + image: geonode/letsencrypt:latest build: ./scripts/docker/letsencrypt/ container_name: letsencrypt4${COMPOSE_PROJECT_NAME} - environment: - - HTTPS_HOST=${HTTPS_HOST} - - HTTP_HOST=${HTTP_HOST} - - ADMIN_EMAIL=${ADMIN_EMAIL} - - LETSENCRYPT_MODE=${LETSENCRYPT_MODE} + env_file: + - .env volumes: - nginx-certificates:/geonode-certificates restart: unless-stopped @@ -88,13 +80,15 @@ services: image: geonode/geoserver:2.23.0 container_name: geoserver4${COMPOSE_PROJECT_NAME} healthcheck: - test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8080/geoserver/ows" + test: "curl -m 10 --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://geoserver:8080/geoserver/ows" start_period: 60s interval: 60s timeout: 10s - retries: 10 + retries: 2 env_file: - .env + ports: + - "8080:8080" volumes: - statics:/mnt/volumes/statics - geoserver-data-dir:/geoserver_data/data @@ -103,10 +97,10 @@ services: - tmp:/tmp restart: unless-stopped depends_on: - db: - condition: service_healthy data-dir-conf: condition: service_healthy + django: + condition: service_healthy data-dir-conf: image: geonode/geoserver_data:2.23.0 diff --git a/entrypoint.sh b/entrypoint.sh index 620976448cc..6bb062b910e 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -41,7 +41,7 @@ echo MONITORING_HOST_NAME=$MONITORING_HOST_NAME echo MONITORING_SERVICE_NAME=$MONITORING_SERVICE_NAME echo MONITORING_DATA_TTL=$MONITORING_DATA_TTL -invoke waitfordbs +# invoke waitfordbs cmd="$@" @@ -61,8 +61,6 @@ else fi invoke statics - invoke waitforgeoserver - invoke geoserverfixture echo "Executing UWSGI server $cmd for Production" fi diff --git a/geonode/api/__init__.py b/geonode/api/__init__.py index 79177e00bdd..55b0b0cb9fd 100644 --- a/geonode/api/__init__.py +++ b/geonode/api/__init__.py @@ -16,3 +16,4 @@ # along with this program. If not, see . # ######################################################################### +default_app_config = "geonode.api.apps.GeoNodeApiAppConfig" diff --git a/geonode/base/api/tests.py b/geonode/base/api/tests.py index 5a278f432bd..88df7c3f7b2 100644 --- a/geonode/base/api/tests.py +++ b/geonode/base/api/tests.py @@ -622,15 +622,15 @@ def test_base_resources(self): }, { "name": "", - "slug": "http-localhost-8001-thesaurus-no-about-thesauro-38", - "uri": "http://localhost:8001//thesaurus/no-about-thesauro#38", + "slug": "http-localhost-8000-thesaurus-no-about-thesauro-38", + "uri": "http://localhost:8000//thesaurus/no-about-thesauro#38", "thesaurus": {"name": "Thesauro without the about", "slug": "no-about-thesauro", "uri": ""}, "i18n": {}, }, { "name": "bar_keyword", - "slug": "http-localhost-8001-thesaurus-no-about-thesauro-bar-keyword", - "uri": "http://localhost:8001//thesaurus/no-about-thesauro#bar_keyword", + "slug": "http-localhost-8000-thesaurus-no-about-thesauro-bar-keyword", + "uri": "http://localhost:8000//thesaurus/no-about-thesauro#bar_keyword", "thesaurus": {"name": "Thesauro without the about", "slug": "no-about-thesauro", "uri": ""}, "i18n": {}, }, diff --git a/geonode/catalogue/__init__.py b/geonode/catalogue/__init__.py index 3b577289766..d1b9c72dbff 100644 --- a/geonode/catalogue/__init__.py +++ b/geonode/catalogue/__init__.py @@ -25,6 +25,7 @@ from django.core.exceptions import ImproperlyConfigured from importlib import import_module +default_app_config = "geonode.catalogue.apps.GeoNodeCatalogueAppConfig" DEFAULT_CATALOGUE_ALIAS = "default" # GeoNode uses this if the CATALOGUE setting is empty (None). diff --git a/geonode/catalogue/metadataxsl/__init__.py b/geonode/catalogue/metadataxsl/__init__.py index 79177e00bdd..0143f08e23a 100644 --- a/geonode/catalogue/metadataxsl/__init__.py +++ b/geonode/catalogue/metadataxsl/__init__.py @@ -16,3 +16,4 @@ # along with this program. If not, see . # ######################################################################### +default_app_config = "geonode.catalogue.metadataxsl.apps.GeoNodeCatalogueMetadataxslAppConfig" diff --git a/geonode/celery_app.py b/geonode/celery_app.py index 0e3c411c2ad..5f9b5e95624 100644 --- a/geonode/celery_app.py +++ b/geonode/celery_app.py @@ -49,14 +49,14 @@ def setup_periodic_tasks(sender, **kwargs): @app.task( bind=True, - name='{{project_name}}.test', + name='geonode.test', queue='default') def test(arg): _log(arg) @app.task( bind=True, - name='{{project_name}}.debug_task', + name='geonode.debug_task', queue='default') def debug_task(self): _log(f"Request: {self.request}") diff --git a/geonode/documents/exif/__init__.py b/geonode/documents/exif/__init__.py index 79177e00bdd..bc045338385 100644 --- a/geonode/documents/exif/__init__.py +++ b/geonode/documents/exif/__init__.py @@ -16,3 +16,4 @@ # along with this program. If not, see . # ######################################################################### +default_app_config = "geonode.documents.exif.apps.GeoNodeDocumentsExifAppConfig" diff --git a/geonode/favorite/__init__.py b/geonode/favorite/__init__.py index 79177e00bdd..83005e67c7f 100644 --- a/geonode/favorite/__init__.py +++ b/geonode/favorite/__init__.py @@ -16,3 +16,4 @@ # along with this program. If not, see . # ######################################################################### +default_app_config = "geonode.favorite.apps.GeoNodeFavoriteAppConfig" diff --git a/geonode/geoserver/createlayer/__init__.py b/geonode/geoserver/createlayer/__init__.py index 3d54b02dfef..a304ef09a5b 100644 --- a/geonode/geoserver/createlayer/__init__.py +++ b/geonode/geoserver/createlayer/__init__.py @@ -16,3 +16,4 @@ # along with this program. If not, see . # ######################################################################### +default_app_config = "geonode.geoserver.createlayer.apps.GeoNodeGeoserverCreatelayerAppConfig" diff --git a/geonode/geoserver/processing/__init__.py b/geonode/geoserver/processing/__init__.py index 6b4db6084e8..333409c58a5 100644 --- a/geonode/geoserver/processing/__init__.py +++ b/geonode/geoserver/processing/__init__.py @@ -16,3 +16,4 @@ # along with this program. If not, see . # ######################################################################### +default_app_config = "geonode.geoserver.processing.apps.GeoNodeGeoserverProcessingAppConfig" diff --git a/geonode/local_settings.py.geoserver.sample b/geonode/local_settings.py.geoserver.sample index 18aa492c76f..59ac574511b 100644 --- a/geonode/local_settings.py.geoserver.sample +++ b/geonode/local_settings.py.geoserver.sample @@ -99,9 +99,9 @@ GEOSERVER_WEB_UI_LOCATION = os.getenv("GEOSERVER_WEB_UI_LOCATION", GEOSERVER_LOC GEOSERVER_PUBLIC_LOCATION = os.getenv("GEOSERVER_PUBLIC_LOCATION", _default_public_location) -OGC_SERVER_DEFAULT_USER = os.getenv("GEOSERVER_ADMIN_USER", "admin") +GEOSERVER_ADMIN_USER = os.getenv("GEOSERVER_ADMIN_USER", "admin") -OGC_SERVER_DEFAULT_PASSWORD = os.getenv("GEOSERVER_ADMIN_PASSWORD", "geoserver") +GEOSERVER_ADMIN_PASSWORD = os.getenv("GEOSERVER_ADMIN_PASSWORD", "geoserver") # OGC (WMS/WFS/WCS) Server Settings OGC_SERVER = { @@ -115,8 +115,8 @@ OGC_SERVER = { # the proxy won't work and the integration tests will fail # the entire block has to be overridden in the local_settings "PUBLIC_LOCATION": GEOSERVER_PUBLIC_LOCATION, - "USER": OGC_SERVER_DEFAULT_USER, - "PASSWORD": OGC_SERVER_DEFAULT_PASSWORD, + "USER": GEOSERVER_ADMIN_USER, + "PASSWORD": GEOSERVER_ADMIN_PASSWORD, "MAPFISH_PRINT_ENABLED": True, "PRINT_NG_ENABLED": True, "GEONODE_SECURITY_ENABLED": True, diff --git a/geonode/people/adapters.py b/geonode/people/adapters.py index a322562d19a..63ac7e36659 100644 --- a/geonode/people/adapters.py +++ b/geonode/people/adapters.py @@ -44,6 +44,7 @@ from django.core.exceptions import ValidationError from django.utils.module_loading import import_string +from geonode.utils import import_class_module from geonode.groups.models import GroupProfile logger = logging.getLogger(__name__) @@ -67,6 +68,18 @@ def get_data_extractor(provider_id): return extractor +def get_group_role_mapper(provider_id): + group_role_mapper_class = import_class_module( + getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}) + .get(PROVIDER_ID, {}) + .get( + "GROUP_ROLE_MAPPER_CLASS", + "geonode.people.profileextractors.OpenIDGroupRoleMapper", + ) + ) + return group_role_mapper_class() + + def update_profile(sociallogin): """Update a people.models.Profile object with info from the sociallogin""" user = sociallogin.user @@ -248,11 +261,14 @@ class GenericOpenIDConnectAdapter(OAuth2Adapter, SocialAccountAdapter): def complete_login(self, request, app, token, response, **kwargs): extra_data = {} if self.profile_url: - headers = {"Authorization": "Bearer {0}".format(token.token)} - resp = requests.get(self.profile_url, headers=headers) - profile_data = resp.json() - extra_data.update(profile_data) - elif "id_token" in response: + try: + headers = {"Authorization": f"Bearer {token.token}"} + resp = requests.get(self.profile_url, headers=headers) + profile_data = resp.json() + extra_data.update(profile_data) + except Exception: + logger.exception(OAuth2Error("Invalid profile_url, falling back to id_token checks...")) + if not extra_data and "id_token" in response: try: extra_data = jwt.decode( response["id_token"], @@ -279,20 +295,21 @@ def complete_login(self, request, app, token, response, **kwargs): def save_user(self, request, sociallogin, form=None): user = super(SocialAccountAdapter, self).save_user(request, sociallogin, form=form) extractor = get_data_extractor(sociallogin.account.provider) + group_role_mapper = get_group_role_mapper(sociallogin.account.provider) try: groups = extractor.extract_groups(sociallogin.account.extra_data) or extractor.extract_roles( sociallogin.account.extra_data ) - is_manager = extractor.extract_is_manager(sociallogin.account.extra_data) # check here if user is member already of other groups and remove it form the ones that are not declared here... for groupprofile in user.group_list_all(): groupprofile.leave(user) - for group_name in groups: + for group_role_name in groups: + group_name, role_name = group_role_mapper.parse_group_and_role(group_role_name) groupprofile = GroupProfile.objects.filter(slug=group_name).first() if groupprofile: groupprofile.join(user) - if is_manager: + if group_role_mapper.is_manager(role_name): groupprofile.promote() except (AttributeError, NotImplementedError): pass # extractor doesn't define a method for extracting field diff --git a/geonode/people/profileextractors.py b/geonode/people/profileextractors.py index 2bf7fa12e51..7b9cdebefec 100644 --- a/geonode/people/profileextractors.py +++ b/geonode/people/profileextractors.py @@ -129,10 +129,6 @@ def _extract_field(self, name, data): PROVIDER_ID = getattr(settings, "SOCIALACCOUNT_OIDC_PROVIDER", "geonode_openid_connect") -IS_MANAGER_FIELD = ( - getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("IS_MANAGER_FIELD", "is_manager") -) - class OpenIDExtractor(BaseExtractor): def extract_email(self, data): @@ -198,10 +194,20 @@ def extract_groups(self, data): def extract_roles(self, data): return data.get("roles", "") - def extract_is_manager(self, data): - return data.get(IS_MANAGER_FIELD, "") - def _get_latest_position(data): all_positions = data.get("positions", {"values": []})["values"] return all_positions[0] if any(all_positions) else None + + +class OpenIDGroupRoleMapper: + """GeoNode will look for names like: ["GroupProfile1.Role", "GroupProfile2.Role", ..., "GroupProfileN.Role"]""" + + def parse_group_and_role(self, group_role_name): + _group_role_name = group_role_name if "." in group_role_name else f"{group_role_name}.None" + group_name, role_name = _group_role_name.rsplit(".", 1) + return (group_name, role_name) + + def is_manager(role_name): + _role_name = role_name or "" + return "manager" in _role_name.lower() diff --git a/geonode/people/socialaccount/providers/geonode_openid_connect/provider.py b/geonode/people/socialaccount/providers/geonode_openid_connect/provider.py index 329ba3c4bf3..196be1e17a2 100644 --- a/geonode/people/socialaccount/providers/geonode_openid_connect/provider.py +++ b/geonode/people/socialaccount/providers/geonode_openid_connect/provider.py @@ -68,7 +68,11 @@ def get_auth_params(self, request, action): return ret def extract_uid(self, data): - return data.get("sub", data.get("id")) + _uid_field = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("UID_FIELD", None) + if _uid_field: + return data.get(_uid_field) + else: + return data.get("uid", data.get("sub", data.get("id"))) def extract_common_fields(self, data): _common_fields = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("COMMON_FIELDS", {}) diff --git a/geonode/people/socialaccount/providers/geonode_openid_connect/tests.py b/geonode/people/socialaccount/providers/geonode_openid_connect/tests.py index 29373c2fd93..04be234c6c3 100644 --- a/geonode/people/socialaccount/providers/geonode_openid_connect/tests.py +++ b/geonode/people/socialaccount/providers/geonode_openid_connect/tests.py @@ -213,7 +213,6 @@ def test_social_email_verification_optional(self): "prompt": "select_account consent", }, "COMMON_FIELDS": {"email": "email", "last_name": "family_name", "first_name": "given_name"}, - "IS_MANAGER_FIELD": "is_manager", "ACCOUNT_CLASS": "allauth.socialaccount.providers.google.provider.GoogleAccount", "ACCESS_TOKEN_URL": "https://oauth2.googleapis.com/token", "AUTHORIZE_URL": "https://accounts.google.com/o/oauth2/v2/auth", diff --git a/geonode/proxy/__init__.py b/geonode/proxy/__init__.py index da86ef5219a..7b2209ac902 100644 --- a/geonode/proxy/__init__.py +++ b/geonode/proxy/__init__.py @@ -16,3 +16,4 @@ # along with this program. If not, see . # ######################################################################### +default_app_config = "geonode.proxy.apps.GeoNodeProxyAppConfig" diff --git a/geonode/security/__init__.py b/geonode/security/__init__.py index da86ef5219a..0edec538e72 100644 --- a/geonode/security/__init__.py +++ b/geonode/security/__init__.py @@ -16,3 +16,4 @@ # along with this program. If not, see . # ######################################################################### +default_app_config = "geonode.security.apps.GeoNodeSecurityAppConfig" diff --git a/geonode/settings.py b/geonode/settings.py index 73e6fea14da..9b746b9d63e 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -1004,9 +1004,12 @@ GEOSERVER_WEB_UI_LOCATION = os.getenv("GEOSERVER_WEB_UI_LOCATION", GEOSERVER_PUBLIC_LOCATION) -OGC_SERVER_DEFAULT_USER = os.getenv("GEOSERVER_ADMIN_USER", "admin") +GEOSERVER_ADMIN_USER = os.getenv("GEOSERVER_ADMIN_USER", "admin") -OGC_SERVER_DEFAULT_PASSWORD = os.getenv("GEOSERVER_ADMIN_PASSWORD", "geoserver") +GEOSERVER_ADMIN_PASSWORD = os.getenv("GEOSERVER_ADMIN_PASSWORD", "geoserver") + +# This is the password from Geoserver factory data-dir. It's only used at install time to perform the very first configurfation of GEOSERVER_ADMIN_PASSWORD +GEOSERVER_FACTORY_PASSWORD = os.getenv("GEOSERVER_FACTORY_PASSWORD", "geoserver") GEOFENCE_SECURITY_ENABLED = ( False if TEST and not INTEGRATION else ast.literal_eval(os.getenv("GEOFENCE_SECURITY_ENABLED", "True")) @@ -1025,8 +1028,8 @@ # the proxy won't work and the integration tests will fail # the entire block has to be overridden in the local_settings "PUBLIC_LOCATION": GEOSERVER_PUBLIC_LOCATION, - "USER": OGC_SERVER_DEFAULT_USER, - "PASSWORD": OGC_SERVER_DEFAULT_PASSWORD, + "USER": GEOSERVER_ADMIN_USER, + "PASSWORD": GEOSERVER_ADMIN_PASSWORD, "MAPFISH_PRINT_ENABLED": ast.literal_eval(os.getenv("MAPFISH_PRINT_ENABLED", "True")), "PRINT_NG_ENABLED": ast.literal_eval(os.getenv("PRINT_NG_ENABLED", "True")), "GEONODE_SECURITY_ENABLED": ast.literal_eval(os.getenv("GEONODE_SECURITY_ENABLED", "True")), @@ -1969,6 +1972,17 @@ def get_geonode_catalogue_service(): SOCIALACCOUNT_OIDC_PROVIDER_ENABLED = ast.literal_eval(os.environ.get("SOCIALACCOUNT_OIDC_PROVIDER_ENABLED", "False")) SOCIALACCOUNT_ADAPTER = os.environ.get("SOCIALACCOUNT_ADAPTER", "geonode.people.adapters.GenericOpenIDConnectAdapter") +_SOCIALACCOUNT_PROFILE_EXTRACTOR = os.environ.get( + "SOCIALACCOUNT_PROFILE_EXTRACTOR", "geonode.people.profileextractors.OpenIDExtractor" +) +SOCIALACCOUNT_PROFILE_EXTRACTORS = { + SOCIALACCOUNT_OIDC_PROVIDER: _SOCIALACCOUNT_PROFILE_EXTRACTOR, +} + +SOCIALACCOUNT_GROUP_ROLE_MAPPER = os.environ.get( + "SOCIALACCOUNT_GROUP_ROLE_MAPPER", "geonode.people.profileextractors.OpenIDGroupRoleMapper" +) + # Enable this in order to enable the OIDC SocialAccount Provider if SOCIALACCOUNT_OIDC_PROVIDER_ENABLED: INSTALLED_APPS += ("geonode.people.socialaccount.providers.geonode_openid_connect",) @@ -1985,7 +1999,8 @@ def get_geonode_catalogue_service(): "prompt": "select_account", }, "COMMON_FIELDS": {"email": "mail", "last_name": "surname", "first_name": "givenName"}, - "IS_MANAGER_FIELD": "is_manager", + "UID_FIELD": "unique_name", + "GROUP_ROLE_MAPPER_CLASS": SOCIALACCOUNT_GROUP_ROLE_MAPPER, "ACCOUNT_CLASS": "allauth.socialaccount.providers.azure.provider.AzureAccount", "ACCESS_TOKEN_URL": f"https://login.microsoftonline.com/{_AZURE_TENANT_ID}/oauth2/v2.0/token", "AUTHORIZE_URL": f"https://login.microsoftonline.com/{_AZURE_TENANT_ID}/oauth2/v2.0/authorize", @@ -2003,7 +2018,7 @@ def get_geonode_catalogue_service(): "prompt": "select_account consent", }, "COMMON_FIELDS": {"email": "email", "last_name": "family_name", "first_name": "given_name"}, - "IS_MANAGER_FIELD": "is_manager", + "GROUP_ROLE_MAPPER_CLASS": SOCIALACCOUNT_GROUP_ROLE_MAPPER, "ACCOUNT_CLASS": "allauth.socialaccount.providers.google.provider.GoogleAccount", "ACCESS_TOKEN_URL": "https://oauth2.googleapis.com/token", "AUTHORIZE_URL": "https://accounts.google.com/o/oauth2/v2/auth", @@ -2018,13 +2033,7 @@ def get_geonode_catalogue_service(): SOCIALACCOUNT_OIDC_PROVIDER: SOCIALACCOUNT_PROVIDERS_DEFS.get(_SOCIALACCOUNT_PROVIDER), } -_SOCIALACCOUNT_PROFILE_EXTRACTOR = os.environ.get( - "SOCIALACCOUNT_PROFILE_EXTRACTOR", "geonode.people.profileextractors.OpenIDExtractor" -) -SOCIALACCOUNT_PROFILE_EXTRACTORS = { - SOCIALACCOUNT_OIDC_PROVIDER: _SOCIALACCOUNT_PROFILE_EXTRACTOR, -} - +# Invitation Adapter INVITATIONS_ADAPTER = ACCOUNT_ADAPTER INVITATIONS_CONFIRMATION_URL_NAME = "geonode.invitations:accept-invite" diff --git a/geonode/tasks/__init__.py b/geonode/tasks/__init__.py index f3b257d5e12..41405557613 100644 --- a/geonode/tasks/__init__.py +++ b/geonode/tasks/__init__.py @@ -16,3 +16,4 @@ # along with this program. If not, see . # ######################################################################### +default_app_config = "geonode.tasks.apps.GeoNodeTasksAppConfig" diff --git a/geonode/upload/tests/test_settings.py b/geonode/upload/tests/test_settings.py index 9e25f52c835..3ff3368c88b 100644 --- a/geonode/upload/tests/test_settings.py +++ b/geonode/upload/tests/test_settings.py @@ -100,9 +100,11 @@ GEOSERVER_PUBLIC_LOCATION = os.getenv("GEOSERVER_PUBLIC_LOCATION", _default_public_location) -OGC_SERVER_DEFAULT_USER = os.getenv("GEOSERVER_ADMIN_USER", "admin") +GEOSERVER_ADMIN_USER = os.getenv("GEOSERVER_ADMIN_USER", "admin") -OGC_SERVER_DEFAULT_PASSWORD = os.getenv("GEOSERVER_ADMIN_PASSWORD", "geoserver") +GEOSERVER_ADMIN_PASSWORD = os.getenv("GEOSERVER_ADMIN_PASSWORD", "geoserver") + +GEOSERVER_FACTORY_PASSWORD = os.getenv("GEOSERVER_FACTORY_PASSWORD", "geoserver") # OGC (WMS/WFS/WCS) Server Settings OGC_SERVER = { @@ -116,8 +118,8 @@ # the proxy won't work and the integration tests will fail # the entire block has to be overridden in the local_settings "PUBLIC_LOCATION": GEOSERVER_PUBLIC_LOCATION, - "USER": OGC_SERVER_DEFAULT_USER, - "PASSWORD": OGC_SERVER_DEFAULT_PASSWORD, + "USER": GEOSERVER_ADMIN_USER, + "PASSWORD": GEOSERVER_ADMIN_PASSWORD, "MAPFISH_PRINT_ENABLED": True, "PRINT_NG_ENABLED": True, "GEONODE_SECURITY_ENABLED": True, diff --git a/geonode/utils.py b/geonode/utils.py index 6d541926ba1..5abab4b2411 100755 --- a/geonode/utils.py +++ b/geonode/utils.py @@ -2012,8 +2012,8 @@ def import_class_module(full_class_string): """ Dynamically load a class from a string - >>> klass = load_class("module.submodule.ClassName") - >>> klass2 = load_class("myfile.Class2") + >>> klass = import_class_module("module.submodule.ClassName") + >>> klass2 = import_class_module("myfile.Class2") """ try: module_path, class_name = full_class_string.rsplit(".", 1) diff --git a/package/support/geonode.local_settings b/package/support/geonode.local_settings index 5525ee0aa20..3e485a5d021 100644 --- a/package/support/geonode.local_settings +++ b/package/support/geonode.local_settings @@ -98,14 +98,18 @@ GEOSERVER_PUBLIC_LOCATION = os.getenv( 'GEOSERVER_PUBLIC_LOCATION', '{}gs/'.format(SITEURL) ) -OGC_SERVER_DEFAULT_USER = os.getenv( +GEOSERVER_ADMIN_USER = os.getenv( 'GEOSERVER_ADMIN_USER', 'admin' ) -OGC_SERVER_DEFAULT_PASSWORD = os.getenv( +GEOSERVER_ADMIN_PASSWORD = os.getenv( 'GEOSERVER_ADMIN_PASSWORD', 'geoserver' ) +GEOSERVER_FACTORY_PASSWORD = os.getenv( + 'GEOSERVER_FACTORY_PASSWORD', 'geoserver' +) + # OGC (WMS/WFS/WCS) Server Settings OGC_SERVER = { 'default': { @@ -117,8 +121,8 @@ OGC_SERVER = { # the proxy won't work and the integration tests will fail # the entire block has to be overridden in the local_settings 'PUBLIC_LOCATION': GEOSERVER_PUBLIC_LOCATION, - 'USER': OGC_SERVER_DEFAULT_USER, - 'PASSWORD': OGC_SERVER_DEFAULT_PASSWORD, + 'USER': GEOSERVER_ADMIN_USER, + 'PASSWORD': GEOSERVER_ADMIN_PASSWORD, 'MAPFISH_PRINT_ENABLED': True, 'PRINT_NG_ENABLED': True, 'GEONODE_SECURITY_ENABLED': True, diff --git a/pavement.py b/pavement.py index 5a494f124f4..3c59b99a4eb 100644 --- a/pavement.py +++ b/pavement.py @@ -765,9 +765,7 @@ def test_integration(options): bind = options.get("bind", "0.0.0.0:8000") foreground = "" if options.get("foreground", False) else "&" sh(f"DJANGO_SETTINGS_MODULE={settings} python -W ignore manage.py runmessaging {foreground}") - sh( - f"DJANGO_SETTINGS_MODULE={settings} python -W ignore manage.py runserver {bind} {foreground}" - ) + sh(f"DJANGO_SETTINGS_MODULE={settings} python -W ignore manage.py runserver {bind} {foreground}") sh("sleep 30") settings = f"REUSE_DB=1 DJANGO_SETTINGS_MODULE={settings}" else: diff --git a/requirements.txt b/requirements.txt index 4c40148f408..8e5702f81fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -173,6 +173,7 @@ webdriver_manager==4.0.0 mistune==3.0.1 protobuf==3.20.3 mako==1.2.4 +paramiko==3.3.1 # not directly required, fixes Blowfish deprecation warning certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability jwcrypto>=1.4 # not directly required, pinned by Snyk to avoid a vulnerability cryptography>=41.0.0 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/scripts/docker/nginx/Dockerfile b/scripts/docker/nginx/Dockerfile index 0a497ce0384..2e3a8fea7cd 100644 --- a/scripts/docker/nginx/Dockerfile +++ b/scripts/docker/nginx/Dockerfile @@ -1,9 +1,12 @@ FROM nginx:1.25.1-alpine -RUN apk add --no-cache openssl inotify-tools +RUN apk add --no-cache openssl inotify-tools vim WORKDIR /etc/nginx/ +RUN mkdir -p /etc/nginx/html +RUN touch /etc/nginx/html/index.html + ADD nginx.conf.envsubst nginx.https.available.conf.envsubst ./ ADD geonode.conf.envsubst ./sites-enabled/ diff --git a/scripts/docker/nginx/docker-entrypoint.sh b/scripts/docker/nginx/docker-entrypoint.sh index 9afb7cffedb..e6bec7a1db2 100644 --- a/scripts/docker/nginx/docker-entrypoint.sh +++ b/scripts/docker/nginx/docker-entrypoint.sh @@ -15,7 +15,7 @@ echo "Creating autoissued certificates for HTTP host" if [ ! -f "/geonode-certificates/autoissued/privkey.pem" ] || [[ $(find /geonode-certificates/autoissued/privkey.pem -mtime +365 -print) ]]; then echo "Autoissued certificate does not exist or is too old, we generate one" mkdir -p "/geonode-certificates/autoissued/" - openssl req -x509 -nodes -days 1825 -newkey rsa:2048 -keyout "/geonode-certificates/autoissued/privkey.pem" -out "/geonode-certificates/autoissued/fullchain.pem" -subj "/CN=${HTTP_HOST:-null}" + openssl req -x509 -nodes -days 1825 -newkey rsa:2048 -keyout "/geonode-certificates/autoissued/privkey.pem" -out "/geonode-certificates/autoissued/fullchain.pem" -subj "/CN=${HTTP_HOST:-HTTPS_HOST}" else echo "Autoissued certificate already exists" fi @@ -32,18 +32,25 @@ else ln -sf "/geonode-certificates/autoissued" /certificate_symlink fi -echo "Sanity checks on http/s ports configuration" -if [ -z "${JENKINS_HTTP_PORT}" ]; then - JENKINS_HTTP_PORT=9080 +if [ -z "${HTTPS_HOST}" ]; then + HTTP_SCHEME="http" +else + HTTP_SCHEME="https" fi +export HTTP_SCHEME=${HTTP_SCHEME:-http} +export GEONODE_LB_HOST_IP=${GEONODE_LB_HOST_IP:-django} +export GEONODE_LB_PORT=${GEONODE_LB_PORT:-8000} +export GEOSERVER_LB_HOST_IP=${GEOSERVER_LB_HOST_IP:-geoserver} +export GEOSERVER_LB_PORT=${GEOSERVER_LB_PORT:-8080} + echo "Replacing environement variables" -envsubst '\$HTTP_HOST \$HTTPS_HOST \$RESOLVER' < /etc/nginx/nginx.conf.envsubst > /etc/nginx/nginx.conf -envsubst '\$HTTP_HOST \$HTTPS_HOST \$RESOLVER' < /etc/nginx/nginx.https.available.conf.envsubst > /etc/nginx/nginx.https.available.conf -envsubst '\$HTTP_HOST \$HTTPS_HOST \$JENKINS_HTTP_PORT' < /etc/nginx/sites-enabled/geonode.conf.envsubst > /etc/nginx/sites-enabled/geonode.conf +envsubst '\$HTTP_HOST \$HTTPS_HOST \$HTTP_SCHEME \$GEONODE_LB_HOST_IP \$GEONODE_LB_PORT \$GEOSERVER_LB_HOST_IP \$GEOSERVER_LB_PORT \$RESOLVER' < /etc/nginx/nginx.conf.envsubst > /etc/nginx/nginx.conf +envsubst '\$HTTP_HOST \$HTTPS_HOST \$HTTP_SCHEME \$GEONODE_LB_HOST_IP \$GEONODE_LB_PORT \$GEOSERVER_LB_HOST_IP \$GEOSERVER_LB_PORT \$RESOLVER' < /etc/nginx/nginx.https.available.conf.envsubst > /etc/nginx/nginx.https.available.conf +envsubst '\$HTTP_HOST \$HTTPS_HOST \$HTTP_SCHEME \$GEONODE_LB_HOST_IP \$GEONODE_LB_PORT \$GEOSERVER_LB_HOST_IP \$GEOSERVER_LB_PORT' < /etc/nginx/sites-enabled/geonode.conf.envsubst > /etc/nginx/sites-enabled/geonode.conf echo "Enabling or not https configuration" -if [ -z "${HTTPS_HOST}" ]; then +if [ -z "${HTTPS_HOST}" ]; then echo "" > /etc/nginx/nginx.https.enabled.conf else ln -sf /etc/nginx/nginx.https.available.conf /etc/nginx/nginx.https.enabled.conf diff --git a/scripts/docker/nginx/geonode.conf.envsubst b/scripts/docker/nginx/geonode.conf.envsubst index 9291ded95bc..1176ce2cc2b 100644 --- a/scripts/docker/nginx/geonode.conf.envsubst +++ b/scripts/docker/nginx/geonode.conf.envsubst @@ -6,8 +6,14 @@ charset utf-8; # max upload size client_max_body_size 100G; client_body_buffer_size 256K; +client_body_timeout 600s; large_client_header_buffers 4 64k; -proxy_read_timeout 600s; + +proxy_connect_timeout 600; +proxy_send_timeout 600; +proxy_read_timeout 600; +uwsgi_read_timeout 600; +send_timeout 600; fastcgi_hide_header Set-Cookie; @@ -35,33 +41,23 @@ gzip_types # GeoServer location /geoserver { - - # Using a variable is a trick to let Nginx start even if upstream host is not up yet - # (see https://sandro-keil.de/blog/2017/07/24/let-nginx-start-if-upstream-host-is-unavailable-or-down/) - set $upstream geoserver:8080; - - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_pass http://$upstream; -} - -# Jenkins -location /jenkins { - - # Using a variable is a trick to let Nginx start even if upstream host is not up yet - # (see https://sandro-keil.de/blog/2017/07/24/let-nginx-start-if-upstream-host-is-unavailable-or-down/) - set $upstream jenkins:$JENKINS_HTTP_PORT; - # set $upstream $HTTP_HOST$HTTPS_HOST:$JENKINS_HTTP_PORT; - - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_pass http://$upstream; + # Using a variable is a trick to let Nginx start even if upstream host is not up yet + # (see https://sandro-keil.de/blog/2017/07/24/let-nginx-start-if-upstream-host-is-unavailable-or-down/) + set $upstream $GEOSERVER_LB_HOST_IP:$GEOSERVER_LB_PORT; + + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $HTTP_SCHEME; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_hide_header X-Frame-Options; + proxy_pass http://$upstream; + proxy_http_version 1.1; + proxy_redirect http://$upstream $HTTP_SCHEME://$HTTP_HOST; + proxy_request_buffering off; + client_max_body_size 0; } # GeoNode @@ -89,31 +85,10 @@ location /uploaded/ { } } -location ~ ^/celery-monitor/? { - - # Using a variable is a trick to let Nginx start even if upstream host is not up yet - # (see https://sandro-keil.de/blog/2017/07/24/let-nginx-start-if-upstream-host-is-unavailable-or-down/) - set $upstream celerymonitor:5555; - - rewrite ^/celery-monitor/?(.*)$ /$1 break; - - sub_filter '="/' '="/celery-monitor/'; - sub_filter_last_modified on; - sub_filter_once off; - - # proxy_pass http://unix:/tmp/flower.sock:/; - proxy_pass http://$upstream; - proxy_redirect off; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_http_version 1.1; -} - location / { # Using a variable is a trick to let Nginx start even if upstream host is not up yet # (see https://sandro-keil.de/blog/2017/07/24/let-nginx-start-if-upstream-host-is-unavailable-or-down/) - set $upstream django:8000; + set $upstream $GEONODE_LB_HOST_IP:$GEONODE_LB_PORT; if ($request_method = OPTIONS) { add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, OPTIONS"; @@ -128,27 +103,26 @@ location / { add_header Access-Control-Allow-Credentials false; add_header Access-Control-Allow-Headers "Content-Type, Accept, Authorization, Origin, User-Agent"; add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, OPTIONS"; - - proxy_connect_timeout 600; - proxy_send_timeout 600; - proxy_read_timeout 600; - send_timeout 600; + proxy_redirect off; proxy_set_header Host $host; + proxy_set_header Origin $HTTP_SCHEME://$host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Host $server_name; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $HTTP_SCHEME; proxy_hide_header X-Frame-Options; + proxy_request_buffering off; # uwsgi_params include /etc/nginx/uwsgi_params; - # proxy_pass http://$upstream; - uwsgi_pass $upstream; + proxy_pass http://$upstream; + # uwsgi_pass $upstream; # when a client closes the connection then keep the channel to uwsgi open. Otherwise uwsgi throws an IOError uwsgi_ignore_client_abort on; + uwsgi_request_buffering off; location ~* \.(?:js|jpg|jpeg|gif|png|tgz|gz|rar|bz2|doc|pdf|ppt|tar|wav|bmp|ttf|rtf|swf|ico|flv|woff|woff2|svg|xml)$ { gzip_static always; diff --git a/scripts/docker/nginx/nginx.conf.envsubst b/scripts/docker/nginx/nginx.conf.envsubst index 4a486226a36..b6065209d51 100644 --- a/scripts/docker/nginx/nginx.conf.envsubst +++ b/scripts/docker/nginx/nginx.conf.envsubst @@ -23,7 +23,7 @@ http { # TODO : do not use unencrypted connection even on LAN, but is it possible to have browser not complaining about unknown authority ? server { listen 80; - server_name $HTTP_HOST 127.0.0.1 geonode; + server_name $HTTP_HOST 127.0.0.1; include sites-enabled/*.conf; } diff --git a/tasks.py b/tasks.py index 5711ab991b9..967c08af386 100755 --- a/tasks.py +++ b/tasks.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- ######################################################################### # # Copyright (C) 2016 OSGeo @@ -23,10 +24,11 @@ import time import docker import socket +import ipaddress import logging import datetime -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse from invoke import task BOOTSTRAP_IMAGE_CHEIP = "codenvy/che-ip:nightly" @@ -41,20 +43,12 @@ def waitfordbs(ctx): ctx.run(f"/usr/bin/wait-for-databases {db_host}", pty=True) -@task -def waitforgeoserver(ctx): - print("****************************geoserver********************************") - while not _gs_service_availability(f"{os.environ['GEOSERVER_LOCATION']}ows"): - print("Wait for GeoServer API availability...") - print("GeoServer is available for HTTP calls!") - - @task def update(ctx): print("***************************setting env*********************************") ctx.run("env", pty=True) - pub_ip = _geonode_public_host_ip() - print(f"Public Hostname or IP is {pub_ip}") + pub_host = _geonode_public_host() + print(f"Public Hostname is {pub_host}") pub_port = _geonode_public_port() print(f"Public PORT is {pub_port}") pub_protocol = "https" if pub_port == "443" else "http" @@ -62,13 +56,14 @@ def update(ctx): pub_port = None db_url = _update_db_connstring() geodb_url = _update_geodb_connstring() - service_ready = False - while not service_ready: + geonode_docker_host = None + for _cnt in range(1, 29): try: - socket.gethostbyname("geonode") - service_ready = True + geonode_docker_host = str(socket.gethostbyname("geonode")) + break except Exception: - time.sleep(10) + print(f"...waiting for NGINX to pop-up...{_cnt}") + time.sleep(1) override_env = "$HOME/.override_env" if os.path.exists(override_env): @@ -77,24 +72,24 @@ def update(ctx): print(f"Can not delete the {override_env} file as it doesn't exists") if pub_port: - siteurl = f"{pub_protocol}://{pub_ip}:{pub_port}/" - gs_pub_loc = f"http://{pub_ip}:{pub_port}/geoserver/" + siteurl = f"{pub_protocol}://{pub_host}:{pub_port}/" + gs_pub_loc = f"http://{pub_host}:{pub_port}/geoserver/" else: - siteurl = f"{pub_protocol}://{pub_ip}/" - gs_pub_loc = f"http://{pub_ip}/geoserver/" + siteurl = f"{pub_protocol}://{pub_host}/" + gs_pub_loc = f"http://{pub_host}/geoserver/" envs = { "local_settings": str(_localsettings()), "siteurl": os.environ.get("SITEURL", siteurl), - "geonode_docker_host": str(socket.gethostbyname("geonode")), + "geonode_docker_host": geonode_docker_host, "public_protocol": pub_protocol, - "public_fqdn": str(pub_ip) + str(f":{pub_port}" if pub_port else ""), - "public_host": str(pub_ip), + "public_fqdn": str(pub_host) + str(f":{pub_port}" if pub_port else ""), + "public_host": str(pub_host), "dburl": os.environ.get("DATABASE_URL", db_url), "geodburl": os.environ.get("GEODATABASE_URL", geodb_url), "static_root": os.environ.get("STATIC_ROOT", "/mnt/volumes/statics/static/"), "media_root": os.environ.get("MEDIA_ROOT", "/mnt/volumes/statics/uploaded/"), "geoip_path": os.environ.get("GEOIP_PATH", "/mnt/volumes/statics/geoip.db"), - "monitoring": os.environ.get("MONITORING_ENABLED", True), + "monitoring": os.environ.get("MONITORING_ENABLED", False), "monitoring_host_name": os.environ.get("MONITORING_HOST_NAME", "geonode"), "monitoring_service_name": os.environ.get("MONITORING_SERVICE_NAME", "local-geonode"), "monitoring_data_ttl": os.environ.get("MONITORING_DATA_TTL", 7), @@ -118,7 +113,7 @@ def update(ctx): ) except ValueError: current_allowed = [] - current_allowed.extend([str(pub_ip), f"{pub_ip}:{pub_port}"]) + current_allowed.extend([str(pub_host), f"{pub_host}:{pub_port}"]) allowed_hosts = [str(c) for c in current_allowed] + ['"geonode"', '"django"'] ctx.run( @@ -326,9 +321,15 @@ def update(ctx): def migrations(ctx): print("**************************migrations*******************************") ctx.run(f"python manage.py migrate --noinput --settings={_localsettings()}", pty=True) - ctx.run(f"python manage.py migrate --noinput --settings={_localsettings()} --database=datastore", pty=True) + ctx.run( + f"python manage.py migrate --noinput --settings={_localsettings()} --database=datastore", + pty=True, + ) try: - ctx.run(f"python manage.py rebuild_index --noinput --settings={_localsettings()}", pty=True) + ctx.run( + f"python manage.py rebuild_index --noinput --settings={_localsettings()}", + pty=True, + ) except Exception: pass @@ -338,7 +339,10 @@ def statics(ctx): print("**************************statics*******************************") try: ctx.run("mkdir -p /mnt/volumes/statics/{static,uploads}") - ctx.run(f"python manage.py collectstatic --noinput --settings={_localsettings()}", pty=True) + ctx.run( + f"python manage.py collectstatic --noinput --settings={_localsettings()}", + pty=True, + ) except Exception: import traceback @@ -352,28 +356,6 @@ def prepare(ctx): _prepare_oauth_fixture() ctx.run("rm -rf /tmp/default_site.json", pty=True) _prepare_site_fixture() - # Updating OAuth2 Service Config - new_ext_ip = os.environ["SITEURL"] - client_id = os.environ["OAUTH2_CLIENT_ID"] - client_secret = os.environ["OAUTH2_CLIENT_SECRET"] - oauth_config = "/geoserver_data/data/security/filter/geonode-oauth2/config.xml" - ctx.run(f'sed -i "s|.*|{client_id}|g" {oauth_config}', pty=True) - ctx.run( - f'sed -i "s|.*|{client_secret}|g" {oauth_config}', - pty=True, - ) - ctx.run( - f'sed -i "s|.*|{new_ext_ip}o/authorize/|g" {oauth_config}', # noqa - pty=True, - ) - ctx.run( - f'sed -i "s|.*|{new_ext_ip}geoserver/index.html|g" {oauth_config}', # noqa - pty=True, - ) - ctx.run( - f'sed -i "s|.*|{new_ext_ip}account/logout/|g" {oauth_config}', - pty=True, - ) @task @@ -400,31 +382,36 @@ def fixtures(ctx): def collectstatic(ctx): print("************************static artifacts******************************") ctx.run( - f"django-admin.py collectstatic --noinput \ + f"django-admin collectstatic --noinput \ --settings={_localsettings()}", pty=True, ) -@task -def geoserverfixture(ctx): - print("********************geoserver fixture********************************") - _geoserver_info_provision(f"{os.environ['GEOSERVER_LOCATION']}rest/") - - @task def monitoringfixture(ctx): - print("*******************monitoring fixture********************************") - ctx.run("rm -rf /tmp/default_monitoring_apps_docker.json", pty=True) - _prepare_monitoring_fixture() - try: - ctx.run( - f"django-admin.py loaddata /tmp/default_monitoring_apps_docker.json \ ---settings={_localsettings()}", - pty=True, - ) - except Exception as e: - logger.error(f"ERROR installing monitoring fixture: {str(e)}") + if ast.literal_eval(os.environ.get("MONITORING_ENABLED", "False")): + print("*******************monitoring fixture********************************") + ctx.run("rm -rf /tmp/default_monitoring_apps_docker.json", pty=True) + _prepare_monitoring_fixture() + try: + ctx.run( + f"django-admin loaddata geonode/monitoring/fixtures/metric_data.json \ + --settings={_localsettings()}", + pty=True, + ) + ctx.run( + f"django-admin loaddata geonode/monitoring/fixtures/notifications.json \ + --settings={_localsettings()}", + pty=True, + ) + ctx.run( + f"django-admin loaddata /tmp/default_monitoring_apps_docker.json \ + --settings={_localsettings()}", + pty=True, + ) + except Exception as e: + logger.error(f"ERROR installing monitoring fixture: {str(e)}") @task @@ -432,10 +419,11 @@ def updateadmin(ctx): print("***********************update admin details**************************") ctx.run("rm -rf /tmp/django_admin_docker.json", pty=True) _prepare_admin_fixture( - os.environ.get("ADMIN_PASSWORD", "admin"), os.environ.get("ADMIN_EMAIL", "admin@example.org") + os.environ.get("ADMIN_PASSWORD", "admin"), + os.environ.get("ADMIN_EMAIL", "admin@example.org"), ) ctx.run( - f"django-admin.py loaddata /tmp/django_admin_docker.json \ + f"django-admin loaddata /tmp/django_admin_docker.json \ --settings={_localsettings()}", pty=True, ) @@ -482,6 +470,14 @@ def _docker_host_ip(): return ip_list[0] +def _is_valid_ip(ip): + try: + ipaddress.IPv4Address(ip) + return True + except Exception as e: + return False + + def _container_exposed_port(component, instname): port = "80" try: @@ -490,7 +486,10 @@ def _container_exposed_port(component, instname): [ c.attrs["Config"]["ExposedPorts"] for c in client.containers.list( - filters={"label": f"org.geonode.component={component}", "status": "running"} + filters={ + "label": f"org.geonode.component={component}", + "status": "running", + } ) if str(instname) in c.name ][0] @@ -529,26 +528,16 @@ def _localsettings(): return settings -def _gs_service_availability(url): - import requests - - try: - r = requests.request("get", url, verify=False) - r.raise_for_status() # Raises a HTTPError if the status is 4xx, 5xxx - except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: - logger.error(f"GeoServer connection error is {e}") - return False - except requests.exceptions.HTTPError as er: - logger.error(f"GeoServer HTTP error is {er}") - return False - else: - logger.info("GeoServer API are available!") - return True +def _geonode_public_host(): + gn_pub_hostip = os.getenv("GEONODE_LB_HOST_IP", None) + if not gn_pub_hostip: + gn_pub_hostip = _docker_host_ip() + return gn_pub_hostip def _geonode_public_host_ip(): gn_pub_hostip = os.getenv("GEONODE_LB_HOST_IP", None) - if not gn_pub_hostip: + if not gn_pub_hostip or not _is_valid_ip(gn_pub_hostip): gn_pub_hostip = _docker_host_ip() return gn_pub_hostip @@ -562,33 +551,8 @@ def _geonode_public_port(): return gn_pub_port -def _geoserver_info_provision(url): - from django.conf import settings - from geoserver.catalog import Catalog - - print("Setting GeoServer Admin Password...") - cat = Catalog(url, username=settings.OGC_SERVER_DEFAULT_USER, password=settings.OGC_SERVER_DEFAULT_PASSWORD) - headers = {"Content-type": "application/xml", "Accept": "application/xml"} - data = f""" - - {(os.getenv('GEOSERVER_ADMIN_PASSWORD', 'geoserver'))} -""" - - response = cat.http_request(f"{cat.service_url}/security/self/password", method="PUT", data=data, headers=headers) - print(f"Response Code: {response.status_code}") - if response.status_code == 200: - print("GeoServer admin password updated SUCCESSFULLY!") - else: - logger.warning(f"WARNING: GeoServer admin password *NOT* updated: code [{response.status_code}]") - - def _prepare_oauth_fixture(): upurl = urlparse(os.environ["SITEURL"]) - net_scheme = upurl.scheme - pub_ip = _geonode_public_host_ip() - print(f"Public Hostname or IP is {pub_ip}") - pub_port = _geonode_public_port() - print(f"Public PORT is {pub_port}") default_fixture = [ { "model": "oauth2_provider.application", @@ -598,9 +562,7 @@ def _prepare_oauth_fixture(): "created": "2018-05-31T10:00:31.661Z", "updated": "2018-05-31T11:30:31.245Z", "algorithm": "RS256", - "redirect_uris": f"{net_scheme}://{pub_ip}:{pub_port}/geoserver/index.html" - if pub_port - else f"{net_scheme}://{pub_ip}/geoserver/index.html", + "redirect_uris": f"{urlunparse(upurl)}geoserver/index.html", "name": "GeoServer", "authorization_grant_type": "authorization-code", "client_type": "confidential", @@ -617,7 +579,11 @@ def _prepare_oauth_fixture(): def _prepare_site_fixture(): upurl = urlparse(os.environ["SITEURL"]) default_fixture = [ - {"model": "sites.site", "pk": 1, "fields": {"domain": str(upurl.hostname), "name": str(upurl.hostname)}} + { + "model": "sites.site", + "pk": 1, + "fields": {"domain": str(upurl.hostname), "name": str(upurl.hostname)}, + } ] with open("/tmp/default_site.json", "w") as fixturefile: json.dump(default_fixture, fixturefile) @@ -642,11 +608,19 @@ def _prepare_monitoring_fixture(): d = "1970-01-01 00:00:00" default_fixture = [ { - "fields": {"active": True, "ip": str(geonode_ip), "name": str(os.environ["MONITORING_HOST_NAME"])}, + "fields": { + "active": True, + "ip": str(geonode_ip), + "name": str(os.environ["MONITORING_HOST_NAME"]), + }, "model": "monitoring.host", "pk": 1, }, - {"fields": {"active": True, "ip": str(geoserver_ip), "name": "geoserver"}, "model": "monitoring.host", "pk": 2}, + { + "fields": {"active": True, "ip": str(geoserver_ip), "name": "geoserver"}, + "model": "monitoring.host", + "pk": 2, + }, { "fields": { "name": str(os.environ["MONITORING_SERVICE_NAME"]), diff --git a/uwsgi.ini b/uwsgi.ini index a0f786b7058..c519637152e 100644 --- a/uwsgi.ini +++ b/uwsgi.ini @@ -1,6 +1,7 @@ [uwsgi] -uwsgi-socket = 0.0.0.0:8000 -http-socket = 0.0.0.0:8001 +# uwsgi-socket = 0.0.0.0:8000 +http-socket = 0.0.0.0:8000 +logto = /var/log/geonode.log # pidfile = /tmp/geonode.pid chdir = /usr/src/geonode/ @@ -13,15 +14,12 @@ vacuum = true ; Delete sockets during shutdown single-interpreter = true die-on-term = true ; Shutdown when receiving SIGTERM (default is respawn) need-app = true - -# logging -# path to where uwsgi logs will be saved -logto = /var/log/geonode.log +thunder-lock = true touch-reload = /usr/src/geonode/geonode/wsgi.py buffer-size = 32768 -harakiri = 60 ; forcefully kill workers after 60 seconds +harakiri = 600 ; forcefully kill workers after 600 seconds py-callos-afterfork = true ; allow workers to trap signals max-requests = 1000 ; Restart workers after this many requests @@ -43,4 +41,4 @@ cheaper-busyness-backlog-alert = 16 ; Spawn emergency workers if more than this cheaper-busyness-backlog-step = 2 ; How many emergency workers to create if there are too many requests in the queue # daemonize = /var/log/uwsgi/geonode.log -# cron = -1 -1 -1 -1 -1 /usr/local/bin/python /usr/src/{{project_name}}/manage.py collect_metrics -n +# cron = -1 -1 -1 -1 -1 /usr/local/bin/python /usr/src/geonode/manage.py collect_metrics -n From 0a5f27ea97f93970660aecb258796b66526dc30a Mon Sep 17 00:00:00 2001 From: afabiani Date: Wed, 23 Aug 2023 09:44:43 +0200 Subject: [PATCH 121/330] - Align setup.cfg to requirements.txt --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 516ea95be42..01f2836b571 100644 --- a/setup.cfg +++ b/setup.cfg @@ -198,6 +198,7 @@ install_requires = mistune==3.0.1 protobuf==3.20.3 mako==1.2.4 + paramiko==3.3.1 # not directly required, fixes Blowfish deprecation warning certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability jwcrypto>=1.4 # not directly required, pinned by Snyk to avoid a vulnerability cryptography>=41.0.0 # not directly required, pinned by Snyk to avoid a vulnerability From c3f275cb8a9ce489df1cdfb612012c5a65bafdf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Aug 2023 09:45:46 +0200 Subject: [PATCH 122/330] Bump boto3 from 1.28.15 to 1.28.31 (#11401) * Bump boto3 from 1.28.15 to 1.28.31 Bumps [boto3](https://github.com/boto/boto3) from 1.28.15 to 1.28.31. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.28.15...1.28.31) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8e5702f81fa..17219ad44c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 -boto3==1.28.15 +boto3==1.28.31 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index 01f2836b571..adda03264b7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 - boto3==1.28.15 + boto3==1.28.31 # Django Caches python-memcached<=1.59 From 597edc12acd997070fca1747d76fd6ebacab0299 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Aug 2023 09:47:30 +0200 Subject: [PATCH 123/330] Bump dj-database-url from 2.0.0 to 2.1.0 (#11393) * Bump dj-database-url from 2.0.0 to 2.1.0 Bumps [dj-database-url](https://github.com/jazzband/dj-database-url) from 2.0.0 to 2.1.0. - [Release notes](https://github.com/jazzband/dj-database-url/releases) - [Changelog](https://github.com/jazzband/dj-database-url/blob/master/CHANGELOG.md) - [Commits](https://github.com/jazzband/dj-database-url/compare/v2.0.0...v2.1.0) --- updated-dependencies: - dependency-name: dj-database-url dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 17219ad44c6..feb024de6ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -65,7 +65,7 @@ numpy==1.25.* # # Apps with packages provided in GeoNode's PPA on Launchpad. # Django Apps -dj-database-url==2.0.0 +dj-database-url==2.1.0 dj-pagination==2.5.0 django-select2==8.1.2 django-floppyforms<1.10.0 diff --git a/setup.cfg b/setup.cfg index adda03264b7..a1ad378b070 100644 --- a/setup.cfg +++ b/setup.cfg @@ -91,7 +91,7 @@ install_requires = # # Apps with packages provided in GeoNode's PPA on Launchpad. # Django Apps - dj-database-url==2.0.0 + dj-database-url==2.1.0 dj-pagination==2.5.0 django-select2==8.1.2 django-floppyforms<1.10.0 From 3c47799c6103016170f6d7ff479d2c3cb29277d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Aug 2023 09:47:53 +0200 Subject: [PATCH 124/330] Bump django-grappelli from 3.0.6 to 3.0.7 (#11392) * Bump django-grappelli from 3.0.6 to 3.0.7 Bumps [django-grappelli](https://github.com/sehmaschine/django-grappelli) from 3.0.6 to 3.0.7. - [Changelog](https://github.com/sehmaschine/django-grappelli/blob/master/docs/changelog.rst) - [Commits](https://github.com/sehmaschine/django-grappelli/compare/3.0.6...3.0.7) --- updated-dependencies: - dependency-name: django-grappelli dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index feb024de6ae..7ee5c632236 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,7 +44,7 @@ django-downloadview==2.3.0 django-polymorphic==3.1.0 django-tastypie<0.15.0 django-tinymce==3.6.1 -django-grappelli==3.0.6 +django-grappelli==3.0.7 django-uuid-upload-path==1.0.0 django-widget-tweaks==1.4.12 django-sequences==2.8 diff --git a/setup.cfg b/setup.cfg index a1ad378b070..817f5d27c26 100644 --- a/setup.cfg +++ b/setup.cfg @@ -70,7 +70,7 @@ install_requires = django-polymorphic==3.1.0 django-tastypie<0.15.0 django-tinymce==3.6.1 - django-grappelli==3.0.6 + django-grappelli==3.0.7 django-uuid-upload-path==1.0.0 django-widget-tweaks==1.4.12 django-sequences==2.8 From d60c6945303904fcb49961dd58877de4a983f520 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Aug 2023 09:49:12 +0200 Subject: [PATCH 125/330] Bump sqlalchemy from 2.0.19 to 2.0.20 (#11391) * Bump sqlalchemy from 2.0.19 to 2.0.20 Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.19 to 2.0.20. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7ee5c632236..875cb274a00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -56,7 +56,7 @@ pyjwt==2.8.0 pyproj<3.7.0 OWSLib==0.29.2 pycsw==2.6.1 -SQLAlchemy==2.0.19 # required by PyCSW +SQLAlchemy==2.0.20 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 diff --git a/setup.cfg b/setup.cfg index 817f5d27c26..bbf44430f3f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -82,7 +82,7 @@ install_requires = pyproj<3.7.0 OWSLib==0.29.2 pycsw==2.6.1 - SQLAlchemy==2.0.19 # required by PyCSW + SQLAlchemy==2.0.20 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 From 027e97365ea9bd65fb8a5dca725bd584294fb111 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Aug 2023 09:49:32 +0200 Subject: [PATCH 126/330] Update setuptools requirement from <68.1.0,>=59.1.1 to >=59.1.1,<68.2.0 (#11390) * Update setuptools requirement from <68.1.0,>=59.1.1 to >=59.1.1,<68.2.0 Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v65.5.1...v68.1.0) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 875cb274a00..66b5d23adc6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -159,7 +159,7 @@ pytest-bdd==6.1.1 splinter==0.19.0 pytest-splinter==3.3.2 pytest-django==4.5.2 -setuptools>=59.1.1,<68.1.0 +setuptools>=59.1.1,<68.2.0 pip==23.2.1 Twisted==22.10.0 pixelmatch==0.3.0 diff --git a/setup.cfg b/setup.cfg index bbf44430f3f..a620e56c964 100644 --- a/setup.cfg +++ b/setup.cfg @@ -184,7 +184,7 @@ install_requires = splinter==0.19.0 pytest-splinter==3.3.2 pytest-django==4.5.2 - setuptools>=59.1.1,<68.1.0 + setuptools>=59.1.1,<68.2.0 pip==23.2.1 Twisted==22.10.0 pixelmatch==0.3.0 From 16cd8c1c31e6e75127ed1c1f283989506604ecc1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Aug 2023 09:49:49 +0200 Subject: [PATCH 127/330] Bump coverage from 7.2.7 to 7.3.0 (#11377) * Bump coverage from 7.2.7 to 7.3.0 Bumps [coverage](https://github.com/nedbat/coveragepy) from 7.2.7 to 7.3.0. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.2.7...7.3.0) --- updated-dependencies: - dependency-name: coverage dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 66b5d23adc6..533b56b3c2a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -150,7 +150,7 @@ docker==6.1.3 invoke==2.2.0 # tests -coverage==7.2.7 +coverage==7.3.0 requests-toolbelt==1.0.0 flake8==6.1.0 black==23.7.0 diff --git a/setup.cfg b/setup.cfg index a620e56c964..109aabe8cd2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -175,7 +175,7 @@ install_requires = invoke==2.2.0 # tests - coverage==7.2.7 + coverage==7.3.0 requests-toolbelt==1.0.0 flake8==6.1.0 black==23.7.0 From 6f3fcbb69557b17356f1bd9de1c0aa42016b7beb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Aug 2023 09:50:22 +0200 Subject: [PATCH 128/330] Bump tqdm from 4.65.0 to 4.66.1 (#11375) * Bump tqdm from 4.65.0 to 4.66.1 Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.65.0 to 4.66.1. - [Release notes](https://github.com/tqdm/tqdm/releases) - [Commits](https://github.com/tqdm/tqdm/compare/v4.65.0...v4.66.1) --- updated-dependencies: - dependency-name: tqdm dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 533b56b3c2a..bde78f9ac4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ decorator==5.1.1 celery==5.3.1 kombu==5.3.1 vine==5.0.0 -tqdm==4.65.0 +tqdm==4.66.1 Deprecated==1.2.14 wrapt==1.15.0 jsonschema==4.18.4 diff --git a/setup.cfg b/setup.cfg index 109aabe8cd2..8f1ebdeb3dd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,7 +43,7 @@ install_requires = celery==5.3.1 kombu==5.3.1 vine==5.0.0 - tqdm==4.65.0 + tqdm==4.66.1 Deprecated==1.2.14 wrapt==1.15.0 jsonschema==4.18.4 From c01a408bf9cc230cc52b33798de089b9724605fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Aug 2023 09:50:49 +0200 Subject: [PATCH 129/330] Bump jsonschema from 4.18.4 to 4.19.0 (#11360) * Bump jsonschema from 4.18.4 to 4.19.0 Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.18.4 to 4.19.0. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.18.4...v4.19.0) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index bde78f9ac4f..700eff43551 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ vine==5.0.0 tqdm==4.66.1 Deprecated==1.2.14 wrapt==1.15.0 -jsonschema==4.18.4 +jsonschema==4.19.0 zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 diff --git a/setup.cfg b/setup.cfg index 8f1ebdeb3dd..4de6c39e998 100644 --- a/setup.cfg +++ b/setup.cfg @@ -46,7 +46,7 @@ install_requires = tqdm==4.66.1 Deprecated==1.2.14 wrapt==1.15.0 - jsonschema==4.18.4 + jsonschema==4.19.0 zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 From 3779a58de795c80935342ab28ecf77a8afa991ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Aug 2023 09:51:18 +0200 Subject: [PATCH 130/330] Bump psycopg2 from 2.9.6 to 2.9.7 (#11330) * Bump psycopg2 from 2.9.6 to 2.9.7 Bumps [psycopg2](https://github.com/psycopg/psycopg2) from 2.9.6 to 2.9.7. - [Changelog](https://github.com/psycopg/psycopg2/blob/master/NEWS) - [Commits](https://github.com/psycopg/psycopg2/compare/2.9.6...2.9.7) --- updated-dependencies: - dependency-name: psycopg2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 700eff43551..cf3512a3829 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # native dependencies Pillow==10.0.0 lxml==4.9.3 -psycopg2==2.9.6 +psycopg2==2.9.7 Django==3.2.20 # Other diff --git a/setup.cfg b/setup.cfg index 4de6c39e998..2485f957baf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,7 +27,7 @@ install_requires = # native dependencies Pillow==10.0.0 lxml==4.9.3 - psycopg2==2.9.6 + psycopg2==2.9.7 Django==3.2.20 # Other From 4620143f32d25cc4b848cbfe29aca8d2af8e85f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Aug 2023 09:53:38 +0200 Subject: [PATCH 131/330] Bump boto3 from 1.28.15 to 1.28.32 (#11403) * Bump boto3 from 1.28.15 to 1.28.32 Bumps [boto3](https://github.com/boto/boto3) from 1.28.15 to 1.28.32. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.28.15...1.28.32) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index cf3512a3829..05db52f8a43 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 -boto3==1.28.31 +boto3==1.28.32 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index 2485f957baf..41a528efbc4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 - boto3==1.28.31 + boto3==1.28.32 # Django Caches python-memcached<=1.59 From bf99098fb8bae4c84dc13faf83ccd73f6777b150 Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Wed, 23 Aug 2023 10:20:28 +0200 Subject: [PATCH 132/330] [Fixes #11357] Remove legacy upload code code quality (#11363) * [Fixes #11357] Remove Legacy upload flow * [Fixes #11357] black and flake formatting * [Fixes #11357] Remove dead code * [Fixes #11357] Fix _get_parallel_uploads_count * [Fixes #11357] Fix formatting --------- Co-authored-by: Alessio Fabiani --- geonode/geoserver/context_processors.py | 4 +- geonode/upload/__init__.py | 19 - geonode/upload/api/views.py | 107 +-- geonode/upload/forms.py | 10 - geonode/upload/tasks.py | 228 +---- .../upload/tests/test_upload_preprocessing.py | 154 --- geonode/upload/upload.py | 879 ------------------ geonode/upload/upload_preprocessing.py | 116 --- geonode/upload/urls.py | 2 - geonode/upload/utils.py | 221 +---- geonode/upload/views.py | 685 +------------- 11 files changed, 21 insertions(+), 2404 deletions(-) delete mode 100644 geonode/upload/tests/test_upload_preprocessing.py delete mode 100644 geonode/upload/upload_preprocessing.py diff --git a/geonode/geoserver/context_processors.py b/geonode/geoserver/context_processors.py index 35525dd900d..464cfffa772 100644 --- a/geonode/geoserver/context_processors.py +++ b/geonode/geoserver/context_processors.py @@ -28,8 +28,8 @@ def geoserver_urls(request): GEOSERVER_LOCAL_URL=ogc_server_settings.LOCATION, GEOSERVER_PUBLIC_LOCATION=ogc_server_settings.public_url, GEOSERVER_BASE_URL=ogc_server_settings.public_url, - UPLOADER_URL=reverse("data_upload"), - LAYER_ANCILLARY_FILES_UPLOAD_URL=reverse("dataset_upload"), + UPLOADER_URL=reverse("importer_upload"), + LAYER_ANCILLARY_FILES_UPLOAD_URL=reverse("importer_upload"), MAPFISH_PRINT_ENABLED=getattr(ogc_server_settings, "MAPFISH_PRINT_ENABLED", False), PRINT_NG_ENABLED=getattr(ogc_server_settings, "PRINT_NG_ENABLED", False), GEONODE_SECURITY_ENABLED=getattr(ogc_server_settings, "GEONODE_SECURITY_ENABLED", False), diff --git a/geonode/upload/__init__.py b/geonode/upload/__init__.py index c50c3f5609f..c3ad74d2b54 100644 --- a/geonode/upload/__init__.py +++ b/geonode/upload/__init__.py @@ -38,21 +38,6 @@ def run_setup_hooks(sender, **kwargs): PeriodicTask, ) - check_intervals = IntervalSchedule.objects.filter(every=600, period="seconds") - if not check_intervals.exists(): - check_interval, _ = IntervalSchedule.objects.get_or_create(every=10, period="seconds") - else: - check_interval = check_intervals.first() - - PeriodicTask.objects.update_or_create( - name="finalize-incomplete-session-resources", - defaults=dict( - task="geonode.upload.tasks.finalize_incomplete_session_uploads", - interval=check_interval, - args="", - start_time=timezone.now(), - ), - ) daily_interval, _ = IntervalSchedule.objects.get_or_create(every=1, period="days") PeriodicTask.objects.update_or_create( name="clean-up-old-task-result", @@ -71,10 +56,6 @@ class UploadAppConfig(AppConfig): def ready(self): super().ready() post_migrate.connect(run_setup_hooks, sender=self) - settings.CELERY_BEAT_SCHEDULE["finalize-incomplete-session-resources"] = { - "task": "geonode.upload.tasks.finalize_incomplete_session_uploads", - "schedule": 10.0, - } settings.CELERY_BEAT_SCHEDULE["clean-up-old-task-result"] = { "task": "geonode.upload.tasks.cleanup_celery_task_entries", "schedule": 86400.0, diff --git a/geonode/upload/api/views.py b/geonode/upload/api/views.py index 08df61f66ee..23cfdf6c259 100644 --- a/geonode/upload/api/views.py +++ b/geonode/upload/api/views.py @@ -16,8 +16,6 @@ # along with this program. If not, see . # ######################################################################### -import json -from urllib.parse import parse_qsl, urlparse from dynamic_rest.viewsets import DynamicModelViewSet from dynamic_rest.filters import DynamicFilterBackend, DynamicSortingFilter @@ -26,20 +24,18 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.exceptions import ValidationError, AuthenticationFailed +from rest_framework.exceptions import ValidationError from rest_framework.parsers import FileUploadParser from rest_framework.permissions import IsAuthenticatedOrReadOnly from rest_framework.authentication import SessionAuthentication, BasicAuthentication from oauth2_provider.contrib.rest_framework import OAuth2Authentication -from django.shortcuts import reverse from django.utils.translation import ugettext as _ from geonode.base.api.filters import DynamicSearchFilter from geonode.base.api.permissions import IsOwnerOrReadOnly, IsSelfOrAdminOrReadOnly from geonode.base.api.pagination import GeoNodeApiPagination -from geonode.upload.utils import get_max_amount_of_steps -from geonode.layers.utils import is_vector + from .serializers import ( UploadSerializer, @@ -49,7 +45,6 @@ from .permissions import UploadPermissionsFilter from ..models import Upload, UploadParallelismLimit, UploadSizeLimit -from ..views import view as upload_view import logging @@ -73,109 +68,13 @@ class UploadViewSet(DynamicModelViewSet): pagination_class = GeoNodeApiPagination http_method_names = ["get", "post"] - def _emulate_client_upload_step(self, request, _step): - """Emulates the calls of a client to the upload flow. - It alters the content of the request object, so the same request should - be reused in the next call of this method. - - Args: - request (Request): A request object with the query params given by the lasted step call. - No params for the first call. - _step (string): The current step, used as an argument in the upload_view call. - None for the first call. - - Returns: - Response: response, upload_view response or a final response. - string: next_step, the next step to be performed. - boolean: is_final, True when the last step is executed or in case of errors. - """ - response = upload_view(request, _step) - if response.status_code == status.HTTP_200_OK: - content = response.content - if isinstance(content, bytes): - content = content.decode("UTF-8") - try: - data = json.loads(content) - except json.decoder.JSONDecodeError: - data = content - - next_step = None - if isinstance(data, dict): - response_status = data.get("status", "") - response_success = data.get("success", False) - redirect_to = data.get("redirect_to", "") - if not response_success or not redirect_to or response_status == "finished": - return response, None, True - - # Prepare next step - parsed_redirect_to = urlparse(redirect_to) - if reverse("data_upload") not in parsed_redirect_to.path: - # Error, next step cannot be performed by `upload_view` - return response, None, True - next_step = parsed_redirect_to.path.split(reverse("data_upload"))[1] - query_params = parse_qsl(parsed_redirect_to.query) - request.method = "GET" - request.GET.clear() - for key, value in query_params: - request.GET[key] = value - if next_step: - return response, next_step, False - elif response.status_code == status.HTTP_302_FOUND: - # Get next step, should be final - parsed_redirect_to = urlparse(response.url) - if reverse("data_upload") not in parsed_redirect_to.path: - # Error, next step cannot be performed by `upload_view` - return response, None, True - next_step = parsed_redirect_to.path.split(reverse("data_upload"))[1] - return response, next_step, False - # Error, next step cannot be performed by `upload_view` - return response, None, True - @extend_schema( methods=["post"], responses={201: None}, - description=""" - Starts an upload session based on the Dataset Upload Form. - - the form params look like: - ``` - 'csrfmiddlewaretoken': self.csrf_token, - 'permissions': '{ "users": {"AnonymousUser": ["view_resourcebase"]} , "groups":{}}', - 'time': 'false', - 'charset': 'UTF-8', - 'base_file': base_file, - 'dbf_file': dbf_file, - 'shx_file': shx_file, - 'prj_file': prj_file, - 'tif_file': tif_file - ``` - """, ) @action(detail=False, methods=["post"]) def upload(self, request, format=None): - user = request.user - if not user or not user.is_authenticated: - raise AuthenticationFailed() - - # Custom upload steps defined by user - non_interactive = json.loads(request.data.get("non_interactive", "false").lower()) - if non_interactive: - is_vector_dataset = is_vector(request.FILES.get("base_file").name) - steps_list = (None, "check", "final") if is_vector_dataset else (None, "final") - # Execute steps and get response - for step in steps_list: - response, _, _ = self._emulate_client_upload_step(request, step) - return response - - # Upload steps defined by geonode.upload.utils._pages - next_step = None - max_steps = get_max_amount_of_steps() - for n in range(max_steps): - response, next_step, is_final = self._emulate_client_upload_step(request, next_step) - if is_final: - return response - # After performing 7 steps if we don't get any final response - return response + return [] class UploadSizeLimitViewSet(DynamicModelViewSet): diff --git a/geonode/upload/forms.py b/geonode/upload/forms.py index 0d7dc89345f..51d9be10c6a 100644 --- a/geonode/upload/forms.py +++ b/geonode/upload/forms.py @@ -243,13 +243,3 @@ def clean(self): return self.cleaned_data # @todo implement clean - - -class SRSForm(forms.Form): - source = forms.CharField(required=True) - - target = forms.CharField(required=False) - - -def _supported_type(ext, supported_types): - return any([type_.matches(ext) for type_ in supported_types]) diff --git a/geonode/upload/tasks.py b/geonode/upload/tasks.py index 8deeb6144ba..9f00d9a0030 100644 --- a/geonode/upload/tasks.py +++ b/geonode/upload/tasks.py @@ -17,244 +17,18 @@ # ######################################################################### from datetime import datetime -import json import logging -from celery import chord -from celery.result import allow_join_result -from gsimporter.api import NotFound - from django.conf import settings -from django.utils.timezone import timedelta, now +from django.utils.timezone import timedelta from geonode.celery_app import app -from geonode.base import enumerations -from geonode.geoserver.helpers import gs_uploader - -from geonode.upload.models import Upload -from geonode.upload.views import final_step_view -from geonode.upload.utils import next_step_response -from geonode.resource.manager import resource_manager - -from geonode.tasks.tasks import AcquireLock, FaultTolerantTask logger = logging.getLogger(__name__) UPLOAD_SESSION_EXPIRY_HOURS = getattr(settings, "UPLOAD_SESSION_EXPIRY_HOURS", 24) -@app.task( - bind=True, base=FaultTolerantTask, queue="upload", expires=30, time_limit=600, acks_late=False, ignore_result=True -) -def finalize_incomplete_session_uploads(self, *args, **kwargs): - """The task periodically checks for pending and stale Upload sessions. - It runs every 600 seconds (see the PeriodTask on geonode.upload._init_), - checks first for expired stale Upload sessions and schedule them for cleanup. - We have to make sure To NOT Delete those Unprocessed Ones, - which are in live sessions. - After removing the stale ones, it collects all the unprocessed and runs them - in parallel.""" - - _upload_ids = [] - _upload_tasks = [] - _upload_ids_expired = [] - - # Check first if we need to delete stale sessions - expiry_time = now() - timedelta(hours=UPLOAD_SESSION_EXPIRY_HOURS) - for _upload in Upload.objects.exclude(state=enumerations.STATE_PROCESSED).exclude(date__gt=expiry_time): - _upload.set_processing_state(enumerations.STATE_INVALID) - _upload_ids_expired.append(_upload.id) - _upload_tasks.append(_upload_session_cleanup.signature(args=(_upload.id,))) - - upload_workflow_finalizer = _upload_workflow_finalizer.signature( - args=( - "_upload_session_cleanup", - _upload_ids_expired, - ), - immutable=True, - ).on_error( - _upload_workflow_error.signature( - args=( - "_upload_session_cleanup", - _upload_ids_expired, - ), - immutable=True, - ) - ) - upload_workflow = chord(_upload_tasks, body=upload_workflow_finalizer) - upload_workflow.apply_async(args=(), expiration=30) - - # Let's finish the valid ones - session = None - for _upload in Upload.objects.exclude(state__in=[enumerations.STATE_PROCESSED, enumerations.STATE_INVALID]).exclude( - id__in=_upload_ids_expired - ): - try: - if not _upload.import_id: - raise NotFound - session = _upload.get_session.import_session if _upload.get_session else None - if not session or session.state != enumerations.STATE_COMPLETE: - session = gs_uploader.get_session(_upload.import_id) - except (NotFound, Exception) as e: - logger.exception(e) - session = None - - if session: - _upload_ids.append(_upload.id) - _upload_tasks.append(_update_upload_session_state.signature(args=(_upload.id,))) - elif _upload.state not in (enumerations.STATE_READY, enumerations.STATE_COMPLETE, enumerations.STATE_RUNNING): - if ( - session - and session.state == enumerations.STATE_COMPLETE - and _upload.resource - and _upload.resource.processed - ): - _upload.set_processing_state(enumerations.STATE_PROCESSED) - - upload_workflow_finalizer = _upload_workflow_finalizer.signature( - args=( - "_update_upload_session_state", - _upload_ids, - ), - immutable=True, - ).on_error( - _upload_workflow_error.signature( - args=( - "_update_upload_session_state", - _upload_ids, - ), - immutable=True, - ) - ) - - upload_workflow = chord(_upload_tasks, body=upload_workflow_finalizer) - result = upload_workflow.apply_async(args=(), expiration=30) - if result.ready(): - with allow_join_result(): - return result.get() - return result.state - - -@app.task( - bind=True, base=FaultTolerantTask, queue="upload", expires=30, time_limit=600, acks_late=False, ignore_result=False -) -def _upload_workflow_finalizer(self, task_name: str, upload_ids: list): - """Task invoked at 'upload_workflow.chord' end in the case everything went well.""" - if upload_ids: - logger.info(f"Task {task_name} upload ids: {upload_ids} finished successfully!") - - -@app.task( - bind=True, - base=FaultTolerantTask, - queue="upload", - acks_late=False, - ignore_result=False, -) -def _upload_workflow_error(self, task_name: str, upload_ids: list): - """Task invoked at 'upload_workflow.chord' end in the case some error occurred.""" - logger.error(f"Task {task_name} upload ids: {upload_ids} did not finish correctly!") - for _upload in Upload.objects.filter(id__in=upload_ids): - _upload.set_processing_state(enumerations.STATE_INVALID) - - -@app.task( - bind=True, base=FaultTolerantTask, queue="upload", expires=30, time_limit=600, acks_late=False, ignore_result=False -) -def _update_upload_session_state(self, upload_session_id: int): - """Task invoked by 'upload_workflow.chord' in order to process all the 'PENDING' Upload tasks.""" - lock_id = f"_update_upload_session_state-{upload_session_id}" - with AcquireLock(lock_id) as lock: - if lock.acquire() is True: - _upload = Upload.objects.get(id=upload_session_id) - session = _upload.get_session.import_session - if not session or session.state != enumerations.STATE_COMPLETE: - session = gs_uploader.get_session(_upload.import_id) - - if session: - try: - _response = next_step_response(None, _upload.get_session) - _upload.refresh_from_db() - _content = _response.content - if isinstance(_content, bytes): - _content = _content.decode("UTF-8") - _response_json = json.loads(_content) - _success = _response_json.get("success", False) - _redirect_to = _response_json.get("redirect_to", "") - - _tasks_ready = any([_task.state in ["READY"] for _task in session.tasks]) - _tasks_failed = any([_task.state in ["BAD_FORMAT", "ERROR", "CANCELED"] for _task in session.tasks]) - _tasks_waiting = any( - [_task.state in ["NO_CRS", "NO_BOUNDS", "NO_FORMAT"] for _task in session.tasks] - ) - - if _success: - if _tasks_failed: - # GeoNode Layer creation errored! - _upload.set_processing_state(enumerations.STATE_INVALID) - elif ( - "upload/final" not in _redirect_to - and "upload/check" not in _redirect_to - and (_tasks_waiting or _upload.get_session.time) - ): - _upload.set_resume_url(_redirect_to) - _upload.set_processing_state(enumerations.STATE_WAITING) - elif session.state in (enumerations.STATE_PENDING, enumerations.STATE_RUNNING) and not ( - _tasks_waiting or _tasks_ready - ): - if _upload.resource and not _upload.resource.processed: - # GeoNode Layer updating... - _upload.set_processing_state(enumerations.STATE_RUNNING) - elif ( - session.state == enumerations.STATE_RUNNING - and _upload.resource - and _upload.resource.processed - ): - # GeoNode Layer successfully processed... - _upload.set_processing_state(enumerations.STATE_PROCESSED) - elif ( - session.state == enumerations.STATE_COMPLETE - and _upload.state in (enumerations.STATE_COMPLETE, enumerations.STATE_PENDING) - and not _tasks_waiting - ) or (session.state == enumerations.STATE_PENDING and _tasks_ready): - if not _upload.resource: - _response = final_step_view(None, _upload.get_session) - if _response: - _upload.refresh_from_db() - if ( - _upload.state not in (enumerations.STATE_PROCESSED, enumerations.STATE_RUNNING) - and not _upload.resource - ): - # GeoNode Layer still updating... - _upload.set_processing_state(enumerations.STATE_RUNNING) - logger.debug(f"Upload {upload_session_id} updated with state {_upload.state}.") - except (NotFound, Exception) as e: - logger.exception(e) - if _upload.state not in (enumerations.STATE_COMPLETE, enumerations.STATE_PROCESSED): - _upload.set_processing_state(enumerations.STATE_INVALID) - logger.error(f"Upload {upload_session_id} deleted with state {_upload.state}.") - elif _upload.state != enumerations.STATE_PROCESSED: - _upload.set_processing_state(enumerations.STATE_INVALID) - logger.error( - f"Unable to find the Importer Session - Upload {upload_session_id} deleted with state {_upload.state}." - ) - - -@app.task( - bind=True, base=FaultTolerantTask, queue="upload", expires=30, time_limit=600, acks_late=False, ignore_result=False -) -def _upload_session_cleanup(self, upload_session_id: int): - """Task invoked by 'upload_workflow.chord' in order to remove and cleanup all the 'INVALID' stale Upload tasks.""" - try: - _upload = Upload.objects.get(id=upload_session_id) - if _upload.resource: - resource_manager.delete(_upload.resource.uuid) - _upload.delete() - logger.debug(f"Upload {upload_session_id} deleted with state {_upload.state}.") - except Exception as e: - logger.error(f"Upload {upload_session_id} errored with exception {e}.") - - @app.task(bind=False, acks_late=False, queue="clery_cleanup", ignore_result=True) def cleanup_celery_task_entries(): from django_celery_results.models import TaskResult diff --git a/geonode/upload/tests/test_upload_preprocessing.py b/geonode/upload/tests/test_upload_preprocessing.py deleted file mode 100644 index 752f8351c9a..00000000000 --- a/geonode/upload/tests/test_upload_preprocessing.py +++ /dev/null @@ -1,154 +0,0 @@ -######################################################################### -# -# Copyright (C) 2018 OSGeo -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# -######################################################################### - -"""unit tests for geonode.upload.upload_preprocessing module""" - -from geonode.tests.base import GeoNodeBaseTestSupport - -try: - import unittest.mock as mock -except ImportError: - from unittest import mock -import os.path - -from django.conf import settings -from django.utils.timezone import timedelta, now - -from geonode.upload import files -from geonode.base import enumerations -from geonode.upload.models import Upload -from geonode.upload.utils import get_kml_doc -from geonode.upload import upload_preprocessing -from geonode.upload.tasks import finalize_incomplete_session_uploads - - -class UploadPreprocessingTestCase(GeoNodeBaseTestSupport): - MOCK_PREFIX = "geonode.upload.upload_preprocessing" - - @mock.patch(MOCK_PREFIX + ".convert_kml_ground_overlay_to_geotiff", autospec=True) - def test_preprocess_files_kml_ground_overlay(self, mock_handler): - dirname = "phony" - kml_path = "fake_path.kml" - image_path = "another_fake_path.png" - data = [ - files.SpatialFile( - base_file=kml_path, - file_type=files.get_type("KML Ground Overlay"), - auxillary_files=[image_path], - sld_files=[], - xml_files=[], - ) - ] - spatial_files = files.SpatialFiles(dirname, data) - upload_preprocessing.preprocess_files(spatial_files) - mock_handler.assert_called_with(kml_path, image_path) - - def test_extract_bbox_param(self): - fake_north = "70.000" - kml_bytes = f""" - - - - - - {fake_north} - - - - - """.strip() - kml_doc, ns = get_kml_doc(kml_bytes.encode()) - result = upload_preprocessing._extract_bbox_param(kml_doc, ns, "north") - self.assertEqual(result, fake_north) - - @mock.patch(MOCK_PREFIX + ".subprocess.check_output", autospec=True) - @mock.patch(MOCK_PREFIX + ".get_kml_doc", autospec=True) - @mock.patch(MOCK_PREFIX + "._extract_bbox_param", autospec=True) - def test_convert_kml_ground_overlay_to_geotiff(self, mock_extract_param, mock_get_kml_doc, mock_subprocess): - fake_other_file_path = "the_image.png" - fake_kml_bytes = "nothing" - mock_get_kml_doc.return_value = ("not_relevant", "for_this_test") - fake_north = "1" - fake_south = "2" - fake_east = "3" - fake_west = "4" - mock_extract_param.side_effect = [fake_west, fake_north, fake_east, fake_south] - mock_open = mock.mock_open(read_data=fake_kml_bytes) - with mock.patch(self.MOCK_PREFIX + ".open", mock_open): - upload_preprocessing.convert_kml_ground_overlay_to_geotiff("fake_kml_path", fake_other_file_path) - mock_subprocess.assert_called_with( - [ - "gdal_translate", - "-of", - "GTiff", - "-a_srs", - "EPSG:4326", - "-a_ullr", - fake_west, - fake_north, - fake_east, - fake_south, - fake_other_file_path, - os.path.splitext(fake_other_file_path)[0] + ".tif", - ] - ) - - def test_only_expected_uploads_are_deleted(self): - UPLOAD_SESSION_EXPIRY_HOURS = getattr(settings, "UPLOAD_SESSION_EXPIRY_HOURS", 24) - expiry_time = now() - timedelta(hours=UPLOAD_SESSION_EXPIRY_HOURS) - minutes_before = expiry_time - timedelta(minutes=2) - minutes_after = expiry_time - timedelta(minutes=-2) - - # Uploads either PROCESSED or within expiry time - uploads_to_survive = [ - Upload.objects.create(state=enumerations.STATE_INVALID, date=minutes_after), - Upload.objects.create(state=enumerations.STATE_COMPLETE, date=minutes_after), - Upload.objects.create(state=enumerations.STATE_PROCESSED, date=minutes_after), - Upload.objects.create(state=enumerations.STATE_PROCESSED, date=minutes_before), - Upload.objects.create(state=enumerations.STATE_INCOMPLETE), - Upload.objects.create(state=enumerations.STATE_PENDING), - Upload.objects.create(state=enumerations.STATE_READY), - Upload.objects.create(state=enumerations.STATE_RUNNING), - Upload.objects.create(state=enumerations.STATE_WAITING), - ] - survived_upload_ids = {u.id for u in uploads_to_survive} - - # Uploads not PROCESSED and before expiry time - uploads_to_be_deleted = [ - Upload.objects.create(state=enumerations.STATE_INVALID, date=minutes_before), - Upload.objects.create(state=enumerations.STATE_COMPLETE, date=minutes_before), - Upload.objects.create(state=enumerations.STATE_INCOMPLETE, date=minutes_before), - Upload.objects.create(state=enumerations.STATE_PENDING, date=minutes_before), - Upload.objects.create(state=enumerations.STATE_READY, date=minutes_before), - Upload.objects.create(state=enumerations.STATE_RUNNING, date=minutes_before), - Upload.objects.create(state=enumerations.STATE_WAITING, date=minutes_before), - ] - delete_upload_ids = {u.id for u in uploads_to_be_deleted} - - uploads = Upload.objects.all() - upload_ids = {u.id for u in uploads} - self.assertEqual(uploads.count(), len(uploads_to_survive) + len(uploads_to_be_deleted)) - self.assertEqual(upload_ids, survived_upload_ids.union(delete_upload_ids)) - - finalize_incomplete_session_uploads.delay() - uploads = Upload.objects.all() - upload_ids = {u.id for u in uploads} - # Only uploads_to_survive are not deleted - self.assertEqual(uploads.count(), len(uploads_to_survive)) - self.assertEqual(upload_ids, survived_upload_ids) diff --git a/geonode/upload/upload.py b/geonode/upload/upload.py index b505e1734b7..0777cb83d3a 100644 --- a/geonode/upload/upload.py +++ b/geonode/upload/upload.py @@ -32,55 +32,12 @@ State is stored in a UploaderSession object stored in the user's session. This needs to be made more stateful by adding a model. """ -import pytz -import shutil -import os.path import logging -import zipfile -import datetime -import geoserver -import gsimporter -from geoserver.resource import Coverage, FeatureType - -from django.conf import settings -from django.db.models import Max -from django.db import transaction -from django.db.utils import IntegrityError -from django.contrib.auth import get_user_model -from django.utils.translation import ugettext_lazy as _ - -from geonode import GeoNodeException -from geonode.base import enumerations -from geonode.upload import LayerNotReady -from geonode.tasks.tasks import AcquireLock -from geonode.layers.models import TIME_REGEX_FORMAT -from geonode.resource.manager import resource_manager -from geonode.upload.api.exceptions import GeneralUploadException - -from ..layers.models import Dataset -from ..layers.metadata import parse_metadata -from ..people.utils import get_default_user -from ..layers.utils import get_valid_dataset_name, is_vector -from ..geoserver.helpers import gs_catalog, gs_uploader -from . import utils -from .models import Upload -from .upload_preprocessing import preprocess_files -from geonode.geoserver.helpers import ( - get_dataset_type, - get_dataset_storetype, - create_geoserver_db_featurestore, - ogc_server_settings, -) logger = logging.getLogger(__name__) -def _log(msg, *args, level="error"): - # this logger is used also for debug purpose with error level - getattr(logger, level)(msg, *args) - - class UploaderSession: """All objects held must be able to survive a good pickling""" @@ -158,839 +115,3 @@ def __init__(self, **kw): def cleanup(self): """do what we should at the given state of the upload""" pass - - -def upload( - name, - base_file, - charset, - user=None, - time_attribute=None, - time_transform_type=None, - end_time_attribute=None, - end_time_transform_type=None, - presentation_strategy=None, - precision_value=None, - precision_step=None, - use_big_date=False, - overwrite=False, - mosaic=False, - append_to_mosaic_opts=None, - append_to_mosaic_name=None, - mosaic_time_regex=None, - mosaic_time_value=None, - time_presentation=None, - time_presentation_res=None, - time_presentation_default_value=None, - time_presentation_reference_value=None, -): - if user is None: - user = get_default_user() - if isinstance(user, str): - user = get_user_model().objects.get(username=user) - import_session, upload = save_step( - user, - name, - base_file, - overwrite, - mosaic=mosaic, - append_to_mosaic_opts=append_to_mosaic_opts, - append_to_mosaic_name=append_to_mosaic_name, - mosaic_time_regex=mosaic_time_regex, - mosaic_time_value=mosaic_time_value, - time_presentation=time_presentation, - time_presentation_res=time_presentation_res, - time_presentation_default_value=time_presentation_default_value, - time_presentation_reference_value=time_presentation_reference_value, - ) - upload_session = UploaderSession( - base_file=base_file, - name=name, - charset=charset, - import_session=import_session, - dataset_abstract="", - dataset_title=name, - permissions=None, - mosaic=mosaic, - append_to_mosaic_opts=append_to_mosaic_opts, - append_to_mosaic_name=append_to_mosaic_name, - mosaic_time_regex=mosaic_time_regex, - mosaic_time_value=mosaic_time_value, - ) - time_step( - upload_session, - time_attribute, - time_transform_type, - presentation_strategy, - precision_value, - precision_step, - end_time_attribute=end_time_attribute, - end_time_transform_type=end_time_transform_type, - time_format=None, - ) - utils.run_import(upload_session, async_upload=False) - final_step(upload_session, user, charset=charset) - - -def _get_next_id(): - # importer tracks ids by autoincrement but is prone to corruption - # which potentially may reset the id - hopefully prevent this... - upload_next_id = list(Upload.objects.all().aggregate(Max("import_id")).values())[0] - upload_next_id = upload_next_id if upload_next_id else 0 - # next_id = next_id + 1 if next_id else 1 - importer_sessions = gs_uploader.get_sessions() - last_importer_session = importer_sessions[len(importer_sessions) - 1].id if importer_sessions else 0 - next_id = max(int(last_importer_session), int(upload_next_id)) + 1 - return next_id - - -def _check_geoserver_store(store_name, dataset_type, overwrite): - """Check if the store exists in geoserver""" - try: - store = gs_catalog.get_store(store_name) - except geoserver.catalog.FailedRequestError: - pass # There is no store, ergo the road is clear - else: - if store: - resources = store.get_resources() - if len(resources) == 0: - if overwrite: - _log("Deleting previously existing store") - store.delete() - else: - raise GeoNodeException(_("Dataset already exists")) - else: - for resource in resources: - if resource.name == store_name: - if not overwrite: - raise GeoNodeException(_("Name already in use and overwrite is False")) - existing_type = resource.resource_type or resource.polymorphic_ctype.name - if existing_type != dataset_type: - msg = ( - f"Type of uploaded file {store_name} ({dataset_type}) does not " - "match type of existing resource type " - f"{existing_type}" - ) - _log(msg) - raise GeoNodeException(msg) - - -def save_step( - user, - layer, - spatial_files, - overwrite=True, - store_spatial_files=True, - mosaic=False, - append_to_mosaic_opts=None, - append_to_mosaic_name=None, - mosaic_time_regex=None, - mosaic_time_value=None, - time_presentation=None, - time_presentation_res=None, - time_presentation_default_value=None, - time_presentation_reference_value=None, - charset_encoding="UTF-8", - target_store=None, -): - lock_id = "upload_workflow_save_step" - with AcquireLock(lock_id, blocking=True) as lock: - if lock.acquire() is True: - _log(f"Uploading layer: {layer}, files {spatial_files}") - if len(spatial_files) > 1: - # we only support more than one file if they're rasters for mosaicing - if not all([f.file_type.dataset_type == "coverage" for f in spatial_files]): - msg = "Please upload only one type of file at a time" - logger.exception(Exception(msg)) - raise GeneralUploadException(detail=msg) - name = get_valid_dataset_name(layer, overwrite) - _log(f"Name for layer: {name}") - if not any(spatial_files.all_files()): - msg = "Unable to recognize the uploaded file(s)" - logger.exception(Exception(msg)) - raise GeneralUploadException(detail=msg) - the_dataset_type = get_dataset_type(spatial_files) - _check_geoserver_store(name, the_dataset_type, overwrite) - if the_dataset_type not in (FeatureType.resource_type, Coverage.resource_type): - msg = f"Expected layer type to FeatureType or Coverage, not {the_dataset_type}" - logger.exception(Exception(msg)) - raise GeneralUploadException(msg) - files_to_upload = preprocess_files(spatial_files) - _log(f"files_to_upload: {files_to_upload}") - _log(f"Uploading {the_dataset_type}") - error_msg = None - try: - upload = None - if Upload.objects.filter(user=user, name=name).exists(): - upload = Upload.objects.filter(user=user, name=name).order_by("-date").first() - if upload: - if upload.state == enumerations.STATE_READY: - import_session = upload.get_session.import_session - else: - upload = None - if not upload: - next_id = _get_next_id() - # Truncate name to maximum length defined by the field. - max_length = Upload._meta.get_field("name").max_length - name = name[:max_length] - # save record of this whether valid or not - will help w/ debugging - upload = Upload.objects.create( - user=user, name=name, state=enumerations.STATE_READY, upload_dir=spatial_files.dirname - ) - upload.store_spatial_files = store_spatial_files - - # @todo settings for use_url or auto detection if geoserver is - # on same host - - # Is it a regular file or an ImageMosaic? - # if mosaic_time_regex and mosaic_time_value: - if mosaic: # we want to ingest as ImageMosaic - target_store, files_to_upload = utils.import_imagemosaic_granules( - spatial_files, - append_to_mosaic_opts, - append_to_mosaic_name, - mosaic_time_regex, - mosaic_time_value, - time_presentation, - time_presentation_res, - time_presentation_default_value, - time_presentation_reference_value, - ) - upload.mosaic = mosaic - upload.append_to_mosaic_opts = append_to_mosaic_opts - upload.append_to_mosaic_name = append_to_mosaic_name - upload.mosaic_time_regex = mosaic_time_regex - upload.mosaic_time_value = mosaic_time_value - # moving forward with a regular Importer session - if len(files_to_upload) > 1: - import_session = gs_uploader.upload_files( - files_to_upload[1:], - use_url=False, - # import_id=next_id, - target_store=target_store, - charset_encoding=charset_encoding, - ) - else: - import_session = gs_uploader.upload_files( - files_to_upload, - use_url=False, - # import_id=next_id, - target_store=target_store, - charset_encoding=charset_encoding, - ) - next_id = import_session.id if import_session else None - if not next_id: - error_msg = "No valid Importer Session could be found" - else: - # moving forward with a regular Importer session - import_session = gs_uploader.upload_files( - files_to_upload, - use_url=False, - import_id=next_id, - mosaic=False, - target_store=target_store, - name=name, - charset_encoding=charset_encoding, - ) - if ogc_server_settings.datastore_db and any(map(is_vector, files_to_upload)): - target = create_geoserver_db_featurestore( - store_name=ogc_server_settings.datastore_db["NAME"], - workspace=settings.DEFAULT_WORKSPACE, - ) - task = import_session.tasks[0] - task.set_target( - store_name=target_store or target.name, workspace=settings.DEFAULT_WORKSPACE - ) - - upload.import_id = import_session.id - upload.save() - - # any unrecognized tasks/files must be deleted or we can't proceed - import_session.delete_unrecognized_tasks() - - if not mosaic: - if not import_session.tasks: - error_msg = "No valid upload files could be found" - if import_session.tasks: - if import_session.tasks[0].state == "NO_FORMAT" or import_session.tasks[0].state == "BAD_FORMAT": - error_msg = ( - "There may be a problem with the data provided - " "we could not identify its format" - ) - elif import_session.tasks[0].state == "ERROR": - task = import_session.tasks[0] - error_msg = ( - "Unexpected error durng the GeoServer upload" - "please check GeoServer logs for more information" - ) - - if not mosaic and len(import_session.tasks) > 1: - error_msg = "Only a single upload is supported at the moment" - - if not error_msg and import_session.tasks: - task = import_session.tasks[0] - # single file tasks will have just a file entry - if hasattr(task, "files"): - # @todo gsimporter - test this - if not all([hasattr(f, "timestamp") for f in task.source.files]): - error_msg = ( - "Not all timestamps could be recognized." - "Please ensure your files contain the correct formats." - ) - - if error_msg: - upload.set_processing_state(enumerations.STATE_INVALID) - - # @todo once the random tmp9723481758915 type of name is not - # around, need to track the name computed above, for now, the - # target store name can be used - except Exception as e: - logger.exception(e) - raise e - - if error_msg: - logger.exception(Exception(error_msg)) - raise GeneralUploadException(detail=error_msg) - else: - _log("The File [%s] has been sent to GeoServer without errors.", name, level="debug") - return import_session, upload - - -def time_step( - upload_session, - time_attribute, - time_transform_type, - presentation_strategy, - precision_value, - precision_step, - end_time_attribute=None, - end_time_transform_type=None, - end_time_format=None, - time_format=None, -): - """ - time_attribute - name of attribute to use as time - - time_transform_type - name of transform. either - DateFormatTransform or IntegerFieldToDateTransform - - time_format - optional string format - end_time_attribute - optional name of attribute to use as end time - - end_time_transform_type - optional name of transform. either - DateFormatTransform or IntegerFieldToDateTransform - - end_time_format - optional string format - presentation_strategy - LIST, DISCRETE_INTERVAL, CONTINUOUS_INTERVAL - precision_value - number - precision_step - year, month, day, week, etc. - """ - import_session = upload_session.import_session - transforms = [] - - def build_time_transform(att, type, format, end_time_attribute, presentation_strategy): - trans = {"type": type, "field": att} - if format: - trans["format"] = format - if end_time_attribute: - trans["enddate"] = end_time_attribute - if presentation_strategy: - trans["presentation"] = presentation_strategy - return trans - - def build_att_remap_transform(att): - # @todo the target is so ugly it should be obvious - return { - "type": "AttributeRemapTransform", - "field": att, - "target": "org.geotools.data.postgis.PostGISDialect$XDate", - } - - use_big_date = getattr(settings, "USE_BIG_DATE", False) - - if time_attribute: - if time_transform_type: - transforms.append( - build_time_transform( - time_attribute, time_transform_type, time_format, end_time_attribute, presentation_strategy - ) - ) - - # this must go after the remapping transform to ensure the - # type change is applied - if use_big_date: - transforms.append(build_att_remap_transform(time_attribute)) - if end_time_attribute: - transforms.append(build_att_remap_transform(end_time_attribute)) - - transforms.append({"type": "CreateIndexTransform", "field": time_attribute}) - # the time_info will be used in the last step to configure the - # layer in geoserver - the dict matches the arguments of the - # set_time_info helper function - upload_session.time_info = dict( - attribute=time_attribute, - end_attribute=end_time_attribute, - presentation=presentation_strategy, - precision_value=precision_value, - precision_step=precision_step, - ) - - if upload_session.time_transforms: - import_session.tasks[0].remove_transforms(upload_session.time_transforms) - - if transforms: - _log(f"Setting transforms {transforms}") - import_session.tasks[0].add_transforms(transforms) - try: - upload_session.time_transforms = transforms - upload_session.time = True - except gsimporter.BadRequest as br: - logger.exception(br) - Upload.objects.invalidate_from_session(upload_session) - raise GeneralUploadException(detail=_("Error configuring time: ") + br) - import_session.tasks[0].save_transforms() - else: - upload_session.time = False - try: - import_session = import_session.reload() - except gsimporter.api.NotFound as e: - logger.exception(e) - Upload.objects.invalidate_from_session(upload_session) - raise GeneralUploadException(detail=_("The GeoServer Import Session is no more available ") + str(e)) - upload_session.import_session = import_session - upload_session = Upload.objects.update_from_session(upload_session) - - -def csv_step(upload_session, lat_field, lng_field): - import_session = upload_session.import_session - task = import_session.tasks[0] - - transform = { - "type": "AttributesToPointGeometryTransform", - "latField": lat_field, - "lngField": lng_field, - } - task.remove_transforms([transform], by_field="type", save=False) - task.add_transforms([transform], save=False) - task.save_transforms() - try: - import_session = import_session.reload() - except gsimporter.api.NotFound as e: - logger.exception(e) - Upload.objects.invalidate_from_session(upload_session) - raise GeneralUploadException(detail=_("The GeoServer Import Session is no more available ") + str(e)) - upload_session.import_session = import_session - upload_session = Upload.objects.update_from_session(upload_session) - - -def srs_step(upload_session, source, target): - import_session = upload_session.import_session - task = import_session.tasks[0] - if source: - _log("Setting SRS to %s", source) - task.set_srs(source) - - transform = { - "type": "ReprojectTransform", - "source": source, - "target": target, - } - task.remove_transforms([transform], by_field="type", save=False) - task.add_transforms([transform], save=False) - task.save_transforms() - try: - import_session = import_session.reload() - except gsimporter.api.NotFound as e: - logger.exception(e) - Upload.objects.invalidate_from_session(upload_session) - raise GeneralUploadException(detail=_("The GeoServer Import Session is no more available ") + str(e)) - upload_session.import_session = import_session - upload_session = Upload.objects.update_from_session(upload_session) - - -def final_step(upload_session, user, charset="UTF-8", dataset_id=None): - import_session = upload_session.import_session - if import_session: - import_id = import_session.id - saved_dataset = None - lock_id = f"final_step-{import_id}" - with AcquireLock(lock_id, blocking=True) as lock: - if lock.acquire() is True: - _upload = None - try: - _upload = Upload.objects.get(import_id=import_id) - saved_dataset = _upload.resource - except Exception as e: - logger.exception(e) - Upload.objects.invalidate_from_session(upload_session) - raise GeneralUploadException(detail=_("The Upload Session is no more available ") + str(e)) - - _log(f"Reloading session {import_id} to check validity") - try: - import_session = gs_uploader.get_session(import_id) - except gsimporter.api.NotFound as e: - logger.exception(e) - Upload.objects.invalidate_from_session(upload_session) - raise GeneralUploadException( - detail=_("The GeoServer Import Session is no more available ") + str(e) - ) - - if import_session.tasks is None or len(import_session.tasks) == 0: - return saved_dataset - - task = import_session.tasks[0] - try: - if charset: - task.set_charset(charset) - except Exception as e: - logger.exception(e) - - # @todo see above in save_step, regarding computed unique name - name = task.layer.name - target = task.target - has_time = False - if upload_session.time and upload_session.time_info and upload_session.time_transforms: - has_time = True - - _vals = dict( - title=upload_session.dataset_title, - abstract=upload_session.dataset_abstract, - alternate=task.get_target_layer_name(), - store=target.name, - name=task.layer.name, - workspace=target.workspace_name, - subtype=get_dataset_storetype(target.store_type) - if not has_time - else get_dataset_storetype("vectorTimeSeries"), - ) - - if saved_dataset: - name = saved_dataset.get_real_instance().name - - _log(f"Getting from catalog [{name}]") - try: - # the importer chooses an available featuretype name late in the game need - # to verify the resource.name otherwise things will fail. This happens - # when the same data is uploaded a second time and the default name is - # chosen - gs_catalog.get_layer(name) - except Exception: - Upload.objects.invalidate_from_session(upload_session) - raise LayerNotReady(_(f"Expected to find layer named '{name}' in geoserver")) - - _tasks_ready = any([_task.state in ["READY"] for _task in import_session.tasks]) - _tasks_failed = any( - [_task.state in ["BAD_FORMAT", "ERROR", "CANCELED"] for _task in import_session.tasks] - ) - _tasks_waiting = any( - [_task.state in ["NO_CRS", "NO_BOUNDS", "NO_FORMAT"] for _task in import_session.tasks] - ) - - if ( - not saved_dataset - and not (_tasks_failed or _tasks_waiting) - and ( - import_session.state == enumerations.STATE_READY - or (import_session.state == enumerations.STATE_PENDING and _tasks_ready) - ) - ): - _log( - f"final_step: Running Import Session {import_session.id} - target: {target.name} - alternate: {task.get_target_layer_name()}" - ) - _log(f" -- session state: {import_session.state} - task state: {task.state}") - - utils.run_import(upload_session, async_upload=False) - - import_session = import_session.reload() - task = import_session.tasks[0] - name = task.layer.name - target = task.target - _vals["store"] = target.name - _vals["name"] = task.layer.name - _vals["workspace"] = target.workspace_name - _vals["alternate"] = task.get_target_layer_name() - elif import_session.state == enumerations.STATE_INCOMPLETE and _tasks_failed: - Upload.objects.invalidate_from_session(upload_session) - raise GeneralUploadException(detail=f"Unknown Session task state: {task.state}") - try: - import_session = gs_uploader.get_session(import_id) - except gsimporter.api.NotFound as e: - logger.exception(e) - Upload.objects.invalidate_from_session(upload_session) - raise GeneralUploadException( - detail=_("The GeoServer Import Session is no more available ") + str(e) - ) - - upload_session.import_session = import_session.reload() - upload_session = Upload.objects.update_from_session(upload_session, resource=saved_dataset) - - _tasks_failed = any( - [_task.state in ["BAD_FORMAT", "ERROR", "CANCELED"] for _task in import_session.tasks] - ) - _tasks_waiting = any( - [_task.state in ["NO_CRS", "NO_BOUNDS", "NO_FORMAT"] for _task in import_session.tasks] - ) - - if import_session.state != enumerations.STATE_COMPLETE or _tasks_waiting or _tasks_failed: - if import_session.state == enumerations.STATE_PENDING and _tasks_waiting: - if any([_task.state == "NO_CRS" for _task in import_session.tasks]): - _redirect_to = f"/upload/srs?id={import_session.id}" - _upload.set_resume_url(_redirect_to) - _upload.set_processing_state(enumerations.STATE_WAITING) - elif _tasks_failed: - _upload.set_processing_state(enumerations.STATE_INVALID) - return None - - _log(f"Creating Django record for [{name}]") - if Upload.objects.filter(import_id=import_id).exists(): - if Upload.objects.filter(import_id=import_id).count() > 1: - Upload.objects.invalidate_from_session(upload_session) - _cause = f"More than Upload Session associated to the Importer ID {import_id}" - raise GeneralUploadException(detail=f"Import Session failed.{str(_cause)}") - saved_dataset = Upload.objects.filter(import_id=import_id).get().resource - - dataset_uuid = None - - if saved_dataset: - _vals["name"] = saved_dataset.get_real_instance().name - _log( - f"Django record for [{saved_dataset.get_real_instance().name}] already exists, updating with vals: {_vals}" - ) - return resource_manager.update(saved_dataset.uuid, instance=saved_dataset, vals=_vals) - else: - _log(f"Django record for [{name}] does not exist, creating with vals: {_vals}") - - metadata_uploaded = False - xml_file = upload_session.base_file[0].xml_files - if xml_file and os.path.exists(xml_file[0]): - try: - # get model properties from XML - # If it's contained within a zip, need to extract it - if upload_session.base_file.archive: - archive = upload_session.base_file.archive - zf = zipfile.ZipFile(archive, "r", allowZip64=True) - zf.extract(xml_file[0], os.path.dirname(archive)) - # Assign the absolute path to this file - xml_file = f"{os.path.dirname(archive)}/{xml_file[0]}" - - # Sanity checks - if isinstance(xml_file, list): - if len(xml_file) > 0: - xml_file = xml_file[0] - else: - xml_file = None - elif not isinstance(xml_file, str): - xml_file = None - - if xml_file and os.path.exists(xml_file[0]) and os.access(xml_file, os.R_OK): - dataset_uuid, vals, regions, keywords, custom = parse_metadata(open(xml_file).read()) - metadata_uploaded = True - _vals.update(vals) - except Exception as e: - Upload.objects.invalidate_from_session(upload_session) - logger.exception(e) - raise GeneralUploadException( - detail=_("Exception occurred while parsing the provided Metadata file.") + str(e) - ) - - # look for SLD - sld_file = upload_session.base_file[0].sld_files - sld_uploaded = False - if sld_file: - # If it's contained within a zip, need to extract it - if upload_session.base_file.archive: - archive = upload_session.base_file.archive - _log(f"using uploaded sld file from {archive}") - zf = zipfile.ZipFile(archive, "r", allowZip64=True) - zf.extract(sld_file[0], os.path.dirname(archive), path=upload_session.tempdir) - # Assign the absolute path to this file - sld_file[0] = f"{os.path.dirname(archive)}/{sld_file[0]}" - else: - _sld_file = f"{os.path.dirname(upload_session.tempdir)}/{os.path.basename(sld_file[0])}" - _log(f"copying [{sld_file[0]}] to [{_sld_file}]") - try: - shutil.copyfile(sld_file[0], _sld_file) - sld_file = _sld_file - except (IsADirectoryError, shutil.SameFileError) as e: - logger.exception(e) - sld_file = sld_file[0] - except Exception as e: - logger.exception(e) - raise GeneralUploadException(detail=_("Error uploading Dataset") + str(e)) - sld_uploaded = True - else: - # get_files will not find the sld if it doesn't match the base name - # so we've worked around that in the view - if provided, it will be here - if upload_session.import_sld_file: - _log("using provided sld file from importer") - base_file = upload_session.base_file - sld_file = base_file[0].sld_files[0] - sld_uploaded = False - _log(f"[sld_uploaded: {sld_uploaded}] sld_file: {sld_file}") - - # Make sure the layer does not exists already - if dataset_uuid and Dataset.objects.filter(uuid=dataset_uuid).count(): - Upload.objects.invalidate_from_session(upload_session) - _log("The UUID identifier from the XML Metadata is already in use in this system.") - raise GeneralUploadException( - detail=_("The UUID identifier from the XML Metadata is already in use in this system.") - ) - - # Is it a regular file or an ImageMosaic? - is_mosaic = False - has_time = has_elevation = False - start = end = None - if upload_session.mosaic_time_regex and upload_session.mosaic_time_value: - has_time = True - is_mosaic = True - start = datetime.datetime.strptime( - upload_session.mosaic_time_value, TIME_REGEX_FORMAT[upload_session.mosaic_time_regex] - ) - start = pytz.utc.localize(start, is_dst=False) - end = start - if upload_session.time and upload_session.time_info and upload_session.time_transforms: - has_time = True - - if upload_session.append_to_mosaic_opts: - # Is it a mosaic or a granule that must be added to an Image Mosaic? - saved_dataset_filter = Dataset.objects.filter(name=upload_session.append_to_mosaic_name) - if not saved_dataset_filter.exists(): - try: - saved_dataset = resource_manager.create( - dataset_uuid, - resource_type=Dataset, - defaults=dict( - dirty_state=True, - state=enumerations.STATE_READY, - store=_vals.get("store"), - workspace=_vals.get("workspace"), - name=upload_session.append_to_mosaic_name, - ), - ) - created = True - except IntegrityError as e: - logger.exception(e) - raise GeneralUploadException( - detail=f"There's an incosistent Datasets on the DB for {task.layer.name}{str(e)}" - ) - elif saved_dataset_filter.count() == 1: - saved_dataset = saved_dataset_filter.get() - created = False - else: - raise GeneralUploadException( - detail=f"There's an incosistent number of Datasets on the DB for {upload_session.append_to_mosaic_name}" - ) - saved_dataset.set_dirty_state() - if saved_dataset.temporal_extent_start and end: - if pytz.utc.localize(saved_dataset.temporal_extent_start, is_dst=False) < end: - saved_dataset.temporal_extent_end = end - Dataset.objects.filter(name=upload_session.append_to_mosaic_name).update( - temporal_extent_end=end - ) - else: - saved_dataset.temporal_extent_start = end - Dataset.objects.filter(name=upload_session.append_to_mosaic_name).update( - temporal_extent_start=end - ) - else: - # The dataset is a standard one, no mosaic options enabled... - saved_dataset_filter = Dataset.objects.filter( - store=_vals.get("store"), workspace=_vals.get("workspace"), name=_vals.get("name") - ) - if not saved_dataset_filter.exists(): - try: - files_list = [] - if upload_session.spatial_files_uploaded: - files_list.append(upload_session.base_file.data[0].base_file) - files_list.extend(upload_session.base_file.data[0].auxillary_files) - files_list.extend(upload_session.base_file.data[0].sld_files) - files_list.extend(upload_session.base_file.data[0].xml_files) - saved_dataset = resource_manager.create( - dataset_uuid, - resource_type=Dataset, - defaults=dict( - store=_vals.get("store"), - subtype=_vals.get("subtype"), - alternate=_vals.get("alternate"), - workspace=_vals.get("workspace"), - title=_vals.get("title"), - name=_vals.get("name"), - abstract=_vals.get("abstract", _("No abstract provided")), - owner=user, - dirty_state=True, - state=enumerations.STATE_READY, - temporal_extent_start=start, - temporal_extent_end=end, - is_mosaic=is_mosaic, - has_time=has_time, - has_elevation=has_elevation, - files=files_list, - time_regex=upload_session.mosaic_time_regex, - ), - ) - created = True - except IntegrityError as e: - logger.exception(e) - raise GeneralUploadException( - detail=f"There's an incosistent Datasets on the DB for {task.layer.name}{str(e)}" - ) - elif saved_dataset_filter.count() == 1: - saved_dataset = saved_dataset_filter.get() - created = False - else: - raise GeneralUploadException( - detail=f"There's an incosistent number of Datasets on the DB for {task.layer.name}" - ) - - assert saved_dataset - - # Hide the dataset until the upload process finishes... - saved_dataset.set_processing_state(enumerations.STATE_RUNNING) - saved_dataset.set_dirty_state() - _log(f" -- Finalizing Upload for {saved_dataset}... {saved_dataset.state}") - - # Update the state from session... - upload_session = Upload.objects.update_from_session(upload_session, resource=saved_dataset) - - # Finalize the upload... - # Set default permissions on the newly created layer and send notifications - permissions = upload_session.permissions - - # Finalize Upload - try: - with transaction.atomic(): - resource_manager.set_permissions( - None, instance=saved_dataset, permissions=permissions, created=created - ) - resource_manager.exec( - "set_time_info", None, instance=saved_dataset, time_info=upload_session.time_info - ) - saved_dataset.refresh_from_db() - resource_manager.update( - None, instance=saved_dataset, xml_file=xml_file, metadata_uploaded=metadata_uploaded - ) - resource_manager.exec( - "set_style", - None, - instance=saved_dataset, - sld_uploaded=sld_uploaded, - sld_file=sld_file, - tempdir=upload_session.tempdir, - ) - resource_manager.set_thumbnail(None, instance=saved_dataset) - - Upload.objects.filter(resource=saved_dataset).update(complete=True) - [ - u.set_processing_state(enumerations.STATE_PROCESSED) - for u in Upload.objects.filter(resource=saved_dataset) - ] - except Exception as e: - logger.exception(e) - Upload.objects.filter(resource=saved_dataset).update(complete=False) - [ - u.set_processing_state(enumerations.STATE_INVALID) - for u in Upload.objects.filter(resource=saved_dataset) - ] - finally: - _log(f" -- Upload completed for {saved_dataset}... {saved_dataset.state}") - - return saved_dataset diff --git a/geonode/upload/upload_preprocessing.py b/geonode/upload/upload_preprocessing.py deleted file mode 100644 index ad3ea584853..00000000000 --- a/geonode/upload/upload_preprocessing.py +++ /dev/null @@ -1,116 +0,0 @@ -######################################################################### -# -# Copyright (C) 2018 OSGeo -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# -######################################################################### - -"""Tools for dealing with preprocessing operations in the upload process - -These functions are executed before the data is sent to geoserver. They're -main purpose is to prepare the data so that it can be ingested. - -""" - -from collections import namedtuple -import logging -import os.path -import subprocess - -from .files import get_type -from .utils import get_kml_doc - -logger = logging.getLogger(__name__) - -GdalBoundingBox = namedtuple( - "GdalBoundingBox", - [ - "ulx", - "uly", - "lrx", - "lry", - ], -) - - -def convert_kml_ground_overlay_to_geotiff(kml_path, other_file_path): - """Write a geotiff file to disk from the provided kml and image - - KML files that specify GroundOverlay as their type are accompanied by a - raster file. Since there is no direct support in geoserver for this type - of KML, we extract the relevant information from the KML file and convert - the raster file to a geotiff. - - """ - - with open(kml_path) as kml_handler: - kml_bytes = kml_handler.read() - kml_doc, namespaces = get_kml_doc(kml_bytes) - bbox = GdalBoundingBox( - ulx=_extract_bbox_param(kml_doc, namespaces, "west"), - uly=_extract_bbox_param(kml_doc, namespaces, "north"), - lrx=_extract_bbox_param(kml_doc, namespaces, "east"), - lry=_extract_bbox_param(kml_doc, namespaces, "south"), - ) - dirname, basename = os.path.split(other_file_path) - output_path = os.path.join(dirname, ".".join((os.path.splitext(basename)[0], "tif"))) - command = [ - "gdal_translate", - "-of", - "GTiff", - "-a_srs", - "EPSG:4326", # KML format always uses EPSG:4326 - "-a_ullr", - bbox.ulx, - bbox.uly, - bbox.lrx, - bbox.lry, - other_file_path, - output_path, - ] - subprocess.check_output(command) - return output_path - - -def preprocess_files(spatial_files): - """Pre-process the input spatial files. - - This function is used during the upload workflow. It is called before the - data is sent to geoserver, thus providing a hook to perform custom - pre-processing for specific types of files. - - :arg spatial_files: The files that are about to be uploaded to geoserver - :type spatial_files: geonode.upload.files.SpatialFiles - :returns: A list with the paths of the pre-processed files - - """ - - result = [] - for spatial_file in spatial_files: - if spatial_file.file_type == get_type("KML Ground Overlay"): - auxillary_file = spatial_file.auxillary_files[0] if len(spatial_file.auxillary_files) > 0 else None - preprocessed = convert_kml_ground_overlay_to_geotiff(spatial_file.base_file, auxillary_file) - result.append(preprocessed) - else: - result.extend(spatial_file.all_files()) - if spatial_files.archive is not None: - result.append(spatial_files.archive) - return result - - -def _extract_bbox_param(kml_doc, namespaces, param): - return kml_doc.xpath("kml:Document/kml:GroundOverlay/kml:LatLonBox/" f"kml:{param}/text()", namespaces=namespaces)[ - 0 - ] diff --git a/geonode/upload/urls.py b/geonode/upload/urls.py index 9c5b2306e1b..f65777f2b9e 100644 --- a/geonode/upload/urls.py +++ b/geonode/upload/urls.py @@ -21,8 +21,6 @@ from . import views urlpatterns = [ # 'geonode.upload.views', - url(r"^progress$", views.data_upload_progress, name="data_upload_progress"), - url(r"^(?P\w+)?$", views.view, name="data_upload"), url(r"^delete/(?P\d+)?$", views.delete, name="data_upload_delete"), url(r"^", include("geonode.upload.api.urls")), ] diff --git a/geonode/upload/utils.py b/geonode/upload/utils.py index 2f230d38052..d81f63e9ba5 100644 --- a/geonode/upload/utils.py +++ b/geonode/upload/utils.py @@ -30,10 +30,9 @@ from django.conf import settings from django.urls import reverse -from django.shortcuts import render from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import ugettext as _ -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponse from django.template.defaultfilters import filesizeformat from geoserver.catalog import FailedRequestError, ConflictingDataError @@ -43,7 +42,7 @@ GeneralUploadException, UploadParallelismLimitException, ) -from geonode.upload.models import Upload, UploadSizeLimit, UploadParallelismLimit +from geonode.upload.models import UploadSizeLimit, UploadParallelismLimit from geonode.utils import json_response as do_json_response, unzip_file, mkdtemp from geonode.geoserver.helpers import ( gs_catalog, @@ -52,6 +51,7 @@ get_store, set_time_dimension, ) # mosaic_delete_first_granule +from geonode.resource.models import ExecutionRequest ogr.UseExceptions() @@ -235,19 +235,6 @@ def get_next_step(upload_session, offset=1): return pages[max(min(len(pages) - 1, index + offset), 0)] -def get_previous_step(upload_session, post_to): - assert upload_session.upload_type is not None - - pages = _pages[upload_session.upload_type] - if post_to == "undefined": - post_to = "final" - index = pages.index(post_to) - 1 - - if index < 0: - return "save" - return pages[index] - - def _advance_step(req, upload_session): if upload_session.completed_step != "error": upload_session.completed_step = get_next_step(upload_session) @@ -255,190 +242,10 @@ def _advance_step(req, upload_session): return "error" -def next_step_response(req, upload_session, force_ajax=True): - _force_ajax = "&force_ajax=true" if req and force_ajax and "force_ajax" not in req.GET else "" - if not upload_session: - return json_response( - { - "status": "error", - "success": False, - "id": None, - "error_msg": "No Upload Session provided.", - } - ) - - import_session = upload_session.import_session - # if the current step is the view POST for this step, advance one - if req and req.method == "POST": - if upload_session.completed_step: - _advance_step(req, upload_session) - else: - upload_session.completed_step = "save" - - next = get_next_step(upload_session) - - if next == "error": - return json_response( - { - "status": "error", - "success": False, - "id": import_session.id, - "error_msg": str(upload_session.error_msg), - } - ) - - if next == "check" and import_session.tasks: - store_type = import_session.tasks[0].target.store_type - if store_type == "coverageStore" or _force_ajax: - # @TODO we skip time steps for coverages currently - upload_session.completed_step = "check" - return next_step_response(req, upload_session, force_ajax=True) - if force_ajax: - url = f"{reverse('data_upload')}?id={import_session.id}" - return json_response( - { - "url": url, - "status": "incomplete", - "success": True, - "id": import_session.id, - "redirect_to": f"{settings.SITEURL}upload/check?id={import_session.id}{_force_ajax}", - } - ) - - if next == "time" and import_session.tasks: - store_type = import_session.tasks[0].target.store_type - layer = import_session.tasks[0].layer - (has_time_dim, dataset_values) = dataset_eligible_for_time_dimension(req, layer, upload_session=upload_session) - if store_type == "coverageStore" or not has_time_dim: - # @TODO we skip time steps for coverages currently - upload_session.completed_step = "time" - return next_step_response(req, upload_session, False) - if upload_session.time is None or not upload_session.time: - upload_session.completed_step = "time" - if force_ajax: - url = f"{reverse('data_upload')}?id={import_session.id}" - return json_response( - { - "url": url, - "status": "incomplete", - "required_input": has_time_dim, - "success": True, - "id": import_session.id, - "redirect_to": f"{settings.SITEURL}upload/time?id={import_session.id}{_force_ajax}", - } - ) - else: - return next_step_response(req, upload_session, force_ajax) - - if next == "mosaic" and force_ajax: - url = f"{reverse('data_upload')}?id={import_session.id}" - return json_response( - { - "url": url, - "status": "incomplete", - "required_input": len(_force_ajax) == 0, - "success": True, - "id": import_session.id, - "redirect_to": f"{settings.SITEURL}upload/mosaic?id={import_session.id}{_force_ajax}", - } - ) - - if next == "srs" and force_ajax: - url = f"{reverse('data_upload')}?id={import_session.id}" - return json_response( - { - "url": url, - "status": "incomplete", - "required_input": len(_force_ajax) == 0, - "success": True, - "id": import_session.id, - "redirect_to": f"{settings.SITEURL}upload/srs?id={import_session.id}{_force_ajax}", - } - ) - - if next == "csv" and force_ajax: - url = f"{reverse('data_upload')}?id={import_session.id}" - return json_response( - { - "url": url, - "status": "incomplete", - "required_input": len(_force_ajax) == 0, - "success": True, - "id": import_session.id, - "redirect_to": f"{settings.SITEURL}upload/csv?id={import_session.id}{_force_ajax}", - } - ) - - # @todo this is not handled cleanly - run is not a real step in that it - # has no corresponding view served by the 'view' function. - if next == "run" and import_session.tasks: - upload_session.completed_step = next - if (_ASYNC_UPLOAD and not req) or (req and req.is_ajax()): - return run_response(req, upload_session) - else: - # on sync we want to run the import and advance to the next step - run_import(upload_session, async_upload=False) - return next_step_response(req, upload_session, force_ajax=force_ajax) - session_id = None - if req and "id" in req.GET: - session_id = f"?id={req.GET['id']}" - elif import_session and import_session.id: - session_id = f"?id={import_session.id}" - - if req and req.is_ajax() or force_ajax: - content_type = "text/html" if req and not req.is_ajax() else None - if session_id: - return json_response( - redirect_to=reverse("data_upload", args=[next]) + session_id, content_type=content_type - ) - else: - return json_response(url=reverse("data_upload", args=[next]), content_type=content_type) - - return HttpResponseRedirect(reverse("data_upload", args=[next])) - - -def is_latitude(colname): - return any([_l in colname.lower() for _l in _latitude_names]) - - -def is_longitude(colname): - return any([_l in colname.lower() for _l in _longitude_names]) - - def is_async_step(upload_session): return _ASYNC_UPLOAD and get_next_step(upload_session, offset=2) == "run" -def check_import_session_is_valid(request, upload_session, import_session): - # check for failing Import Session and Import Tasks - assert import_session is not None - assert len(import_session.tasks) > 0 - - for task in import_session.tasks: - if task.state == "ERROR": - progress = task.get_progress() - upload_session.completed_step = "error" - upload_session.error_msg = progress.get("message") - return None - - # check for invalid attribute names - store_type = import_session.tasks[0].target.store_type - if store_type == "dataStore": - try: - layer = import_session.tasks[0].layer - invalid = [a for a in layer.attributes if str(a.name).find(" ") >= 0] - if invalid: - att_list = f"
    {'. '.join([a.name for a in invalid])}
    " - msg = f"Attributes with spaces are not supported : {att_list}" - upload_session.completed_step = "error" - upload_session.error_msg = msg - return layer - except Exception as e: - return render(request, "upload/dataset_upload_error.html", context={"error_msg": str(e)}) - elif store_type == "coverageStore": - return True - - def _get_time_dimensions(layer, upload_session, values=None): date_time_keywords = ["date", "time", "year", "create", "end", "last", "update", "expire", "enddate"] @@ -582,16 +389,6 @@ def progress_redirect(step, upload_id): ) -def run_response(req, upload_session): - run_import(upload_session) - - if _ASYNC_UPLOAD: - next = get_next_step(upload_session) - return progress_redirect(next, upload_session.import_session.id) - - return next_step_response(req, upload_session) - - def get_max_upload_size(slug): try: max_size = UploadSizeLimit.objects.get(slug=slug).max_size @@ -894,4 +691,14 @@ def _get_max_parallel_uploads(self): return parallelism_limit.max_number def _get_parallel_uploads_count(self): - return Upload.objects.get_incomplete_uploads(self.user).count() + """ + Count the total layers that are part of the running import + """ + return sum( + filter( + None, + ExecutionRequest.objects.filter( + user=self.user, status__in=[ExecutionRequest.STATUS_RUNNING, ExecutionRequest.STATUS_READY] + ).values_list("input_params__total_layers", flat=True), + ) + ) diff --git a/geonode/upload/views.py b/geonode/upload/views.py index b707c5d52cd..691e105b871 100644 --- a/geonode/upload/views.py +++ b/geonode/upload/views.py @@ -31,693 +31,17 @@ State is stored in a UploaderSession object stored in the user's session. This needs to be made more stateful by adding a model. """ -import os -import re -import json import logging -import gsimporter -import traceback -from http.client import BadStatusLine - -from django.conf import settings -from django.shortcuts import render -from django.utils.html import escape from django.shortcuts import get_object_or_404 from django.core.exceptions import PermissionDenied -from django.utils.translation import ugettext_lazy as _ from django.contrib.auth.decorators import login_required -from geonode.base import enumerations -from geonode.layers.models import Dataset -from geonode.base.models import Configuration -from geonode.upload.api.exceptions import GeneralUploadException -from rest_framework.exceptions import APIException, AuthenticationFailed -from geonode.decorators import logged_in_or_basicauth - -from geonode.base import register_event -from geonode.monitoring.models import EventType - -from geonode.geoserver.helpers import select_relevant_files -from .forms import LayerUploadForm, SRSForm, TimeForm from .models import Upload -from .files import get_scan_hint, scan_file -from .utils import ( - _ALLOW_TIME_STEP, - _SUPPORTED_CRS, - _geoserver_down_error_msg, - _get_time_dimensions, - check_import_session_is_valid, - is_async_step, - is_latitude, - is_longitude, - json_response, - get_previous_step, - dataset_eligible_for_time_dimension, - next_step_response, -) -from .upload import save_step, srs_step, time_step, csv_step, final_step, LayerNotReady, UploaderSession +from .utils import json_response logger = logging.getLogger(__name__) -def _log(msg, *args, level="error"): - # this logger is used also for debug purpose with error level - getattr(logger, level)(msg, *args) - - -def _get_upload_session(req): - upload_session = None - if "id" in req.GET: - upload_id = str(req.GET["id"]) - upload_obj = get_object_or_404(Upload, import_id=upload_id, user=req.user) - upload_session = upload_obj.get_session - return upload_session - - -def data_upload_progress(req): - """This would not be needed if geoserver REST did not require admin role - and is an inefficient way of getting this information""" - upload_session = _get_upload_session(req) - - if upload_session: - import_session = upload_session.import_session - try: - progress = import_session.tasks[0].get_progress() - return json_response(progress) - except Exception: - pass - - return json_response({"state": "NONE"}) - - -def save_step_view(req, session): - form = LayerUploadForm(req.POST, req.FILES, user=req.user) - - overwrite = req.path_info.endswith("/replace") - target_store = None - if form.is_valid(): - logger.debug(f"valid_extensions: {form.cleaned_data['valid_extensions']}") - data_retriever = form.cleaned_data["data_retriever"] - relevant_files = select_relevant_files( - form.cleaned_data["valid_extensions"], data_retriever.get_paths(allow_transfer=False).values() - ) - logger.debug(f"relevant_files: {relevant_files}") - base_file = data_retriever.get("base_file").get_path(allow_transfer=False) - name, ext = os.path.splitext(os.path.basename(base_file)) - logger.debug(f"Name: {name}, ext: {ext}") - logger.debug(f"base_file: {base_file}") - spatial_files = scan_file(base_file, scan_hint=None, charset=form.cleaned_data["charset"]) - logger.debug(f"spatial_files: {spatial_files}") - - if overwrite: - dataset = Dataset.objects.filter(id=req.GET["dataset_id"]) - if dataset.exists(): - name = dataset.first().name - target_store = dataset.first().store - - import_session, upload = save_step( - req.user, - name, - spatial_files, - overwrite=overwrite, - store_spatial_files=form.cleaned_data.get("store_spatial_files", True), - mosaic=form.cleaned_data["mosaic"] or get_scan_hint(form.cleaned_data["valid_extensions"]) == "zip-mosaic", - append_to_mosaic_opts=form.cleaned_data["append_to_mosaic_opts"], - append_to_mosaic_name=form.cleaned_data["append_to_mosaic_name"], - mosaic_time_regex=form.cleaned_data["mosaic_time_regex"], - mosaic_time_value=form.cleaned_data["mosaic_time_value"], - time_presentation=form.cleaned_data["time_presentation"], - time_presentation_res=form.cleaned_data["time_presentation_res"], - time_presentation_default_value=form.cleaned_data["time_presentation_default_value"], - time_presentation_reference_value=form.cleaned_data["time_presentation_reference_value"], - charset_encoding=form.cleaned_data["charset"], - target_store=target_store, - ) - - if upload and import_session and import_session.state in (enumerations.STATE_READY, enumerations.STATE_PENDING): - import_session.tasks[0].set_charset(form.cleaned_data["charset"]) - sld = None - if spatial_files[0].sld_files: - sld = spatial_files[0].sld_files[0] - - _log(f"provided sld is {sld}") - # upload_type = get_upload_type(base_file) - upload_session = UploaderSession( - tempdir=data_retriever.temporary_folder, - base_file=spatial_files, - name=upload.name, - charset=form.cleaned_data["charset"], - import_session=import_session, - dataset_abstract=form.cleaned_data["abstract"], - dataset_title=form.cleaned_data["dataset_title"], - permissions=form.cleaned_data["permissions"], - import_sld_file=sld, - spatial_files_uploaded=form.cleaned_data["uploaded"], - upload_type=spatial_files[0].file_type.code, - time=form.cleaned_data["time"], - mosaic=form.cleaned_data["mosaic"], - append_to_mosaic_opts=form.cleaned_data["append_to_mosaic_opts"], - append_to_mosaic_name=form.cleaned_data["append_to_mosaic_name"], - mosaic_time_regex=form.cleaned_data["mosaic_time_regex"], - mosaic_time_value=form.cleaned_data["mosaic_time_value"], - user=upload.user, - ) - Upload.objects.update_from_session(upload_session) - return next_step_response(req, upload_session, force_ajax=True) - return next_step_response(req, None, force_ajax=True) - else: - if hasattr(form, "data_retriever"): - form.data_retriever.delete_files() - errors = [] - for e in form.errors.values(): - errors.extend([escape(v) for v in e]) - raise GeneralUploadException(detail=errors) - - -def srs_step_view(request, upload_session): - if not upload_session: - upload_session = _get_upload_session(request) - import_session = upload_session.import_session - assert import_session is not None - - # form errors to display to user - error = None - native_crs = None - _crs_already_configured = False - - form = SRSForm() - for task in import_session.tasks: - # CRS missing/unknown - if task.state == "NO_CRS": - native_crs = task.layer.srs - else: - _crs_already_configured = True - if form: - name = task.layer.name - - force_ajax = ( - "&force_ajax=true" if request and "force_ajax" in request.GET and request.GET["force_ajax"] == "true" else "" - ) - if request.method == "GET": - if not force_ajax: - # layer = check_import_session_is_valid( - # request, upload_session, import_session) - - if not _crs_already_configured: - context = dict( - form=form, - supported_crs=_SUPPORTED_CRS, - async_upload=False, - native_crs=native_crs or None, - dataset_name=name, - error=error, - ) - return render(request, "upload/dataset_upload_crs.html", context=context) - if _crs_already_configured: - upload_session.completed_step = "srs" - return next_step_response(request, upload_session) - elif request.method != "POST": - raise Exception("405 Method Not Allowed") - - source = request.POST.get("source", "") - target = request.POST.get("target", "") - if not source: - error = "Source SRS is mandatory. Please insert an EPSG code (e.g.: EPSG:4326)." - elif not re.search(r"\:", source) and re.search(r"EPSG", source): - source = re.sub(r"(EPSG)", r"EPSG:", source) - - if not error: - if not source.startswith("EPSG:"): - error = "Source SRS is not valid. Please insert a valid EPSG code (e.g.: EPSG:4326)." - else: - if not target: - target = source - elif not re.search(r"\:", target) and re.search(r"EPSG", target): - target = re.sub(r"(EPSG)", r"EPSG:", target) - - if not target.startswith("EPSG:"): - error = "Target SRS is not valid. Please insert a valid EPSG code (e.g.: EPSG:4326)." - else: - srs_step(upload_session, source, target) - return next_step_response(request, upload_session) - - if error: - return json_response( - { - "status": "error", - "success": False, - "id": upload_session.import_session.id, - "error_msg": f"{error}", - } - ) - else: - upload_session.completed_step = "srs" - - return next_step_response(request, upload_session) - - -def csv_step_view(request, upload_session): - if not upload_session: - upload_session = _get_upload_session(request) - import_session = upload_session.import_session - assert import_session is not None - - # form errors to display to user - error = None - - # need to check if geometry is found - # if so, can proceed directly to next step - attributes = import_session.tasks[0].layer.attributes - for attr in attributes: - if attr.binding == "com.vividsolutions.jts.geom.Point": - upload_session.completed_step = "csv" - return next_step_response(request, upload_session) - - # no geometry found, let's find all the numerical columns - number_names = ["java.lang.Integer", "java.lang.Double"] - point_candidates = sorted([attr.name for attr in attributes if attr.binding in number_names]) - - lat_field = request.POST.get("lat", "") - lng_field = request.POST.get("lng", "") - - force_ajax = ( - "&force_ajax=true" if request and "force_ajax" in request.GET and request.GET["force_ajax"] == "true" else "" - ) - if request.method == "GET": - if not force_ajax: - # layer = check_import_session_is_valid( - # request, upload_session, import_session) - - # try to guess the lat/lng fields from the candidates - lat_candidate = None - lng_candidate = None - non_str_in_headers = [] - for candidate in attributes: - if not isinstance(candidate.name, str): - non_str_in_headers.append(str(candidate.name)) - if is_latitude(candidate.name): - lat_candidate = candidate.name - if lat_candidate and lat_candidate not in point_candidates: - point_candidates.append(lat_candidate) - elif is_longitude(candidate.name): - lng_candidate = candidate.name - if lng_candidate and lng_candidate not in point_candidates: - point_candidates.append(lng_candidate) - if request.method == "POST": - guessed_lat_or_lng = False - selected_lat = lat_field - selected_lng = lng_field - else: - guessed_lat_or_lng = bool(lat_candidate or lng_candidate) - selected_lat = lat_candidate - selected_lng = lng_candidate - present_choices = len(point_candidates) >= 2 - possible_data_problems = None - if non_str_in_headers: - possible_data_problems = ( - "There are some suspicious column names in your data. " - "Did you provide column names in the header? The following names look wrong: " - ) - possible_data_problems += ",".join(non_str_in_headers) - - context = dict( - present_choices=present_choices, - point_candidates=point_candidates, - async_upload=False, - selected_lat=selected_lat, - selected_lng=selected_lng, - guessed_lat_or_lng=guessed_lat_or_lng, - dataset_name=import_session.tasks[0].layer.name, - error=error, - possible_data_problems=possible_data_problems, - ) - return render(request, "upload/dataset_upload_csv.html", context=context) - return next_step_response(request, upload_session) - elif request.method == "POST": - if not lat_field or not lng_field: - error = "Please choose which columns contain the latitude and longitude data." - # elif (lat_field not in point_candidates or - # lng_field not in point_candidates): - # error = 'Invalid latitude/longitude columns' - elif lat_field == lng_field: - error = "You cannot select the same column for latitude and longitude data." - - if error: - return json_response( - { - "status": "error", - "success": False, - "id": upload_session.import_session.id, - "error_msg": f"{error}", - } - ) - else: - csv_step(upload_session, lat_field, lng_field) - return next_step_response(request, upload_session) - elif request.method != "POST": - raise Exception() - - -def check_step_view(request, upload_session): - if not upload_session: - upload_session = _get_upload_session(request) - import_session = upload_session.import_session - assert import_session is not None - - if request.method == "GET": - layer = check_import_session_is_valid(request, upload_session, import_session) - if upload_session.completed_step != "error": - if not layer: - upload_session.completed_step = "error" - upload_session.error_msg = "Could not access/read the uploaded file!" - else: - (has_time_dim, dataset_values) = dataset_eligible_for_time_dimension( - request, import_session.tasks[0].layer, upload_session=upload_session - ) - if has_time_dim: - upload_session.completed_step = "check" - else: - # This command skip completely 'time' configuration - upload_session.completed_step = "time" if _ALLOW_TIME_STEP else "check" - elif request.method != "POST": - raise Exception() - return next_step_response(request, upload_session) - - -def create_time_form(request, upload_session, form_data): - if not upload_session: - upload_session = _get_upload_session(request) - feature_type = upload_session.import_session.tasks[0].layer - - (has_time, dataset_values) = dataset_eligible_for_time_dimension( - request, feature_type, upload_session=upload_session - ) - att_list = [] - if has_time: - att_list = _get_time_dimensions(feature_type, upload_session) - else: - att_list = [{"name": a.name, "binding": a.binding} for a in feature_type.attributes] - - def filter_type(b): - return [att["name"] for att in att_list if b in att["binding"]] - - args = dict( - time_names=filter_type("Date"), - text_names=filter_type("String"), - year_names=filter_type("Integer") + filter_type("Long") + filter_type("Double"), - ) - if form_data: - return TimeForm(form_data, **args) - return TimeForm(**args) - - -def time_step_view(request, upload_session): - if not upload_session: - upload_session = _get_upload_session(request) - import_session = upload_session.import_session - assert import_session is not None - - force_ajax = ( - "&force_ajax=true" if request and "force_ajax" in request.GET and request.GET["force_ajax"] == "true" else "" - ) - if request.method == "GET": - layer = check_import_session_is_valid(request, upload_session, import_session) - if layer: - (has_time_dim, dataset_values) = dataset_eligible_for_time_dimension( - request, layer, upload_session=upload_session - ) - if has_time_dim and dataset_values: - upload_session.completed_step = "check" - if not force_ajax: - context = { - "time_form": create_time_form(request, upload_session, None), - "dataset_name": layer.name, - "dataset_values": dataset_values, - "dataset_attributes": list(dataset_values[0].keys()), - "async_upload": is_async_step(upload_session), - } - return render(request, "upload/dataset_upload_time.html", context=context) - else: - upload_session.completed_step = "time" if _ALLOW_TIME_STEP else "check" - return next_step_response(request, upload_session) - elif request.method != "POST": - raise Exception() - - form = create_time_form(request, upload_session, request.POST) - if not form.is_valid(): - logger.exception("Invalid upload form: %s", form.errors) - raise GeneralUploadException(detail="Invalid Submission") - - cleaned = form.cleaned_data - start_attribute_and_type = cleaned.get("start_attribute", None) - if upload_session.time_transforms: - upload_session.import_session.tasks[0].remove_transforms(upload_session.time_transforms, save=True) - upload_session.import_session.tasks[0].save_transforms() - upload_session.time_transforms = None - - if upload_session.import_session.tasks[0].transforms: - for transform in upload_session.import_session.tasks[0].transforms: - if "type" in transform and ( - str(transform["type"]) == "DateFormatTransform" or str(transform["type"]) == "CreateIndexTransform" - ): - upload_session.import_session.tasks[0].remove_transforms([transform], save=True) - upload_session.import_session.tasks[0].save_transforms() - - try: - upload_session.import_session = import_session.reload() - except gsimporter.api.NotFound as e: - logger.exception(e) - Upload.objects.invalidate_from_session(upload_session) - raise GeneralUploadException(detail=_("The GeoServer Import Session is no more available ") + str(e)) - - if start_attribute_and_type: - - def tx(type_name): - # return None if type_name is None or type_name == 'Date' \ - return None if type_name is None else "DateFormatTransform" - - end_attribute, end_type = cleaned.get("end_attribute", (None, None)) - time_step( - upload_session, - time_attribute=start_attribute_and_type[0], - time_transform_type=tx(start_attribute_and_type[1]), - time_format=cleaned.get("attribute_format", None), - end_time_attribute=end_attribute, - end_time_transform_type=tx(end_type), - end_time_format=cleaned.get("end_attribute_format", None), - presentation_strategy=cleaned["presentation_strategy"], - precision_value=cleaned["precision_value"], - precision_step=cleaned["precision_step"], - ) - - upload_session.completed_step = "check" - return next_step_response(request, upload_session) - - -def final_step_view(req, upload_session): - _json_response = None - if not upload_session: - upload_session = _get_upload_session(req) - if upload_session and getattr(upload_session, "import_session", None): - import_session = upload_session.import_session - _log("Checking session %s validity", import_session.id) - if not check_import_session_is_valid(req, upload_session, import_session): - error_msg = upload_session.import_session.tasks[0].error_message - url = "/upload/dataset_upload_invalid.html" - _json_response = json_response( - { - "url": url, - "status": "error", - "id": import_session.id, - "error_msg": error_msg or "Import Session is Invalid!", - "success": False, - } - ) - return _json_response - else: - try: - dataset_id = None - if req and "dataset_id" in req.GET: - dataset = Dataset.objects.filter(id=req.GET["dataset_id"]) - if dataset.exists(): - dataset_id = dataset.first().resourcebase_ptr_id - - saved_dataset = final_step(upload_session, upload_session.user, dataset_id) - - assert saved_dataset - - # this response is different then all of the other views in the - # upload as it does not return a response as a json object - _json_response = json_response( - { - "status": "finished", - "id": import_session.id, - "url": saved_dataset.get_absolute_url(), - "bbox": saved_dataset.bbox_string, - "crs": {"type": "name", "properties": saved_dataset.srid}, - "success": True, - } - ) - register_event(req, EventType.EVENT_UPLOAD, saved_dataset) - return _json_response - except (LayerNotReady, AssertionError) as e: - logger.exception(e) - force_ajax = ( - "&force_ajax=true" if req and "force_ajax" in req.GET and req.GET["force_ajax"] == "true" else "" - ) - return json_response( - { - "status": "running", - "success": True, - "id": import_session.id, - "redirect_to": f"/upload/final?id={import_session.id}{force_ajax}", - } - ) - except Exception as e: - logger.exception(e) - url = "upload/dataset_upload_invalid.html" - _json_response = json_response({"status": "error", "url": url, "error_msg": str(e), "success": False}) - return _json_response - else: - url = "upload/dataset_upload_invalid.html" - _json_response = json_response( - { - "status": "error", - "url": url, - "error_msg": _("Upload Session invalid or no more accessible!"), - "success": False, - } - ) - return _json_response - - -""" - Workflow Views Definition -""" -_steps = { - "save": save_step_view, - "srs": srs_step_view, - "csv": csv_step_view, - "check": check_step_view, - "time": time_step_view, - "final": final_step_view, -} - - -@login_required -@logged_in_or_basicauth(realm="GeoNode") -def view(req, step=None): - """Main uploader view""" - - config = Configuration.load() - if config.read_only or config.maintenance: - raise AuthenticationFailed() - - upload_session = None - upload_id = req.GET.get("id", None) - - if step is None: - if upload_id: - # upload recovery - upload_obj = get_object_or_404(Upload, import_id=upload_id, user=req.user) - session = upload_obj.get_session - if session: - return next_step_response(req, session) - step = "save" - - # delete existing session - if upload_id and upload_id in req.session: - del req.session[upload_id] - req.session.modified = True - else: - if not upload_id: - return render(req, "upload/dataset_upload_invalid.html", context={}) - - upload_obj = get_object_or_404(Upload, import_id=upload_id, user=req.user) - session = upload_obj.get_session - try: - if session: - upload_session = session - else: - upload_session = _get_upload_session(req) - except Exception as e: - logger.exception(e) - try: - if req.method == "GET" and upload_session: - # set the current step to match the requested page - this - # could happen if the form is ajax w/ progress monitoring as - # the advance would have already happened @hacky - _completed_step = upload_session.completed_step - try: - _completed_step = get_previous_step(upload_session, step) - upload_session.completed_step = _completed_step - except Exception as e: - logger.exception(e) - if isinstance(e, APIException): - raise e - raise GeneralUploadException(detail=traceback.format_exc()) - - resp = _steps[step](req, upload_session) - resp_js = None - if resp: - content = resp.content - if isinstance(content, bytes): - content = content.decode("UTF-8") - try: - resp_js = json.loads(content) - except json.decoder.JSONDecodeError: - resp_js = content - except Exception as e: - logger.exception(e) - if isinstance(e, APIException): - raise e - raise GeneralUploadException(detail=traceback.format_exc()) - - # must be put back to update object in session - if upload_session: - if resp_js and step == "final": - try: - delete_session = resp_js.get("status") != "pending" - if delete_session: - # we're done with this session, wax it - upload_session = None - del req.session[upload_id] - req.session.modified = True - except Exception: - pass - else: - upload_session = _get_upload_session(req) - if upload_session: - upload_session = Upload.objects.update_from_session(upload_session) - if not settings.ASYNC_SIGNALS and resp_js and isinstance(resp_js, dict): - _success = resp_js.get("success", False) - _redirect_to = resp_js.get("redirect_to", "") - _required_input = resp_js.get("required_input", False) - if _success and (_required_input or "upload/final" in _redirect_to): - from geonode.upload.tasks import finalize_incomplete_session_uploads - - finalize_incomplete_session_uploads.apply() - return resp - except BadStatusLine: - logger.exception("bad status line, geoserver down?") - raise GeneralUploadException(detail=_geoserver_down_error_msg) - except gsimporter.RequestFailed as e: - logger.exception(e) - errors = e.args - # http bad gateway or service unavailable - if int(errors[0]) in (502, 503): - errors = [_geoserver_down_error_msg] - raise GeneralUploadException(detail=errors) - except gsimporter.BadRequest as e: - logger.exception(e) - raise GeneralUploadException(detail=e.args[0]) - except Exception as e: - logger.exception(e) - if isinstance(e, APIException): - raise e - raise GeneralUploadException(detail=traceback.format_exc()) - - @login_required def delete(req, id): upload = get_object_or_404(Upload, id=id) @@ -729,10 +53,3 @@ def delete(req, id): success=True, ) ) - - -def response_content_type(request): - if "application/json" in request.META["HTTP_ACCEPT"]: - return "application/json" - else: - return "text/plain" From 49cef44283a4a5a9cf8bd88f54b3413214907440 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Wed, 23 Aug 2023 11:50:45 +0200 Subject: [PATCH 133/330] Avoid pip upgrades during Docker builds (#11405) --- Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0805c6494d6..3fb3fe1df84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,13 +28,13 @@ RUN chmod +x /usr/bin/celery-cmd # RUN cd /usr/src/geonode-contribs/geonode-logstash; pip install --upgrade -e . \ # cd /usr/src/geonode-contribs/ldap; pip install --upgrade -e . -RUN yes w | pip install --src /usr/src -Ur requirements.txt -RUN yes w | pip install --upgrade -e . +RUN yes w | pip install --src /usr/src -r requirements.txt &&\ + yes w | pip install -e . # Cleanup apt update lists -RUN apt-get autoremove --purge -RUN apt-get clean -RUN rm -rf /var/lib/apt/lists/* +RUN apt-get autoremove --purge &&\ + apt-get clean &&\ + rm -rf /var/lib/apt/lists/* # Export ports EXPOSE 8000 From 029a0bf53f49d45a0e7d8e77032febdec6ca8286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Wallschl=C3=A4ger?= Date: Wed, 23 Aug 2023 11:52:01 +0200 Subject: [PATCH 134/330] [Fixes #11316] external pyCSW Service breaks upload of Documents and Datasets (#11319) Co-authored-by: Alessio Fabiani --- geonode/catalogue/backends/generic.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/geonode/catalogue/backends/generic.py b/geonode/catalogue/backends/generic.py index 94eb2198ab2..848fd9758fe 100644 --- a/geonode/catalogue/backends/generic.py +++ b/geonode/catalogue/backends/generic.py @@ -89,9 +89,13 @@ def get_by_uuid(self, uuid): return None record = list(self.records.values())[0] record.keywords = [] - if hasattr(record, "identification") and hasattr(record.identification[0], "keywords"): + if ( + hasattr(record, "identification") + and len(record.identification) > 0 + and hasattr(record.identification[0], "keywords") + ): for kw in record.identification[0].keywords: - record.keywords.extend(kw["keywords"]) + record.keywords.extend(kw.keywords) return record else: return None @@ -111,7 +115,6 @@ def url_for_uuid(self, uuid, outputschema): def urls_for_uuid(self, uuid): """returns list of valid GetRecordById URLs for a given record""" - urls = [] for mformat in self.formats: urls.append(("text/xml", mformat, self.url_for_uuid(uuid, METADATA_FORMATS[mformat][1]))) @@ -124,6 +127,7 @@ def csw_gen_xml(self, layer, template): site_url = settings.SITEURL.rstrip("/") if settings.SITEURL.startswith("http") else settings.SITEURL tpl = get_template(template) ctx = { + "CATALOG_METADATA_TEMPLATE": settings.CATALOG_METADATA_TEMPLATE, "layer": layer, "SITEURL": site_url, "id_pname": id_pname, From be7b716f25f3d82b1e51c0f0bfdd5a514528a496 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Wed, 23 Aug 2023 12:15:30 +0200 Subject: [PATCH 135/330] create-envfile doesn't need Django (#11407) --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 8ec744e587e..1737129a18c 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,6 @@ Workshop](https://docs.geonode.org/en/master/usage/index.html). Quick Docker Start ------------------ - ```bash - python3.10 -m venv ~/.venvs/geonode - source ~/.venvs/geonode/bin/activate - - pip install Django==3.2.* - ``` ```bash python create-envfile.py ``` From 1c4b06c707dc021cde7aff8d2dec95c9d8c4dbb5 Mon Sep 17 00:00:00 2001 From: Emanuele Tajariol Date: Wed, 23 Aug 2023 12:19:08 +0200 Subject: [PATCH 136/330] [Fixes #11226] Faceting: improvement: key to filter - step 2 (#11297) Co-authored-by: Giovanni Allegri Co-authored-by: Alessio Fabiani --- geonode/facets/models.py | 2 +- geonode/facets/providers/baseinfo.py | 3 +-- geonode/facets/providers/category.py | 1 - geonode/facets/providers/keyword.py | 1 - geonode/facets/providers/region.py | 1 - geonode/facets/providers/thesaurus.py | 1 - geonode/facets/providers/users.py | 1 - geonode/facets/tests.py | 15 ++++++++------- 8 files changed, 10 insertions(+), 15 deletions(-) diff --git a/geonode/facets/models.py b/geonode/facets/models.py index 0270720e346..c4109d31d9e 100644 --- a/geonode/facets/models.py +++ b/geonode/facets/models.py @@ -58,7 +58,7 @@ def get_info(self, lang="en", **kwargs) -> dict: """ Get the basic info for this provider, as a dict with these keys: - 'name': the name of the provider (the one returned by name()) - - 'key': the filtering key to be used in a filter query + - 'filter': the filtering key to be used in a filter query - 'label': a generic label for the facet; the client should try and localize it whenever possible - 'localized_label': a localized label for the facet (localized according to the `lang` param) - 'type': the facet type (e.g. user, region, thesaurus, ...) diff --git a/geonode/facets/providers/baseinfo.py b/geonode/facets/providers/baseinfo.py index f81e7583c66..11df2783c14 100644 --- a/geonode/facets/providers/baseinfo.py +++ b/geonode/facets/providers/baseinfo.py @@ -38,7 +38,6 @@ def name(self) -> str: def get_info(self, lang="en", **kwargs) -> dict: return { "name": self.name, - "key": "filter{resource_type.in}", # deprecated "filter": "filter{resource_type.in}", "label": "Resource type", "type": FACET_TYPE_BASE, @@ -108,7 +107,7 @@ def name(self) -> str: def get_info(self, lang="en", **kwargs) -> dict: return { "name": self.name, - "key": "filter{featured}", + "filter": "filter{featured}", "label": "Featured", "type": FACET_TYPE_BASE, "hierarchical": False, diff --git a/geonode/facets/providers/category.py b/geonode/facets/providers/category.py index 28b27749bbf..6b7dc331768 100644 --- a/geonode/facets/providers/category.py +++ b/geonode/facets/providers/category.py @@ -39,7 +39,6 @@ def name(self) -> str: def get_info(self, lang="en", **kwargs) -> dict: return { "name": self.name, - "key": "filter{category.identifier}", # deprecated "filter": "filter{category.identifier}", "label": "Category", "type": FACET_TYPE_CATEGORY, diff --git a/geonode/facets/providers/keyword.py b/geonode/facets/providers/keyword.py index ce9e5f6f8f9..41c4a470cfa 100644 --- a/geonode/facets/providers/keyword.py +++ b/geonode/facets/providers/keyword.py @@ -39,7 +39,6 @@ def name(self) -> str: def get_info(self, lang="en", **kwargs) -> dict: return { "name": self.name, - "key": "filter{keywords.slug.in}", # deprecated "filter": "filter{keywords.slug.in}", "label": "Keyword", "type": FACET_TYPE_KEYWORD, diff --git a/geonode/facets/providers/region.py b/geonode/facets/providers/region.py index c40138e58bb..bd912ab8693 100644 --- a/geonode/facets/providers/region.py +++ b/geonode/facets/providers/region.py @@ -39,7 +39,6 @@ def name(self) -> str: def get_info(self, lang="en", **kwargs) -> dict: return { "name": self.name, - "key": "filter{regions.code.in}", # deprecated "filter": "filter{regions.code.in}", "label": "Region", "type": FACET_TYPE_PLACE, diff --git a/geonode/facets/providers/thesaurus.py b/geonode/facets/providers/thesaurus.py index bb40f455924..f8014728c9d 100644 --- a/geonode/facets/providers/thesaurus.py +++ b/geonode/facets/providers/thesaurus.py @@ -48,7 +48,6 @@ def name(self) -> str: def get_info(self, lang="en", **kwargs) -> dict: return { "name": self._name, - "key": "filter{tkeywords}", # deprecated "filter": "filter{tkeywords}", "label": self.labels.get(lang, self.label), "is_localized": self.labels.get(lang, None) is not None, diff --git a/geonode/facets/providers/users.py b/geonode/facets/providers/users.py index cf2a52cd9ba..ac65d9ee79b 100644 --- a/geonode/facets/providers/users.py +++ b/geonode/facets/providers/users.py @@ -39,7 +39,6 @@ def name(self) -> str: def get_info(self, lang="en", **kwargs) -> dict: return { "name": "owner", - "key": "filter{owner.pk.in}", # deprecated "filter": "filter{owner.pk.in}", "label": "Owner", "type": FACET_TYPE_USER, diff --git a/geonode/facets/tests.py b/geonode/facets/tests.py index cf05e1e3734..3245fe72d9a 100644 --- a/geonode/facets/tests.py +++ b/geonode/facets/tests.py @@ -388,18 +388,19 @@ def test_topics(self): def test_prefiltering(self): reginfo = RegionFacetProvider().get_info() - t0info = facet_registry.get_provider("t_0").get_info() - t1info = facet_registry.get_provider("t_1").get_info() + regfilter = reginfo["filter"] + t0filter = facet_registry.get_provider("t_0").get_info()["filter"] + t1filter = facet_registry.get_provider("t_1").get_info()["filter"] for facet, filters, totals, count0 in ( ("t_0", {}, 2, 10), - ("t_0", {reginfo["key"]: "R0"}, 1, 1), + ("t_0", {regfilter: "R0"}, 1, 1), ("t_1", {}, 2, 10), - ("t_1", {reginfo["key"]: "R0"}, 1, 2), - ("t_1", {reginfo["key"]: "R1"}, 2, 3), + ("t_1", {regfilter: "R0"}, 1, 2), + ("t_1", {regfilter: "R1"}, 2, 3), (reginfo["name"], {}, 2, 4), - (reginfo["name"], {t0info["key"]: self.thesauri_k["0_0"].id}, 2, 1), - (reginfo["name"], {t1info["key"]: self.thesauri_k["1_0"].id}, 2, 3), + (reginfo["name"], {t0filter: self.thesauri_k["0_0"].id}, 2, 1), + (reginfo["name"], {t1filter: self.thesauri_k["1_0"].id}, 2, 3), ): req = self.rf.get(reverse("get_facet", args=[facet]), data=filters) res: JsonResponse = views.get_facet(req, facet) From 3f694c5857ee0546d19d731cc61266873e1542d2 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Wed, 23 Aug 2023 12:20:03 +0200 Subject: [PATCH 137/330] Remove Install paragraph from README (#11408) --- README.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/README.md b/README.md index 1737129a18c..38fc32dc704 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ Table of Contents - [What is GeoNode?](#what-is-geonode) - [Try out GeoNode](#try-out-geonode) - [Quick Docker Start](#quick-docker-start) - - [Install](#install) - [Learn GeoNode](#learn-geonode) - [Development](#development) - [Contributing](#contributing) @@ -76,18 +75,6 @@ Quick Docker Start docker compose up -d ``` -Install -------- - - The latest official release is 4.1.0! - -GeoNode can be setup in different ways, flavors and plattforms. If -you´re planning to do development or install for production please visit -the offical GeoNode installation documentation: - -- [Docker](https://docs.geonode.org/en/master/install/advanced/core/index.html#docker) -- [Ubuntu 22.04](https://docs.geonode.org/en/master/install/advanced/core/index.html#ubuntu-22-04) - Learn GeoNode ------------- From d0496683764ef15e41ada6feb10cbd41a305b51f Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Wed, 23 Aug 2023 12:21:30 +0200 Subject: [PATCH 138/330] Update link to demo in README (#11409) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 38fc32dc704..973935b5dc4 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Try out GeoNode --------------- If you just want to try out GeoNode visit our official Demo online at: -http://master.demo.geonode.org. After your registration you will be able +[http://development.demo.geonode.org[(http://development.demo.geonode.org). After your registration you will be able to test all basic functionalities like uploading layers, creation of maps, editing metadata, styles and much more. To get an overview what GeoNode can do we recommend to have a look at the [Users From 54d85b0c5cf927ecb728c283eed6f54890094e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Wallschl=C3=A4ger?= Date: Wed, 23 Aug 2023 12:25:01 +0200 Subject: [PATCH 139/330] [Fixes: #10841] Serializing and deserializing ResourceBase and Dataset objects breaks validation check (#10843) * issue10841 add convertion to internal types for DynamicModelSerializer * issue10841 add convertion to internal types for DynamicModelSerializer * issue10841 add convertion to internal types for DynamicModelSerializer * issue10841 add convertion to internal types for DynamicModelSerializer * issue10841 add convertion to internal types for DynamicModelSerializer * - Fix formatting --------- Co-authored-by: Giovanni Allegri Co-authored-by: Alessio Fabiani --- geonode/base/api/fields.py | 54 +++++++++++++++++++++++++++++++++ geonode/base/api/serializers.py | 34 ++++++++++----------- geonode/base/api/tests.py | 54 ++++++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 geonode/base/api/fields.py diff --git a/geonode/base/api/fields.py b/geonode/base/api/fields.py new file mode 100644 index 00000000000..d719772cc58 --- /dev/null +++ b/geonode/base/api/fields.py @@ -0,0 +1,54 @@ +######################################################################### +# +# Copyright (C) 2016 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### + +import json + +from django.core.exceptions import ValidationError +from dynamic_rest.fields.fields import DynamicRelationField + + +class ComplexDynamicRelationField(DynamicRelationField): + def to_internal_value_single(self, data, serializer): + """Overwrite of DynamicRelationField implementation to handle complex data structure initialization + + Args: + data (Optional[str, Dict]}): serialized or deserialized data from http calls (POST, GET ...) + serializer (DynamicModelSerializer): Serializer for the given data + + Raises: + ValidationError: raised when requested data does not exist + + django.db.models.QuerySet: return QuerySet object of the request or set data + """ + related_model = serializer.Meta.model + if isinstance(data, str): + data = json.loads(data) + + if isinstance(data, dict): + try: + if hasattr(serializer, "many") and serializer.many is True: + return [serializer.get_model().objects.get(**d) for d in data] + return serializer.get_model().objects.get(**data) + except related_model.DoesNotExist: + raise ValidationError( + "Invalid value for '%s': %s object with ID=%s not found" + % (self.field_name, related_model.__name__, data) + ) + else: + return super().to_internal_value_single(data, serializer) diff --git a/geonode/base/api/serializers.py b/geonode/base/api/serializers.py index 4d07b9478f7..b831326e88a 100644 --- a/geonode/base/api/serializers.py +++ b/geonode/base/api/serializers.py @@ -16,9 +16,10 @@ # along with this program. If not, see . # ######################################################################### -import json +import logging from slugify import slugify from urllib.parse import urljoin +import json from django.db.models import Q from django.conf import settings @@ -26,6 +27,7 @@ from django.forms.models import model_to_dict from django.contrib.auth import get_user_model from django.db.models.query import QuerySet +from django.http import QueryDict from rest_framework import serializers from rest_framework_gis import fields @@ -51,13 +53,11 @@ ExtraMetadata, ) from geonode.groups.models import GroupCategory, GroupProfile - +from geonode.base.api.fields import ComplexDynamicRelationField from geonode.utils import build_absolute_uri from geonode.security.utils import get_resources_with_perms from geonode.resource.models import ExecutionRequest -import logging - logger = logging.getLogger(__name__) @@ -351,9 +351,6 @@ class Meta: model = ResourceBase fields = ("pk", "blob") - def to_internal_value(self, data): - return data - def to_representation(self, value): data = ResourceBase.objects.filter(id=value) if data.exists() and data.count() == 1: @@ -444,7 +441,7 @@ def __init__(self, *args, **kwargs): self.fields["bbox_polygon"] = fields.GeometryField(read_only=True, required=False) self.fields["ll_bbox_polygon"] = fields.GeometryField(read_only=True, required=False) self.fields["srid"] = serializers.CharField(required=False) - self.fields["group"] = DynamicRelationField(GroupSerializer, embed=True, many=False) + self.fields["group"] = ComplexDynamicRelationField(GroupSerializer, embed=True, many=False) self.fields["popular_count"] = serializers.CharField(required=False) self.fields["share_count"] = serializers.CharField(required=False) self.fields["rating"] = serializers.CharField(required=False) @@ -463,28 +460,27 @@ def __init__(self, *args, **kwargs): self.fields["processed"] = serializers.BooleanField(read_only=True) self.fields["state"] = serializers.CharField(read_only=True) self.fields["sourcetype"] = serializers.CharField(read_only=True) - self.fields["embed_url"] = EmbedUrlField(required=False) self.fields["thumbnail_url"] = ThumbnailUrlField(read_only=True) - self.fields["keywords"] = DynamicRelationField(SimpleHierarchicalKeywordSerializer, embed=False, many=True) - self.fields["tkeywords"] = DynamicRelationField(SimpleThesaurusKeywordSerializer, embed=False, many=True) + self.fields["keywords"] = ComplexDynamicRelationField( + SimpleHierarchicalKeywordSerializer, embed=False, many=True + ) + self.fields["tkeywords"] = ComplexDynamicRelationField(SimpleThesaurusKeywordSerializer, embed=False, many=True) self.fields["regions"] = DynamicRelationField(SimpleRegionSerializer, embed=True, many=True, read_only=True) - self.fields["category"] = DynamicRelationField(SimpleTopicCategorySerializer, embed=True, many=False) - self.fields["restriction_code_type"] = DynamicRelationField( + self.fields["category"] = ComplexDynamicRelationField(SimpleTopicCategorySerializer, embed=True, many=False) + self.fields["restriction_code_type"] = ComplexDynamicRelationField( RestrictionCodeTypeSerializer, embed=True, many=False ) - self.fields["license"] = DynamicRelationField(LicenseSerializer, embed=True, many=False) - self.fields["spatial_representation_type"] = DynamicRelationField( + self.fields["license"] = ComplexDynamicRelationField(LicenseSerializer, embed=True, many=False) + self.fields["spatial_representation_type"] = ComplexDynamicRelationField( SpatialRepresentationTypeSerializer, embed=True, many=False ) self.fields["blob"] = serializers.JSONField(required=False, write_only=True) self.fields["is_copyable"] = serializers.BooleanField(read_only=True) - self.fields["download_url"] = DownloadLinkField(read_only=True) - self.fields["favorite"] = FavoriteField(read_only=True) - metadata = DynamicRelationField(ExtraMetadataSerializer, embed=False, many=True, deferred=True) + metadata = ComplexDynamicRelationField(ExtraMetadataSerializer, embed=False, many=True, deferred=True) class Meta: model = ResourceBase @@ -596,6 +592,8 @@ def to_internal_value(self, data): data = json.loads(data) if "data" in data: data["blob"] = data.pop("data") + if isinstance(data, QueryDict): + data = data.dict() data = super(ResourceBaseSerializer, self).to_internal_value(data) return data diff --git a/geonode/base/api/tests.py b/geonode/base/api/tests.py index 88df7c3f7b2..4feffb7d3c2 100644 --- a/geonode/base/api/tests.py +++ b/geonode/base/api/tests.py @@ -32,20 +32,23 @@ from uuid import uuid4 from unittest.mock import patch from urllib.parse import urljoin +from datetime import date, timedelta from django.urls import reverse from django.conf import settings from django.core.files.uploadedfile import SimpleUploadedFile -from django.contrib.auth.models import Group from django.contrib.auth import get_user_model from rest_framework.test import APITestCase +from rest_framework.renderers import JSONRenderer +from rest_framework.parsers import JSONParser from guardian.shortcuts import get_anonymous_user from geonode.maps.models import Map, MapLayer from geonode.tests.base import GeoNodeBaseTestSupport from geonode.base import enumerations +from geonode.base.api.serializers import ResourceBaseSerializer from geonode.groups.models import GroupMember, GroupProfile from geonode.thumbs.exceptions import ThumbnailError from geonode.layers.utils import get_files @@ -56,6 +59,9 @@ TopicCategory, ThesaurusKeyword, ExtraMetadata, + RestrictionCodeType, + License, + Group, ) from geonode.layers.models import Dataset @@ -707,6 +713,52 @@ def test_write_resources(self): self.assertEqual("321-12345-987654321", response.data["resource"]["doi"], response.data["resource"]["doi"]) self.assertEqual(True, response.data["resource"]["is_published"], response.data["resource"]["is_published"]) + def test_resource_serializer_validation(self): + """ + Testing serializing and deserializing of a Dataset base on django-rest description: + https://www.django-rest-framework.org/api-guide/serializers/#deserializing-objects + """ + owner, _ = get_user_model().objects.get_or_create(username="delet-owner") + title = "TEST DS TITLE" + HierarchicalKeyword.add_root(name="a") + keyword = HierarchicalKeyword.objects.get(slug="a") + keyword.add_child(name="a1") + + Dataset( + title=title, + abstract="abstract", + name="test dataset", + alternate="Test Remove User", + attribution="Test Attribution", + uuid=str(uuid4()), + doi="test DOI", + edition=1, + maintenance_frequency=enumerations.UPDATE_FREQUENCIES[0], + constraints_other="Test Constrains other", + temporal_extent_start=date.today() - timedelta(days=1), + temporal_extent_end=date.today(), + data_quality_statement="Test data quality statement", + purpose="Test Purpose", + owner=owner, + subtype="raster", + category=TopicCategory.objects.get(identifier="elevation"), + resource_type="dataset", + license=License.objects.all().first(), + restriction_code_type=RestrictionCodeType.objects.all().first(), + group=Group.objects.all().first(), + ).save() + + ds = ResourceBase.objects.get(title=title) + ds.keywords.add(HierarchicalKeyword.objects.get(slug="a1")) + + serialized = ResourceBaseSerializer(ds) + json = JSONRenderer().render(serialized.data) + stream = BytesIO(json) + data = JSONParser().parse(stream) + self.assertIsInstance(data, dict) + se = ResourceBaseSerializer(data=data) + self.assertTrue(se.is_valid()) + def test_delete_user_with_resource(self): owner, created = get_user_model().objects.get_or_create(username="delet-owner") Dataset( From 618776ef8f4146ffc9fc785b17abac9bba22a409 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Wed, 23 Aug 2023 12:30:21 +0200 Subject: [PATCH 140/330] Typos and fixes to the links in the README (#11411) --- README.md | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 973935b5dc4..187d0da3f12 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,10 @@ Try out GeoNode --------------- If you just want to try out GeoNode visit our official Demo online at: -[http://development.demo.geonode.org[(http://development.demo.geonode.org). After your registration you will be able +[https://development.demo.geonode.org](https://development.demo.geonode.org). After your registration, you will be able to test all basic functionalities like uploading layers, creation of -maps, editing metadata, styles and much more. To get an overview what -GeoNode can do we recommend to have a look at the [Users +maps, editing metadata, styles, and much more. To get an overview what +GeoNode can do we recommend having a look at the [Users Workshop](https://docs.geonode.org/en/master/usage/index.html). Quick Docker Start @@ -60,8 +60,8 @@ Quick Docker Start - When set to `prod` `DEBUG` is disabled and the creation of a valid `SSL` is requested to Letsencrypt's ACME server - When set to `test` `DEBUG` is disabled and a test `SSL` certificate is generated for local testing - When set to `dev` `DEBUG` is enabled and no `SSL` certificate is generated -- `--hostname`: The URL that whill serve GeoNode (`localhost` by default) -- `--email`: The administrator's email. Notice that a real email and a valid SMPT configurations are required if `--env_type` is seto to `prod`. Letsencrypt uses to email for issuing the SSL certificate +- `--hostname`: The URL that will serve GeoNode (`localhost` by default) +- `--email`: The administrator's email. Notice that a real email and valid SMPT configurations are required if `--env_type` is set to `prod`. Letsencrypt uses email for issuing the SSL certificate - `--geonodepwd`: GeoNode's administrator password. A random value is set if left empty - `--geoserverpwd`: GeoNode's administrator password. A random value is set if left empty - `--pgpwd`: PostgreSQL's administrator password. A random value is set if left empty @@ -89,24 +89,22 @@ and configuration settings. Development ----------- -GeoNode is a web based GIS tool, and as such, in order to do development +GeoNode is a web-based GIS tool, and as such, in order to do development on GeoNode itself or to integrate it into your own application, you should be familiar with basic web development concepts as well as with general GIS concepts. -For development GeoNode can be run in a 'development environment'. In +For development, GeoNode can be run in a 'development environment'. In contrast to a 'production environment' development differs as it uses lightweight components to speed up things. -To get you started have a look at the [Install -instructions](#install) which cover all what is needed to run GeoNode -for development. Further visit the the [Developer +To get started visit the [Developer workshop](https://docs.geonode.org/en/master/devel/index.html) for a basic overview. -If you're planning of customizing your GeoNode instance, or to extend -it's functionalities it's not advisable to change core files in any -case. In this case it's common to use setup a [GeoNode Project +If you're planning to customize your GeoNode instance or to extend +its functionalities it's not advisable to change core files in any +case. In this case, it's common to setup a [GeoNode Project Template](https://github.com/GeoNode/geonode-project). Contributing @@ -123,9 +121,9 @@ Roadmap GeoNode's development roadmap is documented in a series of GeoNode Improvement Projects (GNIPS). They are documented at [GeoNode Wiki](https://github.com/GeoNode/geonode/wiki/GeoNode-Improvement-Proposals). -GNIPS are considered to be large undertakings which will add a large -amount of features to the project. As such they are the topic of -community dicussion and guidance. The community discusses these on the +GNIPS are considered to be large undertakings that will add a large +number of features to the project. As such they are the topic of +community discussion and guidance. The community discusses these on the developer mailing list: http://lists.osgeo.org/pipermail/geonode-devel/ Showcase @@ -147,12 +145,12 @@ Most useful links - Project homepage: https://geonode.org - Repository: https://github.com/GeoNode/geonode -- Offical Demo: http://master.demo.geonode.org +- Official Demos: https://demo.geonode.org - GeoNode Wiki: https://github.com/GeoNode/geonode/wiki - Issue tracker: https://github.com/GeoNode/geonode-project/issues In case of sensitive bugs like security vulnerabilities, please - contact a GeoNode Core Developer directly instead of using issue + contact a GeoNode Core Developer directly instead of using an issue tracker. We value your effort to improve the security and privacy of this project! From a9eebae80cb362009660a1fd49e105e7cdb499b9 Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Wed, 23 Aug 2023 17:32:13 +0200 Subject: [PATCH 141/330] Merge pull request from GHSA-rmxg-6qqf-x8mr * - Fixes "Server Side Request forgery" * Fixes IP or domain extraction * fixed formatting * - Bump ipaddress to version 1.0.23 --------- Co-authored-by: G. Allegri --- geonode/proxy/tests.py | 46 ++++++++++++++++++++++++++++++++++++++++++ geonode/proxy/views.py | 11 ++++++++-- geonode/utils.py | 21 +++++++++++++++++++ requirements.txt | 1 + setup.cfg | 1 + 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/geonode/proxy/tests.py b/geonode/proxy/tests.py index 73ef8ba989c..e0cd6385407 100644 --- a/geonode/proxy/tests.py +++ b/geonode/proxy/tests.py @@ -178,6 +178,52 @@ class Response: }, ) + def test_proxy_url_forgery(self): + """The GeoNode Proxy should preserve the original request headers.""" + import geonode.proxy.views + from urllib.parse import urlsplit + + class Response: + status_code = 200 + content = "Hello World" + headers = { + "Content-Type": "text/plain", + "Vary": "Authorization, Accept-Language, Cookie, origin", + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "same-origin", + "X-Frame-Options": "SAMEORIGIN", + "Content-Language": "en-us", + "Content-Length": "119", + "Content-Disposition": 'attachment; filename="filename.tif"', + } + + request_mock = MagicMock() + request_mock.return_value = (Response(), None) + + # Non-Legit requests attempting SSRF + geonode.proxy.views.http_client.request = request_mock + url = f"http://example.org\@%23{urlsplit(settings.SITEURL).hostname}" + + response = self.client.get(f"{self.proxy_url}?url={url}") + self.assertEqual(response.status_code, 403) + + url = f"http://125.126.127.128\@%23{urlsplit(settings.SITEURL).hostname}" + + response = self.client.get(f"{self.proxy_url}?url={url}") + self.assertEqual(response.status_code, 403) + + # Legit requests using the local host (SITEURL) + url = f"/\@%23{urlsplit(settings.SITEURL).hostname}" + + response = self.client.get(f"{self.proxy_url}?url={url}") + self.assertEqual(response.status_code, 200) + + url = f"{settings.SITEURL}\@%23{urlsplit(settings.SITEURL).hostname}" + + response = self.client.get(f"{self.proxy_url}?url={url}") + self.assertEqual(response.status_code, 200) + class DownloadResourceTestCase(GeoNodeBaseTestSupport): def setUp(self): diff --git a/geonode/proxy/views.py b/geonode/proxy/views.py index 10030f3dc25..fc49163f01b 100644 --- a/geonode/proxy/views.py +++ b/geonode/proxy/views.py @@ -40,7 +40,14 @@ from geonode.upload.models import Upload from geonode.base.models import ResourceBase from geonode.storage.manager import storage_manager -from geonode.utils import resolve_object, check_ogc_backend, get_headers, http_client, json_response +from geonode.utils import ( + resolve_object, + check_ogc_backend, + get_headers, + http_client, + json_response, + extract_ip_or_domain, +) from geonode.base.enumerations import LINK_TYPES as _LT from geonode import geoserver # noqa @@ -130,7 +137,7 @@ def proxy( _remote_host = urlsplit(_s.base_url).hostname PROXY_ALLOWED_HOSTS += (_remote_host,) - if not validate_host(url.hostname, PROXY_ALLOWED_HOSTS): + if not validate_host(extract_ip_or_domain(raw_url), PROXY_ALLOWED_HOSTS): return HttpResponse( "DEBUG is set to False but the host of the path provided to the proxy service" " is not in the PROXY_ALLOWED_HOSTS setting.", diff --git a/geonode/utils.py b/geonode/utils.py index 5abab4b2411..2ceaa2ff0d6 100755 --- a/geonode/utils.py +++ b/geonode/utils.py @@ -34,6 +34,7 @@ import requests import tempfile import importlib +import ipaddress import itertools import traceback import subprocess @@ -1930,6 +1931,26 @@ def build_absolute_uri(url): return url +def extract_ip_or_domain(url): + ip_regex = re.compile("^(?:http\:\/\/|https\:\/\/)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})") + domain_regex = re.compile("^(?:http\:\/\/|https\:\/\/)([a-zA-Z0-9.-]+)") + + match = ip_regex.findall(url) + if len(match): + ip_address = match[0] + try: + ipaddress.ip_address(ip_address) # Validate the IP address + return ip_address + except ValueError: + pass + + match = domain_regex.findall(url) + if len(match): + return match[0] + + return None + + def get_xpath_value( element: etree.Element, xpath_expression: str, nsmap: typing.Optional[dict] = None ) -> typing.Optional[str]: diff --git a/requirements.txt b/requirements.txt index 05db52f8a43..df52b4ebb39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,7 @@ rdflib==6.3.2 smart_open==6.3.0 PyMuPDF==1.22.5 pathvalidate==3.1.0 +ipaddress==1.0.23 # Django Apps django-allauth==0.54.0 diff --git a/setup.cfg b/setup.cfg index 41a528efbc4..28a66b16159 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,7 @@ install_requires = smart_open==6.3.0 PyMuPDF==1.22.5 pathvalidate==3.1.0 + ipaddress==1.0.23 # Django Apps django-allauth==0.54.0 From 1671689a2d42f3d44790d4e29fd5e427018231dd Mon Sep 17 00:00:00 2001 From: afabiani Date: Wed, 23 Aug 2023 17:42:42 +0200 Subject: [PATCH 142/330] - Revert ipaddress requirement --- requirements.txt | 1 - setup.cfg | 1 - 2 files changed, 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index df52b4ebb39..05db52f8a43 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,6 @@ rdflib==6.3.2 smart_open==6.3.0 PyMuPDF==1.22.5 pathvalidate==3.1.0 -ipaddress==1.0.23 # Django Apps django-allauth==0.54.0 diff --git a/setup.cfg b/setup.cfg index 28a66b16159..41a528efbc4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,7 +53,6 @@ install_requires = smart_open==6.3.0 PyMuPDF==1.22.5 pathvalidate==3.1.0 - ipaddress==1.0.23 # Django Apps django-allauth==0.54.0 From bc7cb13e8d4362d29e4ea14ae400f2c198f87f8b Mon Sep 17 00:00:00 2001 From: afabiani Date: Wed, 23 Aug 2023 18:11:03 +0200 Subject: [PATCH 143/330] - Fix pep8 formatting issues --- geonode/proxy/tests.py | 8 ++++---- geonode/utils.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/geonode/proxy/tests.py b/geonode/proxy/tests.py index e0cd6385407..f44315c52a7 100644 --- a/geonode/proxy/tests.py +++ b/geonode/proxy/tests.py @@ -203,23 +203,23 @@ class Response: # Non-Legit requests attempting SSRF geonode.proxy.views.http_client.request = request_mock - url = f"http://example.org\@%23{urlsplit(settings.SITEURL).hostname}" + url = f"http://example.org\\@%23{urlsplit(settings.SITEURL).hostname}" response = self.client.get(f"{self.proxy_url}?url={url}") self.assertEqual(response.status_code, 403) - url = f"http://125.126.127.128\@%23{urlsplit(settings.SITEURL).hostname}" + url = f"http://125.126.127.128\\@%23{urlsplit(settings.SITEURL).hostname}" response = self.client.get(f"{self.proxy_url}?url={url}") self.assertEqual(response.status_code, 403) # Legit requests using the local host (SITEURL) - url = f"/\@%23{urlsplit(settings.SITEURL).hostname}" + url = f"/\\@%23{urlsplit(settings.SITEURL).hostname}" response = self.client.get(f"{self.proxy_url}?url={url}") self.assertEqual(response.status_code, 200) - url = f"{settings.SITEURL}\@%23{urlsplit(settings.SITEURL).hostname}" + url = f"{settings.SITEURL}\\@%23{urlsplit(settings.SITEURL).hostname}" response = self.client.get(f"{self.proxy_url}?url={url}") self.assertEqual(response.status_code, 200) diff --git a/geonode/utils.py b/geonode/utils.py index 2ceaa2ff0d6..22c2fae9c2d 100755 --- a/geonode/utils.py +++ b/geonode/utils.py @@ -1932,8 +1932,8 @@ def build_absolute_uri(url): def extract_ip_or_domain(url): - ip_regex = re.compile("^(?:http\:\/\/|https\:\/\/)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})") - domain_regex = re.compile("^(?:http\:\/\/|https\:\/\/)([a-zA-Z0-9.-]+)") + ip_regex = re.compile("^(?:http://|https://)(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})") + domain_regex = re.compile("^(?:http://|https://)([a-zA-Z0-9.-]+)") match = ip_regex.findall(url) if len(match): From e10635aa91a480d9436497ece3ed49f0bab4239d Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Wed, 30 Aug 2023 14:33:40 +0200 Subject: [PATCH 144/330] [Cleanup] Get rid of outdated custom importlib code (#11422) --- geonode/layers/utils.py | 4 ++-- geonode/people/adapters.py | 3 +-- .../geonode_openid_connect/provider.py | 5 ++--- .../providers/geonode_openid_connect/views.py | 7 +++---- geonode/resource/manager.py | 7 ++----- geonode/storage/manager.py | 9 +++------ geonode/utils.py | 17 ----------------- 7 files changed, 13 insertions(+), 39 deletions(-) diff --git a/geonode/layers/utils.py b/geonode/layers/utils.py index 09ef6f02b76..57b53fc2092 100644 --- a/geonode/layers/utils.py +++ b/geonode/layers/utils.py @@ -40,7 +40,9 @@ from django.contrib.auth.models import Group from django.contrib.auth import get_user_model from django.utils.translation import ugettext as _ +from django.utils.module_loading import import_string from django.core.exceptions import ObjectDoesNotExist, SuspiciousFileOperation + from geonode.layers.api.exceptions import InvalidDatasetException from geonode.security.permissions import PermSpec, PermSpecCompact from geonode.storage.manager import storage_manager @@ -502,8 +504,6 @@ def set_datasets_permissions( def get_uuid_handler(): - from django.utils.module_loading import import_string - return import_string(settings.LAYER_UUID_HANDLER) diff --git a/geonode/people/adapters.py b/geonode/people/adapters.py index 63ac7e36659..2a24f6f0bc1 100644 --- a/geonode/people/adapters.py +++ b/geonode/people/adapters.py @@ -44,7 +44,6 @@ from django.core.exceptions import ValidationError from django.utils.module_loading import import_string -from geonode.utils import import_class_module from geonode.groups.models import GroupProfile logger = logging.getLogger(__name__) @@ -69,7 +68,7 @@ def get_data_extractor(provider_id): def get_group_role_mapper(provider_id): - group_role_mapper_class = import_class_module( + group_role_mapper_class = import_string( getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}) .get(PROVIDER_ID, {}) .get( diff --git a/geonode/people/socialaccount/providers/geonode_openid_connect/provider.py b/geonode/people/socialaccount/providers/geonode_openid_connect/provider.py index 196be1e17a2..34671ce1e3f 100644 --- a/geonode/people/socialaccount/providers/geonode_openid_connect/provider.py +++ b/geonode/people/socialaccount/providers/geonode_openid_connect/provider.py @@ -24,8 +24,7 @@ """ from django.conf import settings - -from geonode.utils import import_class_module +from django.utils.module_loading import import_string from allauth.account.models import EmailAddress from allauth.socialaccount.providers.base import AuthAction, ProviderAccount @@ -43,7 +42,7 @@ def to_str(self): class GenericOpenIDConnectProvider(OAuth2Provider): id = "geonode_openid_connect" name = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("NAME", "GeoNode OpenIDConnect") - account_class = import_class_module( + account_class = import_string( getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}) .get(PROVIDER_ID, {}) .get( diff --git a/geonode/people/socialaccount/providers/geonode_openid_connect/views.py b/geonode/people/socialaccount/providers/geonode_openid_connect/views.py index c009c52b95c..5ff02c11a12 100644 --- a/geonode/people/socialaccount/providers/geonode_openid_connect/views.py +++ b/geonode/people/socialaccount/providers/geonode_openid_connect/views.py @@ -17,8 +17,7 @@ # ######################################################################### from django.conf import settings - -from geonode.utils import import_class_module +from django.utils.module_loading import import_string from allauth.socialaccount.providers.oauth2.views import ( OAuth2CallbackView, @@ -26,5 +25,5 @@ ) -oauth2_login = OAuth2LoginView.adapter_view(import_class_module(settings.SOCIALACCOUNT_ADAPTER)) -oauth2_callback = OAuth2CallbackView.adapter_view(import_class_module(settings.SOCIALACCOUNT_ADAPTER)) +oauth2_login = OAuth2LoginView.adapter_view(import_string(settings.SOCIALACCOUNT_ADAPTER)) +oauth2_callback = OAuth2CallbackView.adapter_view(import_string(settings.SOCIALACCOUNT_ADAPTER)) diff --git a/geonode/resource/manager.py b/geonode/resource/manager.py index 38ca1a0e7c3..79a9bb718ce 100644 --- a/geonode/resource/manager.py +++ b/geonode/resource/manager.py @@ -21,7 +21,6 @@ import copy import typing import logging -import importlib from uuid import uuid1, uuid4 from abc import ABCMeta, abstractmethod @@ -35,6 +34,7 @@ from django.contrib.auth.models import Group from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission +from django.utils.module_loading import import_string from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError, FieldDoesNotExist @@ -224,10 +224,7 @@ def __init__(self, concrete_manager=None): self._concrete_resource_manager = concrete_manager or self._get_concrete_manager() def _get_concrete_manager(self): - module_name, class_name = rm_settings.RESOURCE_MANAGER_CONCRETE_CLASS.rsplit(".", 1) - module = importlib.import_module(module_name) - class_ = getattr(module, class_name) - return class_() + return import_string(rm_settings.RESOURCE_MANAGER_CONCRETE_CLASS)() @classmethod def _get_instance(cls, uuid: str) -> ResourceBase: diff --git a/geonode/storage/manager.py b/geonode/storage/manager.py index 421ce6946e3..2966fc6924e 100644 --- a/geonode/storage/manager.py +++ b/geonode/storage/manager.py @@ -21,15 +21,15 @@ import re import shutil import logging -import importlib from uuid import uuid1 from pathlib import Path from typing import BinaryIO, List, Mapping, Union from django.conf import settings -from django.core.exceptions import SuspiciousFileOperation from django.utils.deconstruct import deconstructible +from django.utils.module_loading import import_string +from django.core.exceptions import SuspiciousFileOperation from geonode.storage.data_retriever import DataItemRetriever, DataRetriever @@ -129,10 +129,7 @@ def __init__(self, remote_files: Mapping = {}): self.data_retriever = DataRetriever(remote_files, tranfer_at_creation=False) def _get_concrete_manager(self): - module_name, class_name = sm_settings.STORAGE_MANAGER_CONCRETE_CLASS.rsplit(".", 1) - module = importlib.import_module(module_name) - class_ = getattr(module, class_name) - return class_() + return import_string(sm_settings.STORAGE_MANAGER_CONCRETE_CLASS)() def delete(self, name): return self._concrete_storage_manager.delete(name) diff --git a/geonode/utils.py b/geonode/utils.py index 22c2fae9c2d..694962132f2 100755 --- a/geonode/utils.py +++ b/geonode/utils.py @@ -33,7 +33,6 @@ import datetime import requests import tempfile -import importlib import ipaddress import itertools import traceback @@ -2027,19 +2026,3 @@ def safe_path_leaf(path): f"The provided path '{path}' is not safe. The file is outside the MEDIA_ROOT '{base_path}' base path!" ) return fullpath - - -def import_class_module(full_class_string): - """ - Dynamically load a class from a string - - >>> klass = import_class_module("module.submodule.ClassName") - >>> klass2 = import_class_module("myfile.Class2") - """ - try: - module_path, class_name = full_class_string.rsplit(".", 1) - module = importlib.import_module(module_path) - class_obj = getattr(module, class_name) - return class_obj - except Exception: - return None From f64615fc93a9e2f972bb1e96c8485cf63957d1d0 Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Wed, 30 Aug 2023 18:28:20 +0200 Subject: [PATCH 145/330] Update settings_docker_sample.ini (#11426) * Update settings_docker_sample.ini * Update settings_sample.ini --- geonode/br/management/commands/settings_docker_sample.ini | 4 ++-- geonode/br/management/commands/settings_sample.ini | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/geonode/br/management/commands/settings_docker_sample.ini b/geonode/br/management/commands/settings_docker_sample.ini index 82a28022119..68bbfdf4eff 100644 --- a/geonode/br/management/commands/settings_docker_sample.ini +++ b/geonode/br/management/commands/settings_docker_sample.ini @@ -13,6 +13,6 @@ dumprasterdata = yes # data_layername_exclude_filter = {comma separated list of layernames, optionally with glob syntax} e.g.: tuscany_*,italy [fixtures] -apps = contenttypes,auth,people,groups,account,guardian,admin,actstream,announcements,avatar,base,documents,geoserver,invitations,pinax_notifications,layers,maps,oauth2_provider,harvesting,services,sites,socialaccount,taggit,tastypie,upload,user_messages,geonode_themes,geoapps,favorite,geonode_client -dumps = contenttypes,auth,people,groups,account,guardian,admin,actstream,announcements,avatar,base,documents,geoserver,invitations,pinax_notifications,layers,maps,oauth2_provider,harvesting,services,sites,socialaccount,taggit,tastypie,upload,user_messages,geonode_themes,geoapps,favorite,geonode_client +apps = contenttypes,auth,people,groups,account,guardian,admin,actstream,announcements,avatar,base,documents,geoserver,invitations,pinax_notifications,harvesting,services,layers,maps,oauth2_provider,sites,socialaccount,taggit,tastypie,upload,user_messages,geonode_themes,geoapps,favorite,geonode_client +dumps = contenttypes,auth,people,groups,account,guardian,admin,actstream,announcements,avatar,base,documents,geoserver,invitations,pinax_notifications,harvesting,services,layers,maps,oauth2_provider,sites,socialaccount,taggit,tastypie,upload,user_messages,geonode_themes,geoapps,favorite,geonode_client diff --git a/geonode/br/management/commands/settings_sample.ini b/geonode/br/management/commands/settings_sample.ini index cf02d2532c1..157414ab5e0 100644 --- a/geonode/br/management/commands/settings_sample.ini +++ b/geonode/br/management/commands/settings_sample.ini @@ -13,5 +13,5 @@ dumprasterdata = yes # data_layername_exclude_filter = {comma separated list of layernames, optionally with glob syntax} e.g.: tuscany_*,italy [fixtures] -apps = contenttypes,auth,people,groups,account,guardian,admin,actstream,announcements,avatar,base,documents,geoserver,invitations,pinax_notifications,layers,maps,oauth2_provider,harvesting,services,sites,socialaccount,taggit,tastypie,upload,user_messages,geonode_themes,geoapps,favorite,geonode_client -dumps = contenttypes,auth,people,groups,account,guardian,admin,actstream,announcements,avatar,base,documents,geoserver,invitations,pinax_notifications,layers,maps,oauth2_provider,harvesting,services,sites,socialaccount,taggit,tastypie,upload,user_messages,geonode_themes,geoapps,favorite,geonode_client \ No newline at end of file +apps = contenttypes,auth,people,groups,account,guardian,admin,actstream,announcements,avatar,base,documents,geoserver,invitations,pinax_notifications,harvesting,services,layers,maps,oauth2_provider,sites,socialaccount,taggit,tastypie,upload,user_messages,geonode_themes,geoapps,favorite,geonode_client +dumps = contenttypes,auth,people,groups,account,guardian,admin,actstream,announcements,avatar,base,documents,geoserver,invitations,pinax_notifications,harvesting,services,layers,maps,oauth2_provider,sites,socialaccount,taggit,tastypie,upload,user_messages,geonode_themes,geoapps,favorite,geonode_client From 499930ac8fdcd380222d9c6a7be5901102c0087f Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Wed, 30 Aug 2023 18:36:56 +0200 Subject: [PATCH 146/330] enable .csv document format (#11428) --- geonode/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/geonode/settings.py b/geonode/settings.py index 9b746b9d63e..a5a4a69582d 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -597,6 +597,7 @@ ALLOWED_DOCUMENT_TYPES = ( [ "txt", + "csv", "log", "doc", "docx", From 81f1c4ab45582c6058e3ff9eed80452665bd2b6e Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Tue, 5 Sep 2023 18:09:37 +0200 Subject: [PATCH 147/330] [Fixes #11335] Hookable logic for the automatic assignment of regions (#11362) * [Fixes #11335] Hookable logic for the automatic assignment of regions * [Fixes #11335] Hookable logic for the automatic assignment of regions * [Fixes #11335] Change default region value * [Fixes #11335] Add test coverage * [Fixes #11335] Add test coverage * [Fixes #11335] add missing file headers * [Fixes #11335] Move region dynamic loading into the metadata storer logic * [Fixes #11335] Add cache system for metadata storer * [Fixes #11335] Remove default region handler * [Fixes #11335] Remove cache and use globals * [Fixes #11335] fix broken tests * [Fixes #11335] fix broken tests * [Fixes #11335] fix broken tests * self document the spatial_predicate_region_assignor * [Fixes #11335] remove typo * [Fixes #11335] fix flake8 --------- Co-authored-by: Alessio Fabiani Co-authored-by: G. Allegri --- geonode/base/tests.py | 13 ++++++- geonode/resource/manager.py | 5 +-- geonode/resource/regions_storer.py | 60 ++++++++++++++++++++++++++++++ geonode/resource/utils.py | 55 ++++++--------------------- geonode/settings.py | 9 ++++- geonode/tests/smoke.py | 10 ++--- 6 files changed, 97 insertions(+), 55 deletions(-) create mode 100644 geonode/resource/regions_storer.py diff --git a/geonode/base/tests.py b/geonode/base/tests.py index 3c6cfb9b4f2..4c11e59ee58 100644 --- a/geonode/base/tests.py +++ b/geonode/base/tests.py @@ -81,7 +81,7 @@ from django.core.management import call_command from django.core.management.base import CommandError from geonode.base.forms import ThesaurusAvailableForm, THESAURUS_RESULT_LIST_SEPERATOR - +from geonode.resource.manager import resource_manager test_image = Image.new("RGBA", size=(50, 50), color=(155, 0, 0)) @@ -1163,3 +1163,14 @@ def test_region_assignment_for_extent(self): self.assertFalse( region.is_assignable_to_geom(self.dataset_outside_region), "Extent outside a region should be assigned" ) + + @override_settings(METADATA_STORERS=["geonode.resource.regions_storer.spatial_predicate_region_assignor"]) + def test_regions_are_assigned_if_handler_is_used(self): + dataset = resource_manager.create( + None, + resource_type=Dataset, + defaults=dict(owner=get_user_model().objects.first(), title="test_region_dataset", is_approved=True), + ) + self.assertTrue(dataset.regions.exists()) + self.assertEqual(1, dataset.regions.count()) + self.assertEqual("Global", dataset.regions.first().name) diff --git a/geonode/resource/manager.py b/geonode/resource/manager.py index 79a9bb718ce..245350d6cbf 100644 --- a/geonode/resource/manager.py +++ b/geonode/resource/manager.py @@ -44,7 +44,7 @@ from geonode.security.utils import perms_as_set, get_user_groups, skip_registered_members_common_group from . import settings as rm_settings -from .utils import update_resource, metadata_storers, resourcebase_post_save +from .utils import update_resource, resourcebase_post_save from ..base import enumerations from ..base.models import ResourceBase @@ -398,7 +398,6 @@ def update( extra_metadata=extra_metadata, ) _resource = self._concrete_resource_manager.update(uuid, instance=_resource, notify=notify) - _resource = metadata_storers(_resource.get_real_instance(), custom) # The following is only a demo proof of concept for a pluggable WF subsystem from geonode.resource.processing.models import ProcessingWorkflow @@ -415,7 +414,7 @@ def update( finally: try: _resource.save(notify=notify) - resourcebase_post_save(_resource.get_real_instance()) + resourcebase_post_save(_resource.get_real_instance(), kwargs={**kwargs, **custom}) _resource.set_permissions( created=False, approval_status_changed=( diff --git a/geonode/resource/regions_storer.py b/geonode/resource/regions_storer.py new file mode 100644 index 00000000000..e1213dd6010 --- /dev/null +++ b/geonode/resource/regions_storer.py @@ -0,0 +1,60 @@ +######################################################################### +# +# Copyright (C) 2023 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### + +import re +import logging +import traceback + +from geonode.base.models import Region +from django.contrib.gis.geos import GEOSGeometry + +logger = logging.getLogger(__name__) + + +# A metadata storer that assigns regions to a resource on the base of spatial predicates +def spatial_predicate_region_assignor(instance, *args, **kwargs): + def _get_poly_from_instance(instance): + srid1, wkt1 = instance.geographic_bounding_box.split(";") + srid1 = re.findall(r"\d+", srid1) + poly1 = GEOSGeometry(wkt1, srid=int(srid1[0])) + poly1.transform(4326) + return poly1 + + if not instance.regions or instance.regions.count() == 0: + poly1 = _get_poly_from_instance(instance) + + queryset = Region.objects.all().order_by("name") + global_regions = [] + regions_to_add = [] + for region in queryset: + try: + if region.is_assignable_to_geom(poly1): + regions_to_add.append(region) + if region.level == 0 and region.parent is None: + global_regions.append(region) + except Exception: + tb = traceback.format_exc() + if tb: + logger.debug(tb) + if regions_to_add or global_regions: + if regions_to_add: + instance.regions.add(*regions_to_add) + else: + instance.regions.add(*global_regions) + return instance diff --git a/geonode/resource/utils.py b/geonode/resource/utils.py index b8e70a362cb..5490d85cca6 100644 --- a/geonode/resource/utils.py +++ b/geonode/resource/utils.py @@ -16,12 +16,11 @@ # along with this program. If not, see . # ######################################################################### + import os -import re import uuid import logging import datetime -import traceback from urllib.parse import urlparse, urljoin @@ -30,15 +29,14 @@ from django.utils import timezone from django.core.exceptions import FieldDoesNotExist from django.utils.translation import ugettext_lazy as _ -from django.contrib.gis.geos import GEOSGeometry, MultiPolygon - +from django.contrib.gis.geos import MultiPolygon from geonode.utils import OGC_Servers_Handler +from django.utils.module_loading import import_string from ..base import enumerations from ..base.models import ( ExtraMetadata, Link, - Region, License, ResourceBase, TopicCategory, @@ -279,12 +277,12 @@ def update_resource( return instance -def metadata_storers(instance, custom={}): - from django.utils.module_loading import import_string +def call_storers(instance, custom={}): + if not globals().get("storer_modules"): + storer_module_path = settings.METADATA_STORERS if hasattr(settings, "METADATA_STORERS") else [] + globals()["storer_modules"] = [import_string(storer_path) for storer_path in storer_module_path] - available_storers = settings.METADATA_STORERS if hasattr(settings, "METADATA_STORERS") else [] - for storer_path in available_storers: - storer = import_string(storer_path) + for storer in globals().get("storer_modules", []): storer(instance, custom) return instance @@ -464,41 +462,9 @@ def metadata_post_save(instance, *args, **kwargs): ResourceBase.objects.filter(id=instance.id).update(**defaults) - try: - if not instance.regions or instance.regions.count() == 0: - srid1, wkt1 = instance.geographic_bounding_box.split(";") - srid1 = re.findall(r"\d+", srid1) - - poly1 = GEOSGeometry(wkt1, srid=int(srid1[0])) - poly1.transform(4326) - - queryset = Region.objects.all().order_by("name") - global_regions = [] - regions_to_add = [] - for region in queryset: - try: - if region.is_assignable_to_geom(poly1): - regions_to_add.append(region) - if region.level == 0 and region.parent is None: - global_regions.append(region) - except Exception: - tb = traceback.format_exc() - if tb: - logger.debug(tb) - if regions_to_add or global_regions: - if regions_to_add: - instance.regions.add(*regions_to_add) - else: - instance.regions.add(*global_regions) - except Exception: - tb = traceback.format_exc() - if tb: - logger.debug(tb) - finally: - # refresh catalogue metadata records - from ..catalogue.models import catalogue_post_save + from ..catalogue.models import catalogue_post_save - catalogue_post_save(instance=instance, sender=instance.__class__) + catalogue_post_save(instance=instance, sender=instance.__class__) def resourcebase_post_save(instance, *args, **kwargs): @@ -507,6 +473,7 @@ def resourcebase_post_save(instance, *args, **kwargs): Has to be called by the children """ if instance: + instance = call_storers(instance.get_real_instance(), kwargs.get("custom", {})) if hasattr(instance, "abstract") and not getattr(instance, "abstract", None): instance.abstract = _("No abstract provided") if hasattr(instance, "title") and not getattr(instance, "title", None) or getattr(instance, "title", "") == "": diff --git a/geonode/settings.py b/geonode/settings.py index a5a4a69582d..48194eaacf9 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -2217,7 +2217,6 @@ def get_geonode_catalogue_service(): Variable used to actually get the expected metadata schema for each resource_type. In this way, each resource type can have a different metadata schema """ - EXTRA_METADATA_SCHEMA = { **{ "map": os.getenv("MAP_EXTRA_METADATA_SCHEMA", DEFAULT_EXTRA_METADATA_SCHEMA), @@ -2228,12 +2227,18 @@ def get_geonode_catalogue_service(): **CUSTOM_METADATA_SCHEMA, } +""" +List of modules that implement custom metadata storers that will be called when the metadata of a resource is saved +""" +METADATA_STORERS = [ + # 'geonode.resource.regions_storer.spatial_predicate_region_assignor', +] + """ Define the URLs patterns used by the SizeRestrictedFileUploadHandler to evaluate if the file is greater than the limit size defined """ - SIZE_RESTRICTED_FILE_UPLOAD_ELEGIBLE_URL_NAMES = ( "data_upload", "uploads-upload", diff --git a/geonode/tests/smoke.py b/geonode/tests/smoke.py index bda90ec0e16..8b094310722 100644 --- a/geonode/tests/smoke.py +++ b/geonode/tests/smoke.py @@ -22,7 +22,7 @@ from django.http import HttpResponse from geonode.base.populate_test_data import create_single_dataset from geonode.resource.download_handler import DownloadHandler -from geonode.resource.utils import metadata_storers +from geonode.resource.utils import call_storers from geonode.tests.base import GeoNodeBaseTestSupport import os @@ -298,15 +298,15 @@ def setUp(self): self.uuid = self.dataset.uuid self.abstract = self.dataset.abstract self.custom = { - "processes": {"uuid": "abc123cfde", "abstract": "updated abstract"}, + "processes": {"uuid": "abc123cfde", "name": "updated name"}, "second-stage": {"title": "Updated Title", "abstract": "another update"}, } @override_settings(METADATA_STORERS=["geonode.tests.smoke.dummy_metadata_storer"]) def test_will_use_single_storers_defined(self): - metadata_storers(self.dataset, self.custom) + call_storers(self.dataset, self.custom) self.assertEqual("abc123cfde", self.dataset.uuid) - self.assertEqual("updated abstract", self.dataset.abstract) + self.assertEqual("updated name", self.dataset.name) @override_settings( METADATA_STORERS=[ @@ -315,7 +315,7 @@ def test_will_use_single_storers_defined(self): ] ) def test_will_use_multiple_storers_defined(self): - dataset = metadata_storers(self.dataset, self.custom) + dataset = call_storers(self.dataset, self.custom) self.assertEqual("abc123cfde", dataset.uuid) self.assertEqual("another update", dataset.abstract) self.assertEqual("Updated Title", dataset.title) From cd70285a2c92732bded9905584e305a4fde9ac77 Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Fri, 8 Sep 2023 18:05:36 +0200 Subject: [PATCH 148/330] [Fixes #11421] Improvements for DownloadHandler (#11423) * [Fixes #11421] Improvements for DownloadHandler * [Fixes #11421] Fix tests * [Fixes #11421] Fix tests * [Fixes #11421] Fix tests * [Fixes #11421] Improve module import in download handlers * [Fixes #11421] Improve module import in download handlers * [Fixes #11335] fix flake8 * [Fixes #11421] fix broken test * [Fixes #11421] Test fix tests * [Fixes #11421] rollback test changes * [Fixes #11421] Test order response value * [Fixes #11421] Fix flake8 and black issues * improve ajax_safe logic, extended tests * remove unused import * remove unused import (2) * fixed custom download handler test * fix * black fix * removed old parameter --------- Co-authored-by: G. Allegri --- geonode/base/api/serializers.py | 46 +++++++++- geonode/base/api/tests.py | 90 +++++++++++++++---- geonode/documents/models.py | 8 ++ geonode/documents/tests.py | 22 ++++- geonode/geoserver/tests/test_server.py | 29 +++++- geonode/layers/api/tests.py | 24 +++++ .../{resource => layers}/download_handler.py | 61 ++++++++++--- geonode/layers/models.py | 2 - geonode/layers/tests.py | 76 +++++++++++++--- geonode/layers/utils.py | 34 +++++++ geonode/layers/views.py | 12 ++- geonode/security/permissions.py | 6 +- geonode/security/tests.py | 4 +- geonode/settings.py | 7 +- geonode/tests/smoke.py | 20 ----- geonode/utils.py | 29 ------ 16 files changed, 362 insertions(+), 108 deletions(-) rename geonode/{resource => layers}/download_handler.py (73%) diff --git a/geonode/base/api/serializers.py b/geonode/base/api/serializers.py index b831326e88a..2b6933b4633 100644 --- a/geonode/base/api/serializers.py +++ b/geonode/base/api/serializers.py @@ -29,6 +29,7 @@ from django.db.models.query import QuerySet from django.http import QueryDict +from deprecated import deprecated from rest_framework import serializers from rest_framework_gis import fields from rest_framework.reverse import reverse, NoReverseMatch @@ -54,8 +55,9 @@ ) from geonode.groups.models import GroupCategory, GroupProfile from geonode.base.api.fields import ComplexDynamicRelationField +from geonode.layers.utils import get_dataset_download_handlers, get_default_dataset_download_handler from geonode.utils import build_absolute_uri -from geonode.security.utils import get_resources_with_perms +from geonode.security.utils import get_resources_with_perms, get_geoapp_subtypes from geonode.resource.models import ExecutionRequest logger = logging.getLogger(__name__) @@ -278,8 +280,12 @@ class DownloadLinkField(DynamicComputedField): def __init__(self, **kwargs): super().__init__(**kwargs) + @deprecated(version="4.2.0", reason="Will be replaced by download_urls") def get_attribute(self, instance): try: + logger.info( + "This field is deprecated, and will be removed in the future GeoNode version. Please refer to download_urls" + ) _instance = instance.get_real_instance() return _instance.download_url if hasattr(_instance, "download_url") else None except Exception as e: @@ -287,6 +293,43 @@ def get_attribute(self, instance): return None +class DownloadArrayLinkField(DynamicComputedField): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def get_attribute(self, instance): + try: + _instance = instance.get_real_instance() + except Exception as e: + logger.exception(e) + raise e + if _instance.resource_type in ["map"] + get_geoapp_subtypes(): + return [] + elif _instance.resource_type in ["document"]: + return [ + { + "url": _instance.download_url, + "ajax_safe": _instance.download_is_ajax_safe, + } + ] + elif _instance.resource_type in ["dataset"]: + download_urls = [] + # lets get only the default one first to set it + default_handler = get_default_dataset_download_handler() + obj = default_handler(self.context.get("request"), _instance.alternate) + if obj.download_url: + download_urls.append({"url": obj.download_url, "ajax_safe": obj.is_ajax_safe, "default": True}) + # then let's prepare the payload with everything + handler_list = get_dataset_download_handlers() + for handler in handler_list: + obj = handler(self.context.get("request"), _instance.alternate) + if obj.download_url: + download_urls.append({"url": obj.download_url, "ajax_safe": obj.is_ajax_safe, "default": False}) + return download_urls + else: + return [] + + class FavoriteField(DynamicComputedField): def __init__(self, **kwargs): super().__init__(**kwargs) @@ -479,6 +522,7 @@ def __init__(self, *args, **kwargs): self.fields["is_copyable"] = serializers.BooleanField(read_only=True) self.fields["download_url"] = DownloadLinkField(read_only=True) self.fields["favorite"] = FavoriteField(read_only=True) + self.fields["download_urls"] = DownloadArrayLinkField(read_only=True) metadata = ComplexDynamicRelationField(ExtraMetadataSerializer, embed=False, many=True, deferred=True) diff --git a/geonode/base/api/tests.py b/geonode/base/api/tests.py index 4feffb7d3c2..961d28da16a 100644 --- a/geonode/base/api/tests.py +++ b/geonode/base/api/tests.py @@ -23,7 +23,7 @@ import json import logging from django.contrib.contenttypes.models import ContentType -from django.test import override_settings +from django.test import RequestFactory, override_settings import gisdata from PIL import Image @@ -751,12 +751,16 @@ def test_resource_serializer_validation(self): ds = ResourceBase.objects.get(title=title) ds.keywords.add(HierarchicalKeyword.objects.get(slug="a1")) - serialized = ResourceBaseSerializer(ds) + factory = RequestFactory() + rq = factory.get("test") + rq.user = owner + + serialized = ResourceBaseSerializer(ds, context={"request": rq}) json = JSONRenderer().render(serialized.data) stream = BytesIO(json) data = JSONParser().parse(stream) self.assertIsInstance(data, dict) - se = ResourceBaseSerializer(data=data) + se = ResourceBaseSerializer(data=data, context={"request": rq}) self.assertTrue(se.is_valid()) def test_delete_user_with_resource(self): @@ -2065,6 +2069,7 @@ def test_manager_can_edit_map(self): """ REST API must not forbid saving maps and apps to non-admin and non-owners. """ + self.maxDiff = None from geonode.maps.models import Map _map = Map.objects.filter(uuid__isnull=False, owner__username="admin").first() @@ -2120,7 +2125,7 @@ def test_manager_can_edit_map(self): response = self.client.get(resource_service_permissions_url, format="json") self.assertEqual(response.status_code, 200) resource_perm_spec = response.data - self.assertEqual( + self.assertDictEqual( resource_perm_spec, { "users": [ @@ -2161,7 +2166,7 @@ def test_manager_can_edit_map(self): response = self.client.get(get_perms_url, format="json") self.assertEqual(response.status_code, 200) resource_perm_spec = response.data - self.assertEqual( + self.assertDictEqual( resource_perm_spec, { "users": [ @@ -2200,20 +2205,10 @@ def test_manager_can_edit_map(self): response = self.client.get(get_perms_url, format="json") self.assertEqual(response.status_code, 200) resource_perm_spec = response.data - self.assertEqual( + self.assertDictEqual( resource_perm_spec, { "users": [ - { - "id": 1, - "username": "admin", - "first_name": "admin", - "last_name": "", - "avatar": "https://www.gravatar.com/avatar/7a68c67c8d409ff07e42aa5d5ab7b765/?s=240", - "permissions": "owner", - "is_staff": True, - "is_superuser": True, - }, { "id": bobby.id, "username": "bobby", @@ -2224,6 +2219,16 @@ def test_manager_can_edit_map(self): "is_staff": False, "is_superuser": False, }, + { + "id": 1, + "username": "admin", + "first_name": "admin", + "last_name": "", + "avatar": "https://www.gravatar.com/avatar/7a68c67c8d409ff07e42aa5d5ab7b765/?s=240", + "permissions": "owner", + "is_staff": True, + "is_superuser": True, + }, ], "organizations": [], "groups": [ @@ -2445,6 +2450,59 @@ def test_base_resources_dont_return_download_link_if_map(self): download_url = response.json().get("resource").get("download_url") self.assertIsNone(download_url) + def test_base_resources_return_not_download_links_for_maps(self): + """ + Ensure we can access the Resource Base list. + """ + _map = Map.objects.first() + # From resource base API + url = reverse("base-resources-detail", args=[_map.id]) + response = self.client.get(url, format="json") + download_url = response.json().get("resource").get("download_urls", None) + self.assertListEqual([], download_url) + + # from maps api + url = reverse("maps-detail", args=[_map.id]) + download_url = response.json().get("resource").get("download_urls") + self.assertListEqual([], download_url) + + def test_base_resources_return_download_links_for_documents(self): + """ + Ensure we can access the Resource Base list. + """ + doc = Document.objects.first() + expected_payload = [{"url": build_absolute_uri(doc.download_url), "ajax_safe": doc.download_is_ajax_safe}] + # From resource base API + url = reverse("base-resources-detail", args=[doc.id]) + response = self.client.get(url, format="json") + download_url = response.json().get("resource").get("download_urls") + self.assertListEqual(expected_payload, download_url) + + # from documents api + url = reverse("documents-detail", args=[doc.id]) + download_url = response.json().get("resource").get("download_urls") + self.assertListEqual(expected_payload, download_url) + + def test_base_resources_return_download_links_for_datasets(self): + """ + Ensure we can access the Resource Base list. + """ + _dataset = Dataset.objects.first() + expected_payload = [ + {"url": reverse("dataset_download", args=[_dataset.alternate]), "ajax_safe": True, "default": True} + ] + + # From resource base API + url = reverse("base-resources-detail", args=[_dataset.id]) + response = self.client.get(url, format="json") + download_url = response.json().get("resource").get("download_urls") + self.assertEqual(expected_payload, download_url) + + # from dataset api + url = reverse("datasets-detail", args=[_dataset.id]) + download_url = response.json().get("resource").get("download_urls") + self.assertEqual(expected_payload, download_url) + class TestExtraMetadataBaseApi(GeoNodeBaseTestSupport): def setUp(self): diff --git a/geonode/documents/models.py b/geonode/documents/models.py index 402194f9908..5f5f41ab218 100644 --- a/geonode/documents/models.py +++ b/geonode/documents/models.py @@ -103,6 +103,14 @@ def href(self): elif self.files: return urljoin(settings.SITEURL, reverse("document_link", args=(self.id,))) + @property + def is_local(self): + return False if self.doc_url else True + + @property + def download_is_ajax_safe(self): + return self.is_local + @property def is_file(self): return self.files and self.extension diff --git a/geonode/documents/tests.py b/geonode/documents/tests.py index 8bf91a88529..f58a71f4734 100644 --- a/geonode/documents/tests.py +++ b/geonode/documents/tests.py @@ -56,7 +56,7 @@ from geonode.documents.enumerations import DOCUMENT_TYPE_MAP from geonode.documents.models import Document, DocumentResourceLink -from geonode.base.populate_test_data import all_public, create_models, remove_models +from geonode.base.populate_test_data import all_public, create_models, create_single_doc, remove_models from geonode.upload.api.exceptions import FileUploadLimitException from .forms import DocumentCreateForm @@ -153,6 +153,26 @@ def test_remote_document_is_marked_remote(self): d = Document.objects.get(title="A remote document through form is remote") self.assertEqual(d.sourcetype, SOURCE_TYPE_REMOTE) + def test_download_is_not_ajax_safe(self): + """Remote document is mark as not safe.""" + self.client.login(username="admin", password="admin") + form_data = { + "title": "A remote document through form is remote", + "doc_url": "https://development.demo.geonode.org/static/mapstore/img/geonode-logo.svg", + } + + response = self.client.post(reverse("document_upload"), data=form_data) + + self.assertEqual(response.status_code, 302) + + d = Document.objects.get(title="A remote document through form is remote") + self.assertFalse(d.download_is_ajax_safe) + + def test_download_is_ajax_safe(self): + """Remote document is mark as not safe.""" + d = create_single_doc("example_doc_name") + self.assertTrue(d.download_is_ajax_safe) + def test_create_document_url(self): """Tests creating an external document instead of a file.""" diff --git a/geonode/geoserver/tests/test_server.py b/geonode/geoserver/tests/test_server.py index 1fbafbff07f..42d7ec06bb9 100644 --- a/geonode/geoserver/tests/test_server.py +++ b/geonode/geoserver/tests/test_server.py @@ -48,6 +48,7 @@ from geonode.layers.populate_datasets_data import create_dataset_data from geonode.base.populate_test_data import all_public, create_models, remove_models, create_single_dataset from geonode.geoserver.helpers import gs_catalog, get_sld_for, extract_name_from_sld +from geonode.catalogue.models import catalogue_post_save import logging @@ -1192,8 +1193,19 @@ def test_set_resources_links(self): with self.settings(UPDATE_RESOURCE_LINKS_AT_MIGRATE=True, ASYNC_SIGNALS=False): # Links _def_link_types = ["original", "metadata"] - _links = Link.objects.filter(link_type__in=_def_link_types) # Check 'original' and 'metadata' links exist + Link.objects.update_or_create( + resource=Dataset.objects.first(), + url="https://custom_dowonload_url.com", + defaults=dict( + extension="zip", + name="Original Dataset", + mime="application/octet-stream", + link_type="original", + ), + ) + _links = Link.objects.filter(link_type__in=_def_link_types) + self.assertIsNotNone(_links, "No 'original' and 'metadata' links have been found") # Delete all 'original' and 'metadata' links @@ -1233,6 +1245,18 @@ def test_set_resources_links(self): for _lyr in _post_migrate_datasets: # Check original links in csw_anytext + # by default is not created anymore, we need to create one + Link.objects.update_or_create( + resource=_lyr, + url="https://custom_dowonload_url.com", + defaults=dict( + extension="zip", + name="Original Dataset", + mime="application/octet-stream", + link_type="original", + ), + ) + _post_migrate_links_orig = Link.objects.filter( resource=_lyr.resourcebase_ptr, resource_id=_lyr.resourcebase_ptr.id, link_type="original" ) @@ -1240,6 +1264,9 @@ def test_set_resources_links(self): _post_migrate_links_orig.exists(), f"No 'original' links has been found for the layer '{_lyr.alternate}'", ) + # needed to update the csw_anytext field with the new link created + catalogue_post_save(instance=_lyr, sender=_lyr.__class__) + _lyr.refresh_from_db() for _link_orig in _post_migrate_links_orig: self.assertIn( _link_orig.url, diff --git a/geonode/layers/api/tests.py b/geonode/layers/api/tests.py index 7361e5c744a..88ba047ed03 100644 --- a/geonode/layers/api/tests.py +++ b/geonode/layers/api/tests.py @@ -30,6 +30,7 @@ from guardian.shortcuts import assign_perm, get_anonymous_user from geonode.geoserver.createlayer.utils import create_dataset +from geonode.base.models import Link from geonode.base.populate_test_data import create_models, create_single_dataset from geonode.layers.models import Attribute, Dataset from geonode.maps.models import Map, MapLayer @@ -415,3 +416,26 @@ def test_valid_metadata_file(self): put_data = {"metadata_file": f} response = self.client.put(url, data=put_data) self.assertEqual(200, response.status_code) + + def test_download_api(self): + dataset = create_single_dataset("test_dataset") + url = reverse("datasets-detail", kwargs={"pk": dataset.pk}) + response = self.client.get(url) + self.assertTrue(response.status_code == 200) + data = response.json()["dataset"] + download_url_data = data["download_urls"][0] + download_url = reverse("dataset_download", args=[dataset.alternate]) + self.assertEqual(download_url_data["default"], True) + self.assertEqual(download_url_data["ajax_safe"], True) + self.assertEqual(download_url_data["url"], download_url) + + link = Link(link_type="original", url="https://myoriginal.org", resource=dataset) + link.save() + + response = self.client.get(url) + data = response.json()["dataset"] + download_url_data = data["download_urls"][0] + download_url = reverse("dataset_download", args=[dataset.alternate]) + self.assertEqual(download_url_data["default"], True) + self.assertEqual(download_url_data["ajax_safe"], False) + self.assertEqual(download_url_data["url"], "https://myoriginal.org") diff --git a/geonode/resource/download_handler.py b/geonode/layers/download_handler.py similarity index 73% rename from geonode/resource/download_handler.py rename to geonode/layers/download_handler.py index ae21e4e23a9..d969a0d3703 100644 --- a/geonode/resource/download_handler.py +++ b/geonode/layers/download_handler.py @@ -31,10 +31,10 @@ from geonode.proxy.views import fetch_response_headers from geonode.utils import HttpClient -logger = logging.getLogger("geonode.resource.download_handler") +logger = logging.getLogger("geonode.layers.download_handler") -class DownloadHandler: +class DatasetDownloadHandler: def __str__(self): return f"{self.__module__}.{self.__class__.__name__}" @@ -44,6 +44,7 @@ def __repr__(self): def __init__(self, request, resource_name) -> None: self.request = request self.resource_name = resource_name + self._resource = None def get_download_response(self): """ @@ -51,27 +52,61 @@ def get_download_response(self): that allow the resource download """ resource = self.get_resource() + if not resource: + raise Http404("Resource requested is not available") response = self.process_dowload(resource) return response + @property + def is_link_resource(self): + resource = self.get_resource() + return resource.link_set.filter(resource=resource, link_type="original").exists() + + @property + def is_ajax_safe(self): + """ + AJAX is safe to be used for WPS downloads. In case of a link set in a Link entry we cannot assume it, + since it could point to an external (non CORS enabled) URL + """ + return settings.USE_GEOSERVER and not self.is_link_resource + + @property + def download_url(self): + resource = self.get_resource() + if not resource: + return None + if resource.subtype not in ["vector", "raster", "vector_time"]: + logger.info("Download URL is available only for datasets that have been harvested and copied locally") + return None + + if self.is_link_resource: + return resource.link_set.filter(resource=resource.get_self_resource(), link_type="original").first().url + + return reverse("dataset_download", args=[resource.alternate]) + def get_resource(self): """ Returnt the object needed """ - try: - return _resolve_dataset( - self.request, - self.resource_name, - "base.download_resourcebase", - _("You do not have permissions for this dataset."), - ) - except Exception as e: - raise Http404(Exception(_("Not found"), e)) - - def process_dowload(self, resource): + if not self._resource: + try: + self._resource = _resolve_dataset( + self.request, + self.resource_name, + "base.download_resourcebase", + _("You do not have download permissions for this dataset."), + ) + except Exception as e: + logger.exception(e) + + return self._resource + + def process_dowload(self, resource=None): """ Generate the response object """ + if not resource: + resource = self.get_resource() if not settings.USE_GEOSERVER: # if GeoServer is not used, we redirect to the proxy download return HttpResponseRedirect(reverse("download", args=[resource.id])) diff --git a/geonode/layers/models.py b/geonode/layers/models.py index 0d6c425e8e0..05df8956868 100644 --- a/geonode/layers/models.py +++ b/geonode/layers/models.py @@ -330,8 +330,6 @@ def download_url(self): if self.subtype not in ["vector", "raster", "vector_time"]: logger.info("Download URL is available only for datasets that have been harvested and copied locally") return None - if self.link_set.filter(resource=self.get_self_resource(), link_type="original").exists(): - return self.link_set.filter(resource=self.get_self_resource(), link_type="original").first().url return build_absolute_uri(reverse("dataset_download", args=(self.alternate,))) @property diff --git a/geonode/layers/tests.py b/geonode/layers/tests.py index 2deb3ca0a21..ec08069bac6 100644 --- a/geonode/layers/tests.py +++ b/geonode/layers/tests.py @@ -41,6 +41,7 @@ from django.contrib.gis.geos import Polygon from django.db.models import Count from django.contrib.auth import get_user_model +from django.http import HttpResponse from django.conf import settings from django.test.utils import override_settings @@ -48,6 +49,7 @@ from geonode.geoserver.createlayer.utils import create_dataset from geonode.layers import utils +from geonode.layers.utils import clear_dataset_download_handlers from geonode.base import enumerations from geonode.layers import DatasetAppConfig from geonode.layers.admin import DatasetAdmin @@ -81,6 +83,7 @@ ) from geonode.base.populate_test_data import all_public, create_models, remove_models, create_single_dataset +from geonode.layers.download_handler import DatasetDownloadHandler logger = logging.getLogger(__name__) @@ -323,6 +326,7 @@ def test_dataset_styles(self): def test_dataset_links(self): lyr = Dataset.objects.filter(subtype="vector").first() self.assertEqual(lyr.subtype, "vector") + if check_ogc_backend(geoserver.BACKEND_PACKAGE): links = Link.objects.filter(resource=lyr.resourcebase_ptr, link_type="metadata") self.assertIsNotNone(links) @@ -373,11 +377,6 @@ def test_dataset_links(self): links = Link.objects.filter(resource=lyr.resourcebase_ptr, link_type="image") self.assertIsNotNone(links) - Link.objects.filter(resource=lyr.resourcebase_ptr, link_type="original").update( - url="http://google.com/test" - ) - self.assertEqual(lyr.download_url, "http://google.com/test") - def test_get_valid_user(self): # Verify it accepts an admin user adminuser = get_user_model().objects.get(is_superuser=True) @@ -1210,7 +1209,7 @@ def test_dataset_download_invalid_wps_format(self): self.assertEqual(500, response.status_code) self.assertDictEqual({"error": "The format provided is not valid for the selected resource"}, response.json()) - @patch("geonode.resource.download_handler.HttpClient.request") + @patch("geonode.layers.download_handler.HttpClient.request") def test_dataset_download_call_the_catalog_raise_error_for_no_200(self, mocked_catalog): _response = MagicMock(status_code=500, content="foo-bar") mocked_catalog.return_value = _response, "foo-bar" @@ -1234,7 +1233,7 @@ def test_dataset_download_call_the_catalog_raise_error_for_error_content(self): # if settings.USE_GEOSERVER is false, the URL must be redirected self.client.login(username="admin", password="admin") dataset = Dataset.objects.first() - with patch("geonode.resource.download_handler.HttpClient.request") as mocked_catalog: + with patch("geonode.layers.download_handler.HttpClient.request") as mocked_catalog: mocked_catalog.return_value = _response, content url = reverse("dataset_download", args=[dataset.alternate]) response = self.client.get(url) @@ -1247,7 +1246,7 @@ def test_dataset_download_call_the_catalog_works(self): self.client.login(username="admin", password="admin") dataset = Dataset.objects.first() layer = create_dataset(dataset.title, dataset.title, dataset.owner, "Point") - with patch("geonode.resource.download_handler.HttpClient.request") as mocked_catalog: + with patch("geonode.layers.download_handler.HttpClient.request") as mocked_catalog: mocked_catalog.return_value = _response, "" url = reverse("dataset_download", args=[layer.alternate]) response = self.client.get(url) @@ -1266,21 +1265,21 @@ def test_dataset_download_call_the_catalog_work_anonymous(self): _response = MagicMock(status_code=200, text="", headers={"Content-Type": ""}) # noqa dataset = Dataset.objects.first() layer = create_dataset(dataset.title, dataset.title, dataset.owner, "Point") - with patch("geonode.resource.download_handler.HttpClient.request") as mocked_catalog: + with patch("geonode.layers.download_handler.HttpClient.request") as mocked_catalog: mocked_catalog.return_value = _response, "" url = reverse("dataset_download", args=[layer.alternate]) response = self.client.get(url) self.assertTrue(response.status_code == 200) @override_settings(USE_GEOSERVER=True) - @patch("geonode.resource.download_handler.get_template") + @patch("geonode.layers.download_handler.get_template") def test_dataset_download_call_the_catalog_work_for_raster(self, pathed_template): # if settings.USE_GEOSERVER is false, the URL must be redirected _response = MagicMock(status_code=200, text="", headers={"Content-Type": ""}) # noqa dataset = Dataset.objects.filter(subtype="raster").first() layer = create_dataset(dataset.title, dataset.title, dataset.owner, "Point") Dataset.objects.filter(alternate=layer.alternate).update(subtype="raster") - with patch("geonode.resource.download_handler.HttpClient.request") as mocked_catalog: + with patch("geonode.layers.download_handler.HttpClient.request") as mocked_catalog: mocked_catalog.return_value = _response, "" url = reverse("dataset_download", args=[layer.alternate]) response = self.client.get(url) @@ -1293,13 +1292,13 @@ def test_dataset_download_call_the_catalog_work_for_raster(self, pathed_template ) @override_settings(USE_GEOSERVER=True) - @patch("geonode.resource.download_handler.get_template") + @patch("geonode.layers.download_handler.get_template") def test_dataset_download_call_the_catalog_work_for_vector(self, pathed_template): # if settings.USE_GEOSERVER is false, the URL must be redirected _response = MagicMock(status_code=200, text="", headers={"Content-Type": ""}) # noqa dataset = Dataset.objects.filter(subtype="vector").first() layer = create_dataset(dataset.title, dataset.title, dataset.owner, "Point") - with patch("geonode.resource.download_handler.HttpClient.request") as mocked_catalog: + with patch("geonode.layers.download_handler.HttpClient.request") as mocked_catalog: mocked_catalog.return_value = _response, "" url = reverse("dataset_download", args=[layer.alternate]) response = self.client.get(url) @@ -2219,3 +2218,54 @@ def _assert_perms(self, expected_perms, dataset, username, assertion=True): self.assertSetEqual(expected_perms, actual) else: self.assertFalse(username in [user.username for user in perms["users"]]) + + +class TestDatasetDownloadHandler(GeoNodeBaseTestSupport): + def setUp(self): + user = get_user_model().objects.first() + request = RequestFactory().get("http://test_url.com") + request.user = user + self.dataset = create_single_dataset("test_dataset_for_download") + self.sut = DatasetDownloadHandler(request, self.dataset.alternate) + + def test_download_url_without_original_link(self): + expected_url = reverse("dataset_download", args=[self.dataset.alternate]) + self.assertEqual(expected_url, self.sut.download_url) + + def test_download_url_with_original_link(self): + Link.objects.update_or_create( + resource=self.dataset.resourcebase_ptr, + url="https://custom_dowonload_url.com", + defaults=dict( + extension="zip", + name="Original Dataset", + mime="application/octet-stream", + link_type="original", + ), + ) + expected_url = "https://custom_dowonload_url.com" + self.assertEqual(expected_url, self.sut.download_url) + + def test_get_resource_exists(self): + self.assertIsNotNone(self.sut.get_resource()) + + def test_process_dowload(self): + response = self.sut.get_download_response() + self.assertIsNotNone(response) + + +class DummyDownloadHandler(DatasetDownloadHandler): + def get_download_response(self): + return HttpResponse(content=b"abcsfd2") + + +class TestCustomDownloadHandler(GeoNodeBaseTestSupport): + @override_settings(DEFAULT_DATASET_DOWNLOAD_HANDLER="geonode.layers.tests.DummyDownloadHandler") + def test_download_custom_handler(self): + clear_dataset_download_handlers() + dataset = create_single_dataset("test_custom_download_dataset") + url = reverse("dataset_download", args=[dataset.alternate]) + self.client.login(username="admin", password="admin") + response = self.client.get(url) + self.assertTrue(response.status_code == 200) + self.assertEqual(response.content, b"abcsfd2") diff --git a/geonode/layers/utils.py b/geonode/layers/utils.py index 57b53fc2092..164d07b8562 100644 --- a/geonode/layers/utils.py +++ b/geonode/layers/utils.py @@ -591,3 +591,37 @@ def is_sld_upload_only(request): def mdata_search_by_type(request, filetype): files = list({v.name for k, v in request.FILES.items()}) return len(files) == 1 and all([filetype in f for f in files]) + + +default_dataset_download_handler = None +dataset_download_handler_list = [] + + +def get_dataset_download_handlers(): + if not dataset_download_handler_list and getattr(settings, "DATASET_DOWNLOAD_HANDLERS", None): + dataset_download_handler_list.append(import_string(settings.DATASET_DOWNLOAD_HANDLERS[0])) + + return dataset_download_handler_list + + +def get_default_dataset_download_handler(): + global default_dataset_download_handler + if not default_dataset_download_handler and getattr(settings, "DEFAULT_DATASET_DOWNLOAD_HANDLER", None): + default_dataset_download_handler = import_string(settings.DEFAULT_DATASET_DOWNLOAD_HANDLER) + + return default_dataset_download_handler + + +def set_default_dataset_download_handler(handler): + global default_dataset_download_handler + handler_module = import_string(handler) + if handler_module not in dataset_download_handler_list: + dataset_download_handler_list.append(handler_module) + + default_dataset_download_handler = handler_module + + +def clear_dataset_download_handlers(): + global default_dataset_download_handler + dataset_download_handler_list.clear() + default_dataset_download_handler = None diff --git a/geonode/layers/views.py b/geonode/layers/views.py index d9b08da7f97..3aa3bb91ff8 100644 --- a/geonode/layers/views.py +++ b/geonode/layers/views.py @@ -59,7 +59,12 @@ from geonode.decorators import check_keyword_write_perms from geonode.layers.forms import DatasetForm, DatasetTimeSerieForm, LayerAttributeForm, NewLayerUploadForm from geonode.layers.models import Dataset, Attribute -from geonode.layers.utils import is_sld_upload_only, is_xml_upload_only, validate_input_source +from geonode.layers.utils import ( + is_sld_upload_only, + is_xml_upload_only, + get_default_dataset_download_handler, + validate_input_source, +) from geonode.services.models import Service from geonode.base import register_event from geonode.monitoring.models import EventType @@ -69,7 +74,6 @@ from geonode.utils import check_ogc_backend, llbbox_to_mercator, resolve_object, mkdtemp from geonode.geoserver.helpers import ogc_server_settings, select_relevant_files, write_uploaded_files_to_disk from geonode.geoserver.security import set_geowebcache_invalidate_cache -from django.utils.module_loading import import_string if check_ogc_backend(geoserver.BACKEND_PACKAGE): from geonode.geoserver.helpers import gs_catalog @@ -733,8 +737,8 @@ def dataset_metadata_advanced(request, layername): @csrf_exempt def dataset_download(request, layername): - DownloadHandler = import_string(settings.DATASET_DOWNLOAD_HANDLER) - return DownloadHandler(request, layername).get_download_response() + handler = get_default_dataset_download_handler() + return handler(request, layername).get_download_response() @login_required diff --git a/geonode/security/permissions.py b/geonode/security/permissions.py index 0c3ddac2147..4d8b2e0bf1f 100644 --- a/geonode/security/permissions.py +++ b/geonode/security/permissions.py @@ -571,9 +571,9 @@ def compact(self): } ) - json["users"] = user_perms - json["organizations"] = organization_perms - json["groups"] = group_perms + json["users"] = sorted(user_perms, key=lambda x: x["id"], reverse=True) + json["organizations"] = sorted(organization_perms, key=lambda x: x["id"], reverse=True) + json["groups"] = sorted(group_perms, key=lambda x: x["id"], reverse=True) return json.copy() diff --git a/geonode/security/tests.py b/geonode/security/tests.py index 52b43a38551..f80a0ef8a52 100644 --- a/geonode/security/tests.py +++ b/geonode/security/tests.py @@ -2480,7 +2480,9 @@ def test_user_with_view_perms(self): # setting the view permissions url = reverse(_case["url"], kwargs={"pk": _case["resource"].pk}) - _case["resource"].set_permissions({"users": {self.marty.username: ["base.view_resourcebase"]}}) + _case["resource"].set_permissions( + {"users": {self.marty.username: ["base.view_resourcebase", "base.download_resourcebase"]}} + ) # calling the api self.client.force_login(self.marty) result = self.client.get(url) diff --git a/geonode/settings.py b/geonode/settings.py index 48194eaacf9..eae71d843fa 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -1343,9 +1343,6 @@ "Zipped All Files", ] - -DISPLAY_ORIGINAL_DATASET_LINK = ast.literal_eval(os.getenv("DISPLAY_ORIGINAL_DATASET_LINK", "True")) - ACCOUNT_NOTIFY_ON_PASSWORD_CHANGE = ast.literal_eval(os.getenv("ACCOUNT_NOTIFY_ON_PASSWORD_CHANGE", "False")) TASTYPIE_DEFAULT_FORMATS = ["json"] @@ -2361,4 +2358,6 @@ def get_geonode_catalogue_service(): {"class": "geonode.facets.providers.thesaurus.ThesaurusFacetProvider", "config": {"type": "select"}}, ] -DATASET_DOWNLOAD_HANDLER = os.getenv("DATASET_DOWNLOAD_HANDLER", "geonode.resource.download_handler.DownloadHandler") +DEFAULT_DATASET_DOWNLOAD_HANDLER = "geonode.layers.download_handler.DatasetDownloadHandler" + +DATASET_DOWNLOAD_HANDLERS = ast.literal_eval(os.getenv("DATASET_DOWNLOAD_HANDLERS", "[]")) diff --git a/geonode/tests/smoke.py b/geonode/tests/smoke.py index 8b094310722..1317fd8cc36 100644 --- a/geonode/tests/smoke.py +++ b/geonode/tests/smoke.py @@ -19,9 +19,7 @@ from unittest import TestCase -from django.http import HttpResponse from geonode.base.populate_test_data import create_single_dataset -from geonode.resource.download_handler import DownloadHandler from geonode.resource.utils import call_storers from geonode.tests.base import GeoNodeBaseTestSupport @@ -336,21 +334,3 @@ def dummy_metadata_storer2(dataset, custom): if custom.get("second-stage", None): for key, value in custom["second-stage"].items(): setattr(dataset, key, value) - - -class DummyDownloadManager(DownloadHandler): - def get_download_response(self): - return HttpResponse(content=b"abcsfd2") - - -class TestDownloadManager(GeoNodeBaseTestSupport): - def setUp(self): - self.sut = DownloadHandler - - @override_settings(DATASET_DOWNLOAD_HANDLER="geonode.tests.smoke.DummyDownloadManager") - def test_download_handler(self): - dataset = create_single_dataset("test_dataset") - url = reverse("dataset_download", args=[dataset.alternate]) - response = self.client.get(url) - self.assertTrue(response.status_code == 200) - self.assertEqual(response.content, b"abcsfd2") diff --git a/geonode/utils.py b/geonode/utils.py index 694962132f2..26feb074218 100755 --- a/geonode/utils.py +++ b/geonode/utils.py @@ -1411,10 +1411,7 @@ def get_legend_url( def set_resource_default_links(instance, layer, prune=False, **kwargs): from geonode.base.models import Link - from django.urls import reverse from django.utils.translation import ugettext - from geonode.layers.models import Dataset - from geonode.documents.models import Document # Prune old links if prune: @@ -1481,32 +1478,6 @@ def set_resource_default_links(instance, layer, prune=False, **kwargs): logger.exception(e) bbox = instance.bbox_string - # Create Raw Data download link - if settings.DISPLAY_ORIGINAL_DATASET_LINK: - logger.debug(" -- Resource Links[Create Raw Data download link]...") - if isinstance(instance, Dataset): - download_url = build_absolute_uri(reverse("dataset_download", args=(instance.alternate,))) - elif isinstance(instance, Document): - download_url = build_absolute_uri(reverse("document_download", args=(instance.id,))) - else: - download_url = None - - while Link.objects.filter(resource=instance.resourcebase_ptr, link_type="original").exists(): - Link.objects.filter(resource=instance.resourcebase_ptr, link_type="original").delete() - Link.objects.update_or_create( - resource=instance.resourcebase_ptr, - url=download_url, - defaults=dict( - extension="zip", - name="Original Dataset", - mime="application/octet-stream", - link_type="original", - ), - ) - logger.debug(" -- Resource Links[Create Raw Data download link]...done!") - else: - Link.objects.filter(resource=instance.resourcebase_ptr, name="Original Dataset").delete() - # Set download links for WMS, WCS or WFS and KML logger.debug(" -- Resource Links[Set download links for WMS, WCS or WFS and KML]...") instance_ows_url = f"{instance.ows_url}?" if instance.ows_url else f"{ogc_server_settings.public_url}ows?" From 04837e7449bacfa8f5d590c21a256408085da939 Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Mon, 11 Sep 2023 11:54:55 +0200 Subject: [PATCH 149/330] Add args and kwargs in resources API (#11459) Co-authored-by: Giovanni Allegri --- geonode/base/api/views.py | 34 +++++++++++++++++----------------- geonode/documents/api/views.py | 2 +- geonode/layers/api/views.py | 10 +++++----- geonode/maps/api/views.py | 4 ++-- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/geonode/base/api/views.py b/geonode/base/api/views.py index ae81b395883..fbbd2faec29 100644 --- a/geonode/base/api/views.py +++ b/geonode/base/api/views.py @@ -378,7 +378,7 @@ def _filtered(self, request, filter): description="API endpoint allowing to retrieve the approved Resources.", ) @action(detail=False, methods=["get"]) - def approved(self, request): + def approved(self, request, *args, **kwargs): return self._filtered(request, {"is_approved": True}) @extend_schema( @@ -387,7 +387,7 @@ def approved(self, request): description="API endpoint allowing to retrieve the published Resources.", ) @action(detail=False, methods=["get"]) - def published(self, request): + def published(self, request, *args, **kwargs): return self._filtered(request, {"is_published": True}) @extend_schema( @@ -396,7 +396,7 @@ def published(self, request): description="API endpoint allowing to retrieve the featured Resources.", ) @action(detail=False, methods=["get"]) - def featured(self, request): + def featured(self, request, *args, **kwargs): return self._filtered(request, {"featured": True}) @extend_schema( @@ -411,7 +411,7 @@ def featured(self, request): IsAuthenticated, ], ) - def favorites(self, request, pk=None): + def favorites(self, request, pk=None, *args, **kwargs): paginator = GeoNodeApiPagination() paginator.page_size = request.GET.get("page_size", 10) favorites = Favorite.objects.favorites_for_user(user=request.user) @@ -425,7 +425,7 @@ def favorites(self, request, pk=None): description="API endpoint allowing to retrieve the favorite Resources.", ) @action(detail=True, methods=["post", "delete"], permission_classes=[IsAuthenticated]) - def favorite(self, request, pk=None): + def favorite(self, request, pk=None, *args, **kwargs): resource = self.get_object() user = request.user @@ -476,7 +476,7 @@ def favorite(self, request, pk=None): """, ) @action(detail=False, methods=["get"]) - def resource_types(self, request): + def resource_types(self, request, *args, **kwargs): def _to_compact_perms_list( allowed_perms: dict, resource_type: str, resource_subtype: str, compact_perms_labels: dict = {} ) -> list: @@ -583,7 +583,7 @@ def _to_compact_perms_list( methods=["get", "put", "patch", "delete"], permission_classes=[IsAuthenticated], ) - def resource_service_permissions(self, request, pk): + def resource_service_permissions(self, request, pk, *args, **kwargs): """Instructs the Async dispatcher to execute a 'DELETE' or 'UPDATE' on the permissions of a valid 'uuid' - GET input_params: { @@ -720,7 +720,7 @@ def resource_service_permissions(self, request, pk): methods=["post"], permission_classes=[IsAuthenticated, UserHasPerms(perms_dict={"default": {"POST": ["base.add_resourcebase"]}})], ) - def set_thumbnail_from_bbox(self, request, resource_id): + def set_thumbnail_from_bbox(self, request, resource_id, *args, **kwargs): import traceback from django.utils.datastructures import MultiValueDictKeyError @@ -778,7 +778,7 @@ def set_thumbnail_from_bbox(self, request, resource_id): methods=["post"], permission_classes=[IsAuthenticated], ) - def resource_service_ingest(self, request, resource_type: str = None): + def resource_service_ingest(self, request, resource_type: str = None, *args, **kwargs): """Instructs the Async dispatcher to execute a 'INGEST' operation - POST input_params: { @@ -879,7 +879,7 @@ def resource_service_ingest(self, request, resource_type: str = None): methods=["post"], permission_classes=[IsAuthenticated, UserHasPerms(perms_dict={"default": {"POST": ["base.add_resourcebase"]}})], ) - def resource_service_create(self, request, resource_type: str = None): + def resource_service_create(self, request, resource_type: str = None, *args, **kwargs): """Instructs the Async dispatcher to execute a 'CREATE' operation **WARNING**: This will create an empty dataset; if you need to upload a resource to GeoNode, consider using the endpoint "ingest" instead @@ -981,7 +981,7 @@ def resource_service_create(self, request, resource_type: str = None): methods=["delete"], permission_classes=[IsAuthenticated, UserHasPerms], ) - def resource_service_delete(self, request, pk): + def resource_service_delete(self, request, pk, *args, **kwargs): """Instructs the Async dispatcher to execute a 'DELETE' operation over a valid 'uuid' - DELETE input_params: { @@ -1065,7 +1065,7 @@ def resource_service_delete(self, request, pk): methods=["put"], permission_classes=[IsAuthenticated, UserHasPerms], ) - def resource_service_update(self, request, pk): + def resource_service_update(self, request, pk, *args, **kwargs): """Instructs the Async dispatcher to execute a 'UPDATE' operation over a valid 'uuid' - PUT input_params: { @@ -1195,7 +1195,7 @@ def resource_service_update(self, request, pk): ), ], ) - def resource_service_copy(self, request, pk): + def resource_service_copy(self, request, pk, *args, **kwargs): """Instructs the Async dispatcher to execute a 'COPY' operation over a valid 'pk' - PUT input_params: { @@ -1300,7 +1300,7 @@ def resource_service_copy(self, request, pk): UserHasPerms(perms_dict={"default": {"POST": ["base.add_resourcebase"]}}), ], ) - def ratings(self, request, pk): + def ratings(self, request, pk, *args, **kwargs): resource = get_object_or_404(ResourceBase, pk=pk) resource = resource.get_real_instance() ct = ContentType.objects.get_for_model(resource) @@ -1337,7 +1337,7 @@ def ratings(self, request, pk): permission_classes=[IsAuthenticated, UserHasPerms], parser_classes=[JSONParser, MultiPartParser], ) - def set_thumbnail(self, request, pk): + def set_thumbnail(self, request, pk, *args, **kwargs): resource = get_object_or_404(ResourceBase, pk=pk) if not request.data.get("file"): @@ -1397,7 +1397,7 @@ def set_thumbnail(self, request, pk): url_path=r"extra_metadata", # noqa url_name="extra-metadata", ) - def extra_metadata(self, request, pk): + def extra_metadata(self, request, pk, *args, **kwargs): _obj = get_object_or_404(ResourceBase, pk=pk) if request.method == "GET": @@ -1488,7 +1488,7 @@ def _get_request_params(self, request, encode=False): url_path=r"linked_resources", # noqa url_name="linked_resources", ) - def linked_resources(self, request, pk): + def linked_resources(self, request, pk, *args, **kwargs): try: """ To let the API be able to filter the linked result, we cannot rely on the DynamicFilterBackend diff --git a/geonode/documents/api/views.py b/geonode/documents/api/views.py index 720cb7639ba..dd15a23e363 100644 --- a/geonode/documents/api/views.py +++ b/geonode/documents/api/views.py @@ -145,7 +145,7 @@ def perform_create(self, serializer): description="API endpoint allowing to retrieve the DocumentResourceLink(s).", ) @action(detail=True, methods=["get"]) - def linked_resources(self, request, pk=None): + def linked_resources(self, request, pk=None, *args, **kwargs): document = self.get_object() resources_id = document.links.all().values("object_id") resources = ResourceBase.objects.filter(id__in=resources_id) diff --git a/geonode/layers/api/views.py b/geonode/layers/api/views.py index 3ad985c7781..218216a17bd 100644 --- a/geonode/layers/api/views.py +++ b/geonode/layers/api/views.py @@ -100,7 +100,7 @@ def get_serializer_class(self): UserHasPerms(perms_dict={"default": {"PUT": ["base.change_resourcebase_metadata"]}}), ], ) - def metadata(self, request, pk=None): + def metadata(self, request, pk=None, *args, **kwargs): """ Endpoint to upload ISO metadata Usage Example: @@ -165,7 +165,7 @@ def metadata(self, request, pk=None): description="API endpoint allowing to retrieve the MapLayers list.", ) @action(detail=True, methods=["get"]) - def maplayers(self, request, pk=None): + def maplayers(self, request, pk=None, *args, **kwargs): dataset = self.get_object() resources = dataset.maplayers return Response(SimpleMapLayerSerializer(many=True).to_representation(resources)) @@ -176,7 +176,7 @@ def maplayers(self, request, pk=None): description="API endpoint allowing to retrieve maps using the dataset.", ) @action(detail=True, methods=["get"]) - def maps(self, request, pk=None): + def maps(self, request, pk=None, *args, **kwargs): dataset = self.get_object() resources = dataset.maps return Response(SimpleMapSerializer(many=True).to_representation(resources)) @@ -208,7 +208,7 @@ def maps(self, request, pk=None): methods=["patch"], serializer_class=DatasetReplaceAppendSerializer, ) - def replace(self, request, dataset_id=None): + def replace(self, request, dataset_id=None, *args, **kwargs): """ Edpoint for replace data to an existing layer """ @@ -241,7 +241,7 @@ def replace(self, request, dataset_id=None): methods=["patch"], serializer_class=DatasetReplaceAppendSerializer, ) - def append(self, request, dataset_id=None): + def append(self, request, dataset_id=None, *args, **kwargs): """ Edpoint for replace data to an existing layer """ diff --git a/geonode/maps/api/views.py b/geonode/maps/api/views.py index ae127f8fffb..aec88e47fbe 100644 --- a/geonode/maps/api/views.py +++ b/geonode/maps/api/views.py @@ -96,7 +96,7 @@ def create(self, request, *args, **kwargs): description="API endpoint allowing to retrieve the MapLayers list.", ) @action(detail=True, methods=["get"]) - def maplayers(self, request, pk=None): + def maplayers(self, request, pk=None, *args, **kwargs): map = self.get_object() resources = map.maplayers return Response(MapLayerSerializer(embed=True, many=True).to_representation(resources)) @@ -107,7 +107,7 @@ def maplayers(self, request, pk=None): description="API endpoint allowing to retrieve the local MapLayers.", ) @action(detail=True, methods=["get"]) - def datasets(self, request, pk=None): + def datasets(self, request, pk=None, *args, **kwargs): map = self.get_object() resources = map.datasets return Response(DatasetSerializer(embed=True, many=True).to_representation(resources)) From c37b981f9b053efd4460fb5e3d5512c9a2818493 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Mon, 11 Sep 2023 13:07:30 +0200 Subject: [PATCH 150/330] Update .clabot with Francisco Vicent (#11462) --- .clabot | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.clabot b/.clabot index 931c7559a78..6a4e514540c 100644 --- a/.clabot +++ b/.clabot @@ -74,6 +74,7 @@ "JohannesSchnell", "EHJ-52n", "MartinPontius", - "ahmdthr" + "ahmdthr", + "fvicent" ] } From 6b331fdd489df6bd522e9b63914083a22205da64 Mon Sep 17 00:00:00 2001 From: Francisco Vicent Date: Tue, 12 Sep 2023 05:41:23 -0300 Subject: [PATCH 151/330] Remove `ProfileCreationForm.clean_username()` (#11433) Co-authored-by: Giovanni Allegri --- geonode/people/forms.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/geonode/people/forms.py b/geonode/people/forms.py index 0f2af9ac03f..abebe9c41a7 100644 --- a/geonode/people/forms.py +++ b/geonode/people/forms.py @@ -43,19 +43,6 @@ class Meta: model = get_user_model() fields = ("username",) - def clean_username(self): - # Since User.username is unique, this check is redundant, - # but it sets a nicer error message than the ORM. See #13147. - username = self.cleaned_data["username"] - try: - get_user_model().objects.get(username=username) - except get_user_model().DoesNotExist: - return username - raise forms.ValidationError( - self.error_messages["duplicate_username"], - code="duplicate_username", - ) - class ProfileChangeForm(UserChangeForm): class Meta: From 233567b3bd5acc5eeea2cb59fbdfd1016f02a15f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Sep 2023 10:45:02 +0200 Subject: [PATCH 152/330] Bump django from 3.2.20 to 3.2.21 (#11458) Bumps [django](https://github.com/django/django) from 3.2.20 to 3.2.21. - [Commits](https://github.com/django/django/compare/3.2.20...3.2.21) --- updated-dependencies: - dependency-name: django dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Giovanni Allegri --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 05db52f8a43..153ce38977e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ Pillow==10.0.0 lxml==4.9.3 psycopg2==2.9.7 -Django==3.2.20 +Django==3.2.21 # Other amqp==5.1.1 From 20fb68f5a22b979857008f969772dc5d76fd44b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Sep 2023 10:45:45 +0200 Subject: [PATCH 153/330] Bump boto3 from 1.28.32 to 1.28.44 (#11452) Bumps [boto3](https://github.com/boto/boto3) from 1.28.32 to 1.28.44. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.28.32...1.28.44) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 153ce38977e..b901f2c8d36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 -boto3==1.28.32 +boto3==1.28.44 # Django Caches python-memcached<=1.59 From f7bce2a82fe13f7c19e4a175eb57d3b5a275c450 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Sep 2023 10:46:33 +0200 Subject: [PATCH 154/330] Bump black from 23.7.0 to 23.9.1 (#11470) Bumps [black](https://github.com/psf/black) from 23.7.0 to 23.9.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/23.7.0...23.9.1) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b901f2c8d36..dade467396b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -153,7 +153,7 @@ invoke==2.2.0 coverage==7.3.0 requests-toolbelt==1.0.0 flake8==6.1.0 -black==23.7.0 +black==23.9.1 pytest==7.4.0 pytest-bdd==6.1.1 splinter==0.19.0 From bb536929e1f6272860731c6f2a17edcf028ae0c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Sep 2023 10:47:38 +0200 Subject: [PATCH 155/330] Bump pytest from 7.4.0 to 7.4.2 (#11451) Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.4.0 to 7.4.2. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/7.4.0...7.4.2) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index dade467396b..dad5daf3de5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -154,7 +154,7 @@ coverage==7.3.0 requests-toolbelt==1.0.0 flake8==6.1.0 black==23.9.1 -pytest==7.4.0 +pytest==7.4.2 pytest-bdd==6.1.1 splinter==0.19.0 pytest-splinter==3.3.2 From 377787ee324ff115a0fe691317d2dd6cc529bd56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Sep 2023 10:48:25 +0200 Subject: [PATCH 156/330] Bump kombu from 5.3.1 to 5.3.2 (#11436) Bumps [kombu](https://github.com/celery/kombu) from 5.3.1 to 5.3.2. - [Release notes](https://github.com/celery/kombu/releases) - [Changelog](https://github.com/celery/kombu/blob/main/Changelog.rst) - [Commits](https://github.com/celery/kombu/compare/v5.3.1...v5.3.2) --- updated-dependencies: - dependency-name: kombu dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Giovanni Allegri --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index dad5daf3de5..c7479462401 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ Paver==1.3.4 python-slugify==8.0.1 decorator==5.1.1 celery==5.3.1 -kombu==5.3.1 +kombu==5.3.2 vine==5.0.0 tqdm==4.66.1 Deprecated==1.2.14 From 006332bd7f86667c414031830aa64d911e4b71e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Sep 2023 10:50:05 +0200 Subject: [PATCH 157/330] Bump boto3 from 1.28.32 to 1.28.45 (#11469) Bumps [boto3](https://github.com/boto/boto3) from 1.28.32 to 1.28.45. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.28.32...1.28.45) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c7479462401..c6cd3b7d6c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 -boto3==1.28.44 +boto3==1.28.45 # Django Caches python-memcached<=1.59 From 9626fbde3971ea2476eaabd15c2b3c7af33aa722 Mon Sep 17 00:00:00 2001 From: EHJ-52n Date: Wed, 13 Sep 2023 11:42:53 +0200 Subject: [PATCH 158/330] Add own service for memcached (#8818) Goal: one service per container - add own service for memcached - remove memcached from geonode image - add MEMCACHED_OPTIONS variable to env files - enable memcached by default --- .env.sample | 6 +++++- .env_dev | 6 +++++- .env_local | 6 +++++- .env_test | 6 +++++- docker-compose-test.yml | 13 +++++++++++++ docker-compose.yml | 13 +++++++++++++ entrypoint.sh | 1 - scripts/docker/base/ubuntu/Dockerfile | 5 ++--- 8 files changed, 48 insertions(+), 8 deletions(-) diff --git a/.env.sample b/.env.sample index b74e48d371c..47ed2e0c24e 100644 --- a/.env.sample +++ b/.env.sample @@ -177,9 +177,13 @@ CACHE_BUSTING_STATIC_ENABLED=False MEMCACHED_ENABLED=False MEMCACHED_BACKEND=django.core.cache.backends.memcached.MemcachedCache -MEMCACHED_LOCATION=127.0.0.1:11211 +MEMCACHED_LOCATION=memcached:11211 MEMCACHED_LOCK_EXPIRE=3600 MEMCACHED_LOCK_TIMEOUT=10 +# +# Options for memcached binary, e.g. -vvv to log all requests and cache hits +# +MEMCACHED_OPTIONS= MAX_DOCUMENT_SIZE=200 CLIENT_RESULTS_LIMIT=5 diff --git a/.env_dev b/.env_dev index 6119f68ebd6..c5ef4c58b63 100644 --- a/.env_dev +++ b/.env_dev @@ -177,9 +177,13 @@ CACHE_BUSTING_STATIC_ENABLED=False MEMCACHED_ENABLED=False MEMCACHED_BACKEND=django.core.cache.backends.memcached.MemcachedCache -MEMCACHED_LOCATION=127.0.0.1:11211 +MEMCACHED_LOCATION=memcached:11211 MEMCACHED_LOCK_EXPIRE=3600 MEMCACHED_LOCK_TIMEOUT=10 +# +# Options for memcached binary, e.g. -vvv to log all requests and cache hits +# +MEMCACHED_OPTIONS= MAX_DOCUMENT_SIZE=200 CLIENT_RESULTS_LIMIT=5 diff --git a/.env_local b/.env_local index 48cba183d81..492022d6d3d 100644 --- a/.env_local +++ b/.env_local @@ -177,9 +177,13 @@ CACHE_BUSTING_STATIC_ENABLED=False MEMCACHED_ENABLED=False MEMCACHED_BACKEND=django.core.cache.backends.memcached.MemcachedCache -MEMCACHED_LOCATION=127.0.0.1:11211 +MEMCACHED_LOCATION=memcached:11211 MEMCACHED_LOCK_EXPIRE=3600 MEMCACHED_LOCK_TIMEOUT=10 +# +# Options for memcached binary, e.g. -vvv to log all requests and cache hits +# +MEMCACHED_OPTIONS= MAX_DOCUMENT_SIZE=200 CLIENT_RESULTS_LIMIT=5 diff --git a/.env_test b/.env_test index fb1910d57b1..cfbc1098d97 100644 --- a/.env_test +++ b/.env_test @@ -186,9 +186,13 @@ CACHE_BUSTING_STATIC_ENABLED=False MEMCACHED_ENABLED=False MEMCACHED_BACKEND=django.core.cache.backends.memcached.MemcachedCache -MEMCACHED_LOCATION=127.0.0.1:11211 +MEMCACHED_LOCATION=memcached:11211 MEMCACHED_LOCK_EXPIRE=3600 MEMCACHED_LOCK_TIMEOUT=10 +# +# Options for memcached binary, e.g. -vvv to log all requests and cache hits +# +MEMCACHED_OPTIONS= MAX_DOCUMENT_SIZE=200 CLIENT_RESULTS_LIMIT=5 diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 762183a5eac..14a4b999fab 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -68,6 +68,19 @@ services: - statics:/mnt/volumes/statics restart: unless-stopped + # memcached service + memcached: + image: memcached:alpine + container_name: memcached4${COMPOSE_PROJECT_NAME} + command: memcached ${MEMCACHED_OPTIONS} + restart: on-failure + healthcheck: + test: nc -z 127.0.0.1 11211 + interval: 30s + timeout: 30s + retries: 5 + start_period: 30s + # Gets and installs letsencrypt certificates letsencrypt: image: geonode/letsencrypt:latest diff --git a/docker-compose.yml b/docker-compose.yml index 853231d824a..36d6d196fe2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,19 @@ services: entrypoint: ["/usr/src/geonode/entrypoint.sh"] command: "celery-cmd" + # memcached service + memcached: + image: memcached:alpine + container_name: memcached4${COMPOSE_PROJECT_NAME} + command: memcached ${MEMCACHED_OPTIONS} + restart: on-failure + healthcheck: + test: nc -z 127.0.0.1 11211 + interval: 30s + timeout: 30s + retries: 5 + start_period: 30s + # Nginx is serving django static and media files and proxies to django and geonode geonode: image: geonode/nginx:1.25.1 diff --git a/entrypoint.sh b/entrypoint.sh index 6bb062b910e..bcb925467f7 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -16,7 +16,6 @@ invoke () { # Start cron && memcached services service cron restart -service memcached restart echo $"\n\n\n" echo "-----------------------------------------------------" diff --git a/scripts/docker/base/ubuntu/Dockerfile b/scripts/docker/base/ubuntu/Dockerfile index 13ca38d20c4..8e406212798 100644 --- a/scripts/docker/base/ubuntu/Dockerfile +++ b/scripts/docker/base/ubuntu/Dockerfile @@ -5,7 +5,7 @@ RUN mkdir -p /usr/src/geonode ## Enable postgresql-client-15 RUN apt-get update -y && apt-get install curl wget unzip gnupg2 -y RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - -# will install python3.10 +# will install python3.10 RUN apt-get install lsb-core -y RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" |tee /etc/apt/sources.list.d/pgdg.list @@ -32,8 +32,7 @@ RUN pip3 install uwsgi \ && pip install pip --upgrade \ && pip install pygdal==$(gdal-config --version).* flower==0.9.4 -# Activate "memcached" -RUN apt-get install -y memcached +# Install "sherlock" to be used with "memcached" RUN pip install sherlock # Cleanup apt update lists From e0afec3558bedffa192c38957b1b3ab7f6691b25 Mon Sep 17 00:00:00 2001 From: afabiani Date: Wed, 13 Sep 2023 11:51:45 +0200 Subject: [PATCH 159/330] - Align setup.cfg to requirements.txt --- requirements.txt | 2 +- setup.cfg | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index c6cd3b7d6c4..f7849c48547 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ Paver==1.3.4 python-slugify==8.0.1 decorator==5.1.1 celery==5.3.1 -kombu==5.3.2 +kombu==5.3.1 vine==5.0.0 tqdm==4.66.1 Deprecated==1.2.14 diff --git a/setup.cfg b/setup.cfg index 41a528efbc4..6107d4414fa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,7 +28,7 @@ install_requires = Pillow==10.0.0 lxml==4.9.3 psycopg2==2.9.7 - Django==3.2.20 + Django==3.2.21 # Other amqp==5.1.1 @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 - boto3==1.28.32 + boto3==1.28.45 # Django Caches python-memcached<=1.59 @@ -178,8 +178,8 @@ install_requires = coverage==7.3.0 requests-toolbelt==1.0.0 flake8==6.1.0 - black==23.7.0 - pytest==7.4.0 + black==23.9.1 + pytest==7.4.2 pytest-bdd==6.1.1 splinter==0.19.0 pytest-splinter==3.3.2 From 8f1c00ec0172daa9ae9d038281990fd358aaad4e Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Wed, 13 Sep 2023 12:02:23 +0200 Subject: [PATCH 160/330] fix: requirements.txt to reduce vulnerabilities (#11445) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-DJANGO-5880505 Co-authored-by: snyk-bot From e13a811e123dc058583e20b0743fe1a99081b51b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 13:02:06 +0200 Subject: [PATCH 161/330] Bump celery from 5.3.1 to 5.3.4 (#11437) * Bump celery from 5.3.1 to 5.3.4 Bumps [celery](https://github.com/celery/celery) from 5.3.1 to 5.3.4. - [Release notes](https://github.com/celery/celery/releases) - [Changelog](https://github.com/celery/celery/blob/main/Changelog.rst) - [Commits](https://github.com/celery/celery/compare/v5.3.1...v5.3.4) --- updated-dependencies: - dependency-name: celery dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 4 ++-- setup.cfg | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index f7849c48547..b24ab38f9d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,8 +14,8 @@ urllib3==1.26.15 Paver==1.3.4 python-slugify==8.0.1 decorator==5.1.1 -celery==5.3.1 -kombu==5.3.1 +celery==5.3.4 +kombu==5.3.2 vine==5.0.0 tqdm==4.66.1 Deprecated==1.2.14 diff --git a/setup.cfg b/setup.cfg index 6107d4414fa..19dd2ae80af 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,8 +40,8 @@ install_requires = Paver==1.3.4 python-slugify==8.0.1 decorator==5.1.1 - celery==5.3.1 - kombu==5.3.1 + celery==5.3.4 + kombu==5.3.2 vine==5.0.0 tqdm==4.66.1 Deprecated==1.2.14 From c8e9789cdb89127fe8f267e4523f694b68e2e6df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 14:02:52 +0200 Subject: [PATCH 162/330] Bump pytz from 2023.3 to 2023.3.post1 (#11457) * Bump pytz from 2023.3 to 2023.3.post1 Bumps [pytz](https://github.com/stub42/pytz) from 2023.3 to 2023.3.post1. - [Commits](https://github.com/stub42/pytz/compare/release_2023.3...release_2023.3.post1) --- updated-dependencies: - dependency-name: pytz dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Giovanni Allegri Co-authored-by: Alessio Fabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index b24ab38f9d1..2377ee48557 100644 --- a/requirements.txt +++ b/requirements.txt @@ -126,7 +126,7 @@ inflection>=0.4.0 jdcal==1.4.1 mock<6.0.0 python-dateutil==2.8.2 -pytz==2023.3 +pytz==2023.3.post1 requests==2.31.0 timeout-decorator==0.5.0 pylibmc==1.6.3 diff --git a/setup.cfg b/setup.cfg index 19dd2ae80af..7d6c39d8356 100644 --- a/setup.cfg +++ b/setup.cfg @@ -151,7 +151,7 @@ install_requires = jdcal==1.4.1 mock<6.0.0 python-dateutil==2.8.2 - pytz==2023.3 + pytz==2023.3.post1 requests==2.31.0 timeout-decorator==0.5.0 pylibmc==1.6.3 From 3a7eed101ead74d6f3b4e9347f147f3319cf2d40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 14:03:11 +0200 Subject: [PATCH 163/330] Bump coverage from 7.3.0 to 7.3.1 (#11456) * Bump coverage from 7.3.0 to 7.3.1 Bumps [coverage](https://github.com/nedbat/coveragepy) from 7.3.0 to 7.3.1. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.3.0...7.3.1) --- updated-dependencies: - dependency-name: coverage dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2377ee48557..fd8ce3257f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -150,7 +150,7 @@ docker==6.1.3 invoke==2.2.0 # tests -coverage==7.3.0 +coverage==7.3.1 requests-toolbelt==1.0.0 flake8==6.1.0 black==23.9.1 diff --git a/setup.cfg b/setup.cfg index 7d6c39d8356..60335f5a9ec 100644 --- a/setup.cfg +++ b/setup.cfg @@ -175,7 +175,7 @@ install_requires = invoke==2.2.0 # tests - coverage==7.3.0 + coverage==7.3.1 requests-toolbelt==1.0.0 flake8==6.1.0 black==23.9.1 From 1f2c297a1dfb6c68c23d96edffef920a0dda3fc8 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Wed, 13 Sep 2023 14:29:21 +0200 Subject: [PATCH 164/330] restore loopback port for memcached in local env files (#11478) --- .env_dev | 2 +- .env_local | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env_dev b/.env_dev index c5ef4c58b63..93b0c0e2f5c 100644 --- a/.env_dev +++ b/.env_dev @@ -177,7 +177,7 @@ CACHE_BUSTING_STATIC_ENABLED=False MEMCACHED_ENABLED=False MEMCACHED_BACKEND=django.core.cache.backends.memcached.MemcachedCache -MEMCACHED_LOCATION=memcached:11211 +MEMCACHED_LOCATION=127.0.0.1:11211 MEMCACHED_LOCK_EXPIRE=3600 MEMCACHED_LOCK_TIMEOUT=10 # diff --git a/.env_local b/.env_local index 492022d6d3d..5083046741d 100644 --- a/.env_local +++ b/.env_local @@ -177,7 +177,7 @@ CACHE_BUSTING_STATIC_ENABLED=False MEMCACHED_ENABLED=False MEMCACHED_BACKEND=django.core.cache.backends.memcached.MemcachedCache -MEMCACHED_LOCATION=memcached:11211 +MEMCACHED_LOCATION=127.0.0.1:11211 MEMCACHED_LOCK_EXPIRE=3600 MEMCACHED_LOCK_TIMEOUT=10 # From d30176e9e47234629d77e66888ebcfb87f04007e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 14:56:40 +0200 Subject: [PATCH 165/330] Bump brotli from 1.0.9 to 1.1.0 (#11455) * Bump brotli from 1.0.9 to 1.1.0 Bumps [brotli](https://github.com/google/brotli) from 1.0.9 to 1.1.0. - [Release notes](https://github.com/google/brotli/releases) - [Changelog](https://github.com/google/brotli/blob/master/CHANGELOG.md) - [Commits](https://github.com/google/brotli/compare/v1.0.9...v1.1.0) --- updated-dependencies: - dependency-name: brotli dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Bump brotli from 1.0.9 to 1.1.0 Bumps [brotli](https://github.com/google/brotli) from 1.0.9 to 1.1.0. - [Release notes](https://github.com/google/brotli/releases) - [Changelog](https://github.com/google/brotli/blob/master/CHANGELOG.md) - [Commits](https://github.com/google/brotli/compare/v1.0.9...v1.1.0) --- updated-dependencies: - dependency-name: brotli dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index fd8ce3257f2..c8f46e0e022 100644 --- a/requirements.txt +++ b/requirements.txt @@ -118,7 +118,7 @@ boto3==1.28.45 # Django Caches python-memcached<=1.59 whitenoise==6.5.0 -Brotli==1.0.9 +Brotli==1.1.0 # Contribs xmltodict<0.13.1 diff --git a/setup.cfg b/setup.cfg index 60335f5a9ec..104ad92ad2f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -143,7 +143,7 @@ install_requires = # Django Caches python-memcached<=1.59 whitenoise==6.5.0 - Brotli==1.0.9 + Brotli==1.1.0 # Contribs xmltodict<0.13.1 From e3cd45eedba7ffdb8d112a6f36fd6dddb3b290e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 14:57:40 +0200 Subject: [PATCH 166/330] Bump django-widget-tweaks from 1.4.12 to 1.5.0 (#11420) * Bump django-widget-tweaks from 1.4.12 to 1.5.0 Bumps [django-widget-tweaks](https://github.com/jazzband/django-widget-tweaks) from 1.4.12 to 1.5.0. - [Release notes](https://github.com/jazzband/django-widget-tweaks/releases) - [Changelog](https://github.com/jazzband/django-widget-tweaks/blob/master/CHANGES.rst) - [Commits](https://github.com/jazzband/django-widget-tweaks/compare/1.4.12...1.5.0) --- updated-dependencies: - dependency-name: django-widget-tweaks dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Bump django-widget-tweaks from 1.4.12 to 1.5.0 Bumps [django-widget-tweaks](https://github.com/jazzband/django-widget-tweaks) from 1.4.12 to 1.5.0. - [Release notes](https://github.com/jazzband/django-widget-tweaks/releases) - [Changelog](https://github.com/jazzband/django-widget-tweaks/blob/master/CHANGES.rst) - [Commits](https://github.com/jazzband/django-widget-tweaks/compare/1.4.12...1.5.0) --- updated-dependencies: - dependency-name: django-widget-tweaks dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index c8f46e0e022..60e75c7a97d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,7 @@ django-tastypie<0.15.0 django-tinymce==3.6.1 django-grappelli==3.0.7 django-uuid-upload-path==1.0.0 -django-widget-tweaks==1.4.12 +django-widget-tweaks==1.5.0 django-sequences==2.8 oauthlib==3.2.2 pyopenssl==23.2.0 diff --git a/setup.cfg b/setup.cfg index 104ad92ad2f..9316c207ba8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -72,7 +72,7 @@ install_requires = django-tinymce==3.6.1 django-grappelli==3.0.7 django-uuid-upload-path==1.0.0 - django-widget-tweaks==1.4.12 + django-widget-tweaks==1.5.0 django-sequences==2.8 oauthlib==3.2.2 pyopenssl==23.2.0 From 4b49d62ea192e2d19c103e5221db781e2c62a93a Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Wed, 13 Sep 2023 15:01:42 +0200 Subject: [PATCH 167/330] =?UTF-8?q?[Fixes=20#11349]=20Keep=20into=20accoun?= =?UTF-8?q?t=20maplayers=20visibility=20and=20order=20when=20=E2=80=A6=20(?= =?UTF-8?q?#11443)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fixes #11349] Keep into account maplayers visibility and order when generating map thumbnails * [Fixes #11349] Fix black format * [Fixes #11349] Fix test * [Fixes #11349] remove typo --------- Co-authored-by: Giovanni Allegri --- geonode/thumbs/tests/test_unit.py | 8 ++++---- geonode/thumbs/thumbnails.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/geonode/thumbs/tests/test_unit.py b/geonode/thumbs/tests/test_unit.py index 49c8168d604..a00936b4ada 100644 --- a/geonode/thumbs/tests/test_unit.py +++ b/geonode/thumbs/tests/test_unit.py @@ -230,8 +230,8 @@ def test_datasets_locations_simple_map(self): [ [ settings.OGC_SERVER["default"]["LOCATION"], - ["geonode:Meteorite_Landings_from_NASA_Open_Data_Portal1", dataset.alternate], - ["test_style", "theaters_nyc"], + [dataset.alternate, "geonode:Meteorite_Landings_from_NASA_Open_Data_Portal1"], + ["theaters_nyc", "test_style"], ] ], ) @@ -255,9 +255,9 @@ def test_datasets_locations_composition_map_default_bbox(self): [ settings.GEOSERVER_LOCATION, [ - "rt_geologia.dbg_risorse_minerarie", - "geonode:Meteorite_Landings_from_NASA_Open_Data_Portal1", "geonode:theaters_nyc", + "geonode:Meteorite_Landings_from_NASA_Open_Data_Portal1", + "rt_geologia.dbg_risorse_minerarie", ], [], ] diff --git a/geonode/thumbs/thumbnails.py b/geonode/thumbs/thumbnails.py index a7c8e28a255..3006a5bf58f 100644 --- a/geonode/thumbs/thumbnails.py +++ b/geonode/thumbs/thumbnails.py @@ -271,7 +271,7 @@ def _datasets_locations( else: bbox = utils.transform_bbox(instance.bbox, target_crs) elif isinstance(instance, Map): - for map_dataset in instance.maplayers.iterator(): + for map_dataset in instance.maplayers.filter(visibility=True).order_by("order").iterator(): if not map_dataset.local and not map_dataset.ows_url: logger.warning( "Incorrectly defined remote dataset encountered (no OWS URL defined)." From 9e27bf3661a9485356d7f486c824ab7ef89d8641 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 15:13:49 +0200 Subject: [PATCH 168/330] Update setuptools requirement from <68.2.0,>=59.1.1 to >=59.1.1,<68.3.0 (#11454) * Update setuptools requirement from <68.2.0,>=59.1.1 to >=59.1.1,<68.3.0 Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v65.5.1...v68.2.0) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 60e75c7a97d..3f2ffef9439 100644 --- a/requirements.txt +++ b/requirements.txt @@ -159,7 +159,7 @@ pytest-bdd==6.1.1 splinter==0.19.0 pytest-splinter==3.3.2 pytest-django==4.5.2 -setuptools>=59.1.1,<68.2.0 +setuptools>=59.1.1,<68.3.0 pip==23.2.1 Twisted==22.10.0 pixelmatch==0.3.0 diff --git a/setup.cfg b/setup.cfg index 9316c207ba8..72e98f8a1d0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -184,7 +184,7 @@ install_requires = splinter==0.19.0 pytest-splinter==3.3.2 pytest-django==4.5.2 - setuptools>=59.1.1,<68.2.0 + setuptools>=59.1.1,<68.3.0 pip==23.2.1 Twisted==22.10.0 pixelmatch==0.3.0 From 08f7895189b823149768a31be0e1746ce070ee40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 15:14:32 +0200 Subject: [PATCH 169/330] Bump twisted from 22.10.0 to 23.8.0 (#11440) * Bump twisted from 22.10.0 to 23.8.0 Bumps [twisted](https://github.com/twisted/twisted) from 22.10.0 to 23.8.0. - [Release notes](https://github.com/twisted/twisted/releases) - [Changelog](https://github.com/twisted/twisted/blob/trunk/NEWS.rst) - [Commits](https://github.com/twisted/twisted/compare/twisted-22.10.0...twisted-23.8.0) --- updated-dependencies: - dependency-name: twisted dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Bump twisted from 22.10.0 to 23.8.0 Bumps [twisted](https://github.com/twisted/twisted) from 22.10.0 to 23.8.0. - [Release notes](https://github.com/twisted/twisted/releases) - [Changelog](https://github.com/twisted/twisted/blob/trunk/NEWS.rst) - [Commits](https://github.com/twisted/twisted/compare/twisted-22.10.0...twisted-23.8.0) --- updated-dependencies: - dependency-name: twisted dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3f2ffef9439..8551995c41f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -161,7 +161,7 @@ pytest-splinter==3.3.2 pytest-django==4.5.2 setuptools>=59.1.1,<68.3.0 pip==23.2.1 -Twisted==22.10.0 +Twisted==23.8.0 pixelmatch==0.3.0 factory-boy==3.3.0 flaky==3.7.0 diff --git a/setup.cfg b/setup.cfg index 72e98f8a1d0..fe58f6791ba 100644 --- a/setup.cfg +++ b/setup.cfg @@ -186,7 +186,7 @@ install_requires = pytest-django==4.5.2 setuptools>=59.1.1,<68.3.0 pip==23.2.1 - Twisted==22.10.0 + Twisted==23.8.0 pixelmatch==0.3.0 factory-boy==3.3.0 flaky==3.7.0 From 01d89c7d7af327d8b754859f38c69d9599e01881 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 15:15:57 +0200 Subject: [PATCH 170/330] Bump boto3 from 1.28.45 to 1.28.46 (#11473) * Bump boto3 from 1.28.45 to 1.28.46 Bumps [boto3](https://github.com/boto/boto3) from 1.28.45 to 1.28.46. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.28.45...1.28.46) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8551995c41f..e310a393018 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.13.2 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 -boto3==1.28.45 +boto3==1.28.46 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index fe58f6791ba..c2b6f433c83 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 - boto3==1.28.45 + boto3==1.28.46 # Django Caches python-memcached<=1.59 From 8d14e39d0720f1f725097f7d6c1fa6874c23517e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 15:18:37 +0200 Subject: [PATCH 171/330] Bump smart-open from 6.3.0 to 6.4.0 (#11449) * Bump smart-open from 6.3.0 to 6.4.0 Bumps [smart-open](https://github.com/piskvorky/smart_open) from 6.3.0 to 6.4.0. - [Release notes](https://github.com/piskvorky/smart_open/releases) - [Changelog](https://github.com/RaRe-Technologies/smart_open/blob/develop/CHANGELOG.md) - [Commits](https://github.com/piskvorky/smart_open/compare/v6.3.0...v6.4.0) --- updated-dependencies: - dependency-name: smart-open dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Bump smart-open from 6.3.0 to 6.4.0 Bumps [smart-open](https://github.com/piskvorky/smart_open) from 6.3.0 to 6.4.0. - [Release notes](https://github.com/piskvorky/smart_open/releases) - [Changelog](https://github.com/RaRe-Technologies/smart_open/blob/develop/CHANGELOG.md) - [Commits](https://github.com/piskvorky/smart_open/compare/v6.3.0...v6.4.0) --- updated-dependencies: - dependency-name: smart-open dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index e310a393018..08520ba3975 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ jsonschema==4.19.0 zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 -smart_open==6.3.0 +smart_open==6.4.0 PyMuPDF==1.22.5 pathvalidate==3.1.0 diff --git a/setup.cfg b/setup.cfg index c2b6f433c83..6c8112577db 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,7 +50,7 @@ install_requires = zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 - smart_open==6.3.0 + smart_open==6.4.0 PyMuPDF==1.22.5 pathvalidate==3.1.0 From 60273ed47ca475e13def4091924a980790d9d23b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 15:22:07 +0200 Subject: [PATCH 172/330] Bump django-storages from 1.13.2 to 1.14 (#11453) * Bump django-storages from 1.13.2 to 1.14 Bumps [django-storages](https://github.com/jschneier/django-storages) from 1.13.2 to 1.14. - [Changelog](https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jschneier/django-storages/compare/1.13.2...1.14) --- updated-dependencies: - dependency-name: django-storages dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Bump django-storages from 1.13.2 to 1.14 Bumps [django-storages](https://github.com/jschneier/django-storages) from 1.13.2 to 1.14. - [Changelog](https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jschneier/django-storages/compare/1.13.2...1.14) --- updated-dependencies: - dependency-name: django-storages dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 08520ba3975..7b678c828f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -109,7 +109,7 @@ elasticsearch>=2.0.0,<9.0.0 django-bootstrap3-datetimepicker-2==2.8.3 # storage manager dependencies -django-storages==1.13.2 +django-storages==1.14 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 diff --git a/setup.cfg b/setup.cfg index 6c8112577db..35eaad85c52 100644 --- a/setup.cfg +++ b/setup.cfg @@ -134,7 +134,7 @@ install_requires = django-bootstrap3-datetimepicker-2==2.8.3 # storage manager dependencies - django-storages==1.13.2 + django-storages==1.14 dropbox==11.36.2 google-cloud-storage==2.10.0 google-cloud-core==2.3.3 From b64083966772f1cbde42ab48e3bfaa18af5f9a8b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 16:12:52 +0200 Subject: [PATCH 173/330] Bump ipython from 8.14.0 to 8.15.0 (#11438) * Bump ipython from 8.14.0 to 8.15.0 Bumps [ipython](https://github.com/ipython/ipython) from 8.14.0 to 8.15.0. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/compare/8.14.0...8.15.0) --- updated-dependencies: - dependency-name: ipython dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Bump ipython from 8.14.0 to 8.15.0 Bumps [ipython](https://github.com/ipython/ipython) from 8.14.0 to 8.15.0. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/compare/8.14.0...8.15.0) --- updated-dependencies: - dependency-name: ipython dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7b678c828f8..0ec3426887d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -145,7 +145,7 @@ pycountry # production uWSGI==2.0.22 gunicorn==21.2.0 -ipython==8.14.0 +ipython==8.15.0 docker==6.1.3 invoke==2.2.0 diff --git a/setup.cfg b/setup.cfg index 35eaad85c52..08de7a9da04 100644 --- a/setup.cfg +++ b/setup.cfg @@ -170,7 +170,7 @@ install_requires = # production uWSGI==2.0.22 gunicorn==21.2.0 - ipython==8.14.0 + ipython==8.15.0 docker==6.1.3 invoke==2.2.0 From 6a6968fd8a35ee368a913704d7f06a4ee0dfdba9 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Wed, 13 Sep 2023 17:15:45 +0200 Subject: [PATCH 174/330] drop apha channel from thumbnail mask (#11481) --- geonode/thumbs/thumbnails.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geonode/thumbs/thumbnails.py b/geonode/thumbs/thumbnails.py index 3006a5bf58f..45819775408 100644 --- a/geonode/thumbs/thumbnails.py +++ b/geonode/thumbs/thumbnails.py @@ -171,7 +171,7 @@ def create_thumbnail( img = Image.open(content) img.verify() # verify that it is, in fact an image img = Image.open(BytesIO(image)) # "re-open" the file (required after running verify method) - merged_partial_thumbs.paste(img, mask=img.convert("RGBA")) + merged_partial_thumbs.paste(img, mask=img.convert("RGBA").split()[-1]) except UnidentifiedImageError as e: logger.error(f"Thumbnail generation. Error occurred while fetching dataset image: {image}") logger.exception(e) From 1404400c13b95ba7b5c4e282bd0885a49744c70a Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Wed, 13 Sep 2023 18:10:08 +0200 Subject: [PATCH 175/330] [Fixes #11430] Improve metadata template (#11431) * Fixup dataset template * Fixup document template * Fixup geoapp and maps template * [Fixes #11430] Fix template typo --- geonode/documents/templates/layouts/doc_panels.html | 3 +++ geonode/geoapps/templates/layouts/app_panels.html | 12 ++++++++++++ geonode/layers/templates/layouts/panels.html | 4 ++-- geonode/maps/templates/layouts/map_panels.html | 8 +++++++- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/geonode/documents/templates/layouts/doc_panels.html b/geonode/documents/templates/layouts/doc_panels.html index bbde7b2e7d5..d02d01085fc 100644 --- a/geonode/documents/templates/layouts/doc_panels.html +++ b/geonode/documents/templates/layouts/doc_panels.html @@ -556,6 +556,7 @@
    + {% block document_poc %}
    {% trans "Responsible Parties" %}
    @@ -563,6 +564,8 @@ {{ document_form.poc }}
    + {% endblock %} +
    {% trans "Responsible and Permissions" %}
    diff --git a/geonode/geoapps/templates/layouts/app_panels.html b/geonode/geoapps/templates/layouts/app_panels.html index d46741d8c2e..cb8aa41f766 100644 --- a/geonode/geoapps/templates/layouts/app_panels.html +++ b/geonode/geoapps/templates/layouts/app_panels.html @@ -292,9 +292,11 @@
    + {% block geoapp_title %} {{ geoapp_form.title }} + {% endblock %}
    @@ -312,6 +314,7 @@ {{ geoapp_form.date }}
    + {% block geoapp_category %}
    + {% block geoapp_attributes %}
    @@ -393,6 +398,7 @@ {{ geoapp_form.attribution }}
    + {% endblock geoapp_attributes %}
    @@ -452,6 +458,7 @@
    + {% block geoapp_temporal_extent_start %}
    @@ -459,6 +466,8 @@ {{ geoapp_form.temporal_extent_start }}
    + {% endblock geoapp_temporal_extent_start %} + {% block geoapp_temporal_extent_end %}
    @@ -466,6 +475,8 @@ {{ geoapp_form.temporal_extent_end }}
    + {% endblock geoapp_temporal_extent_end %} + {% block maintenance_block %}
    @@ -484,6 +495,7 @@
    {% endblock geoapp_extra_metadata %}
    + {% endblock maintenance_block %}
    diff --git a/geonode/layers/templates/layouts/panels.html b/geonode/layers/templates/layouts/panels.html index c10634646f3..579b5f46ea6 100644 --- a/geonode/layers/templates/layouts/panels.html +++ b/geonode/layers/templates/layouts/panels.html @@ -560,15 +560,15 @@
    + {% block dataset_poc %}
    {% trans "Responsible Parties" %}
    - {% block dataset_poc %}
    {{ dataset_form.poc }}
    - {% endblock dataset_poc %}
    + {% endblock dataset_poc %}
    {% trans "Responsible and Permissions" %}
    diff --git a/geonode/maps/templates/layouts/map_panels.html b/geonode/maps/templates/layouts/map_panels.html index 307549913da..dce48088967 100644 --- a/geonode/maps/templates/layouts/map_panels.html +++ b/geonode/maps/templates/layouts/map_panels.html @@ -508,13 +508,15 @@ {% endblock map_supplemental_information %}
    + {% block map_temporal_extent_start %}
    {{ map_form.temporal_extent_start }} -
    +
    + {% endblock %}
    {% block map_temporal_extent_end %}
    @@ -524,6 +526,7 @@
    {% endblock map_temporal_extent_end %}
    + {% block maintenance_block %}
    @@ -542,8 +545,10 @@
    {% endblock map_extra_metadata %}
    + {% endblock maintenance_block %}
    + {% block map_poc %}
    {% trans "Responsible Parties" %}
    @@ -551,6 +556,7 @@ {{ map_form.poc }}
    + {% endblock %}
    {% trans "Responsible and Permissions" %}
    From abd1baf3d709265604010b7b15c8bfcc2a72a252 Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Wed, 13 Sep 2023 22:03:04 +0200 Subject: [PATCH 176/330] =?UTF-8?q?[Fixes=20#11441]Implement=20the=20updat?= =?UTF-8?q?e=20of=20the=20spatial=20extent=20for=20non-spat=E2=80=A6=20(#1?= =?UTF-8?q?1442)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fixes #11441]Implement the update of the spatial extent for non-spatial resource types * [Fixes #11441]Implement the update of the spatial extent for non-spatial resource types * [Fixes #11441] Fix tests * [Fixes #11441] Fix tests * list resource types with settable bbox at load time * fixed tests * enable bbox save on the base of instance model * fix tests * [Fixes #11441] Fix test * [Fixes #11441] Expose standard_bbox and add small validation in the serializer * fix bbox projection * missing util * return extent as we expect it in input * black reformatting * fixes * fix black formatting * fix test --------- Co-authored-by: Giovanni Allegri --- geonode/base/api/exceptions.py | 7 ++ geonode/base/api/serializers.py | 37 ++++++++++ geonode/base/api/tests.py | 115 ++++++++++++++++++++++++++++- geonode/base/models.py | 18 ++++- geonode/base/populate_test_data.py | 32 ++++++++ geonode/documents/api/tests.py | 32 +++++++- geonode/geoapps/api/tests.py | 16 +++- geonode/security/utils.py | 12 +-- geonode/utils.py | 6 ++ 9 files changed, 260 insertions(+), 15 deletions(-) diff --git a/geonode/base/api/exceptions.py b/geonode/base/api/exceptions.py index a1377af067c..32812298acc 100644 --- a/geonode/base/api/exceptions.py +++ b/geonode/base/api/exceptions.py @@ -20,6 +20,13 @@ from rest_framework.views import exception_handler +class InvalidResourceException(APIException): + status_code = 500 + default_detail = "Invalid Resource exception" + default_code = "invalid_resource_exception" + category = "resource_api" + + def geonode_exception_handler(exc, context): # Call REST framework's default exception handler first, # to get the standard error response. diff --git a/geonode/base/api/serializers.py b/geonode/base/api/serializers.py index 2b6933b4633..3e18a25138b 100644 --- a/geonode/base/api/serializers.py +++ b/geonode/base/api/serializers.py @@ -38,6 +38,8 @@ from dynamic_rest.fields.fields import DynamicRelationField, DynamicComputedField from avatar.templatetags.avatar_tags import avatar_url +from geonode.utils import bbox_swap +from geonode.base.api.exceptions import InvalidResourceException from geonode.favorite.models import Favorite from geonode.base.models import ( @@ -53,12 +55,15 @@ ThesaurusKeywordLabel, ExtraMetadata, ) +from geonode.documents.models import Document +from geonode.geoapps.models import GeoApp from geonode.groups.models import GroupCategory, GroupProfile from geonode.base.api.fields import ComplexDynamicRelationField from geonode.layers.utils import get_dataset_download_handlers, get_default_dataset_download_handler from geonode.utils import build_absolute_uri from geonode.security.utils import get_resources_with_perms, get_geoapp_subtypes from geonode.resource.models import ExecutionRequest +from django.contrib.gis.geos import Polygon logger = logging.getLogger(__name__) @@ -383,6 +388,15 @@ def to_representation(self, value): return UserSerializer(embed=True, many=False).to_representation(value) +class ExtentBboxField(DynamicComputedField): + def get_attribute(self, instance): + return instance.ll_bbox + + def to_representation(self, value): + bbox = bbox_swap(value[:-1]) + return super().to_representation({"coords": bbox, "srid": value[-1]}) + + class DataBlobField(DynamicRelationField): def value_to_string(self, obj): value = self.value_from_object(obj) @@ -448,6 +462,9 @@ def to_representation(self, instance): return data +api_bbox_settable_resource_models = [Document, GeoApp] + + class ResourceBaseSerializer( ResourceBaseToRepresentationSerializerMixin, BaseDynamicModelSerializer, @@ -483,6 +500,7 @@ def __init__(self, *args, **kwargs): self.fields["data_quality_statement"] = serializers.CharField(required=False) self.fields["bbox_polygon"] = fields.GeometryField(read_only=True, required=False) self.fields["ll_bbox_polygon"] = fields.GeometryField(read_only=True, required=False) + self.fields["extent"] = ExtentBboxField(required=False) self.fields["srid"] = serializers.CharField(required=False) self.fields["group"] = ComplexDynamicRelationField(GroupSerializer, embed=True, many=False) self.fields["popular_count"] = serializers.CharField(required=False) @@ -613,6 +631,7 @@ class Meta: "data_quality_statement": {"required": False}, "bbox_polygon": {"required": False}, "ll_bbox_polygon": {"required": False}, + "extent": {"required": False}, "srid": {"required": False}, "popular_count": {"required": False}, "share_count": {"required": False}, @@ -641,6 +660,24 @@ def to_internal_value(self, data): data = super(ResourceBaseSerializer, self).to_internal_value(data) return data + def save(self, **kwargs): + extent = self.validated_data.pop("extent", None) + instance = super().save(**kwargs) + if extent and instance.get_real_instance()._meta.model in api_bbox_settable_resource_models: + srid = extent.get("srid", "EPSG:4326") + coords = extent.get("coords") + if not coords: + logger.warning("BBOX was sent, but no coords were supplied. Skipping") + return instance + try: + # small validation test + Polygon.from_bbox(coords) + except Exception as e: + logger.exception(e) + raise InvalidResourceException("The standard bbox provided is invalid") + instance.set_bbox_polygon(coords, srid) + return instance + """ - Deferred / not Embedded --> ?include[]=data """ diff --git a/geonode/base/api/tests.py b/geonode/base/api/tests.py index 961d28da16a..677c7e06d8a 100644 --- a/geonode/base/api/tests.py +++ b/geonode/base/api/tests.py @@ -70,7 +70,13 @@ from geonode.geoapps.models import GeoApp from geonode.utils import build_absolute_uri from geonode.resource.api.tasks import ExecutionRequest -from geonode.base.populate_test_data import create_models, create_single_dataset, create_single_doc, create_single_map +from geonode.base.populate_test_data import ( + create_models, + create_single_dataset, + create_single_doc, + create_single_map, + create_single_geoapp, +) from geonode.resource.api.tasks import resouce_service_dispatcher logger = logging.getLogger(__name__) @@ -2913,3 +2919,110 @@ def test_linked_resource_filter_should_work(self): finally: if _d: _d.delete() + + +class TestApiAdditionalBBoxCalculation(GeoNodeBaseTestSupport): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.dataset = create_single_dataset("single_layer") + cls.map = create_single_map("single_map") + cls.doc = create_single_doc("single_doc") + cls.geoapp = create_single_geoapp("single_geoapp") + cls.bbox = {"extent": {"coords": [6847623, 4776382, 7002886, 4878813], "srid": "EPSG:6875"}} + + def setUp(self): + self.admin = get_user_model().objects.get(username="admin") + + def test_dataset_should_not_update_bbox(self): + self.client.force_login(self.admin) + url = reverse("base-resources-detail", kwargs={"pk": self.dataset.get_self_resource().pk}) + response = self.client.patch( + url, + data=json.dumps({"extent": {"coords": [6847623, 4776382, 7002886, 4878813], "srid": "EPSG:6875"}}), + content_type="application/json", + ) + + llbbox = self.dataset.ll_bbox_polygon + srid = self.dataset.srid + + self.assertEqual(200, response.status_code) + self.dataset.refresh_from_db() + self.assertEqual(self.dataset.ll_bbox_polygon, llbbox) + self.assertEqual(self.dataset.srid, srid) + + def test_map_should_not_update_bbox(self): + self.client.force_login(self.admin) + url = reverse("base-resources-detail", kwargs={"pk": self.map.get_self_resource().pk}) + response = self.client.patch( + url, + data=json.dumps({"extent": {"coords": [6847623, 4776382, 7002886, 4878813], "srid": "EPSG:6875"}}), + content_type="application/json", + ) + + llbbox = self.map.ll_bbox_polygon + srid = self.map.srid + + self.assertEqual(200, response.status_code) + self.map.refresh_from_db() + self.assertEqual(self.map.ll_bbox_polygon, llbbox) + self.assertEqual(self.map.srid, srid) + + def test_document_should_update_bbox(self): + self.client.force_login(self.admin) + url = reverse("base-resources-detail", kwargs={"pk": self.doc.get_self_resource().pk}) + response = self.client.patch( + url, + data=json.dumps({"extent": {"coords": [6847623, 4776382, 7002886, 4878813], "srid": "EPSG:6875"}}), + content_type="application/json", + ) + + self.assertEqual(200, response.status_code) + self.doc.refresh_from_db() + expected = { + "coords": [10.094299016880456, 43.172169804633185, 12.036103612263465, 44.11087068031093], + "srid": "EPSG:4326", + } + resp = response.json()["resource"].get("extent") + self.assertEqual(resp, expected) + self.assertEqual("EPSG:6875", self.doc.srid) + expected = "POLYGON ((10.094299016880456 43.172169804633185, 10.094299016880456 44.11087068031093, 12.036103612263465 44.11087068031093, 12.036103612263465 43.172169804633185, 10.094299016880456 43.172169804633185))" # noqa + self.assertEqual(self.doc.ll_bbox_polygon.wkt, expected) + + def test_geoapp_should_update_bbox(self): + self.client.force_login(self.admin) + url = reverse("base-resources-detail", kwargs={"pk": self.geoapp.get_self_resource().pk}) + response = self.client.patch( + url, + data=json.dumps({"extent": {"coords": [6847623, 4776382, 7002886, 4878813], "srid": "EPSG:6875"}}), + content_type="application/json", + ) + + self.assertEqual(200, response.status_code) + self.geoapp.refresh_from_db() + expected = { + "coords": [10.094299016880456, 43.172169804633185, 12.036103612263465, 44.11087068031093], + "srid": "EPSG:4326", + } + resp = response.json()["resource"].get("extent") + self.assertEqual(resp, expected) + expected = "POLYGON ((10.094299016880456 43.172169804633185, 10.094299016880456 44.11087068031093, 12.036103612263465 44.11087068031093, 12.036103612263465 43.172169804633185, 10.094299016880456 43.172169804633185))" # noqa + self.assertEqual(self.geoapp.ll_bbox_polygon.wkt, expected) + self.assertEqual("EPSG:6875", self.geoapp.srid) + + def test_geoapp_send_invalid_bbox_should_raise_error(self): + self.client.force_login(self.admin) + url = reverse("base-resources-detail", kwargs={"pk": self.geoapp.get_self_resource().pk}) + response = self.client.patch( + url, + data=json.dumps({"extent": {"coords": [6847623, 4776382, 7002886], "srid": "EPSG:6875"}}), + content_type="application/json", + ) + + self.assertEqual(500, response.status_code) + expected = { + "success": False, + "errors": ["The standard bbox provided is invalid"], + "code": "invalid_resource_exception", + } + self.assertDictEqual(expected, response.json()) diff --git a/geonode/base/models.py b/geonode/base/models.py index 1efe7288b0d..1a2c8a38abd 100644 --- a/geonode/base/models.py +++ b/geonode/base/models.py @@ -66,7 +66,14 @@ from geonode.singleton import SingletonModel from geonode.groups.conf import settings as groups_settings from geonode.base.bbox_utils import BBOXHelper, polygon_from_bbox -from geonode.utils import bbox_to_wkt, find_by_attr, bbox_to_projection, get_allowed_extensions, is_monochromatic_image +from geonode.utils import ( + bbox_to_wkt, + find_by_attr, + bbox_to_projection, + bbox_swap, + get_allowed_extensions, + is_monochromatic_image, +) from geonode.thumbs.utils import thumb_size, remove_thumbs, get_unique_upload_path from geonode.groups.models import GroupProfile from geonode.security.utils import get_visible_resources, get_geoapp_subtypes @@ -1402,7 +1409,14 @@ def set_ll_bbox_polygon(self, bbox, srid="EPSG:4326"): else: match = re.match(r"^(EPSG:)?(?P\d{4,6})$", str(srid)) bbox_polygon.srid = int(match.group("srid")) if match else 4326 - self.ll_bbox_polygon = Polygon.from_bbox(bbox_to_projection(list(bbox_polygon.extent) + [srid])[:-1]) + + # Adapt coords order from xmin,ymin,xmax,ymax to xmin,xmax,ymin,ymax + standard_extent = list(bbox_polygon.extent) + bbox_gn_order = bbox_swap(standard_extent) + [srid] + projected_bbox_gn_order = bbox_to_projection(bbox_gn_order) + projected_bbox = bbox_swap(projected_bbox_gn_order[:-1]) + + self.ll_bbox_polygon = Polygon.from_bbox(projected_bbox) ResourceBase.objects.filter(id=self.id).update(ll_bbox_polygon=self.ll_bbox_polygon) except Exception as e: raise GeoNodeException(e) diff --git a/geonode/base/populate_test_data.py b/geonode/base/populate_test_data.py index 3b0be582bf0..faa8c3b91f6 100644 --- a/geonode/base/populate_test_data.py +++ b/geonode/base/populate_test_data.py @@ -42,6 +42,7 @@ from geonode.compat import ensure_string from geonode.documents.models import Document from geonode.base.models import ResourceBase, TopicCategory +from geonode.geoapps.models import GeoApp # This is used to populate the database with the search fixture data. This is # primarily used as a first step to generate the json data for the fixture using @@ -482,6 +483,37 @@ def create_single_doc(name, owner=None, **kwargs): return m +def create_single_geoapp(name, resource_type="geostory", owner=None, **kwargs): + admin, created = get_user_model().objects.get_or_create(username="admin") + if created: + admin.is_superuser = True + admin.first_name = "admin" + admin.set_password("admin") + admin.save() + test_datetime = datetime.strptime("2020-01-01", "%Y-%m-%d") + dd = (name, "lorem ipsum", name, f"{name}", [0, 22, 0, 22], test_datetime, ("populartag",)) + title, abstract, name, alternate, (bbox_x0, bbox_x1, bbox_y0, bbox_y1), start, kws = dd + logger.debug(f"[SetUp] Add geoapp {title}") + m, _ = GeoApp.objects.get_or_create( + title=title, + defaults=dict( + uuid=str(uuid4()), + abstract=abstract, + owner=owner or admin, + resource_type=resource_type, + bbox_polygon=Polygon.from_bbox((bbox_x0, bbox_y0, bbox_x1, bbox_y1)), + ll_bbox_polygon=Polygon.from_bbox((bbox_x0, bbox_y0, bbox_x1, bbox_y1)), + srid="EPSG:4326", + files=dfile, + **kwargs, + ), + ) + m.set_default_permissions(owner=owner or admin) + m.clear_dirty_state() + m.set_processing_state(enumerations.STATE_PROCESSED) + return m + + def add_keywords_to_resource(resource, keywords): for keyword in keywords: resource.keywords.add(keyword) diff --git a/geonode/documents/api/tests.py b/geonode/documents/api/tests.py index 1951da4e2e1..9a08fbd8671 100644 --- a/geonode/documents/api/tests.py +++ b/geonode/documents/api/tests.py @@ -22,7 +22,7 @@ from urllib.parse import urljoin from django.urls import reverse -from rest_framework.test import APITestCase +from rest_framework.test import APITransactionTestCase from guardian.shortcuts import assign_perm, get_anonymous_user from geonode import settings @@ -34,7 +34,7 @@ logger = logging.getLogger(__name__) -class DocumentsApiTests(APITestCase): +class DocumentsApiTests(APITransactionTestCase): fixtures = ["initial_data.json", "group_test_data.json", "default_oauth_apps.json"] def setUp(self): @@ -143,6 +143,34 @@ def test_creation_should_create_the_doc(self): self.assertEqual("xml", extension) self.assertTrue(Document.objects.filter(title="New document for testing").exists()) + def test_creation_should_create_the_doc_and_update_the_bbox(self): + """ + If file_path is not available, should raise error + """ + self.client.force_login(self.admin) + payload = { + "document": { + "title": "New document for testing", + "metadata_only": True, + "file_path": self.valid_file_path, + "extent": {"coords": [1123692.0, 5338214.0, 1339852.0, 5482615.0], "srid": "EPSG:3857"}, + }, + } + actual = self.client.post(self.url, data=payload, format="json") + self.assertEqual(201, actual.status_code) + extension = actual.json().get("document", {}).get("extension", "") + self.assertEqual("xml", extension) + doc = Document.objects.filter(title="New document for testing").all() + self.assertTrue(doc.exists()) + x = doc.first() + x.refresh_from_db() + self.assertEqual("EPSG:3857", x.srid) + self.assertEqual(actual.json()["document"].get("extent")["srid"], "EPSG:4326") + self.assertEqual( + actual.json()["document"].get("extent")["coords"], + [10.094296982428332, 43.1721654049465, 12.03609530058109, 44.11086592050112], + ) + def test_file_path_and_doc_path_are_not_returned(self): """ If file_path and doc_path should not be visible diff --git a/geonode/geoapps/api/tests.py b/geonode/geoapps/api/tests.py index 6467e2f9e0d..4b3a545f182 100644 --- a/geonode/geoapps/api/tests.py +++ b/geonode/geoapps/api/tests.py @@ -95,10 +95,24 @@ def test_geoapps_crud(self): self.assertTrue(self.client.login(username="bobby", password="bob")) # Create url = f"{reverse('geoapps-list')}?include[]=data" - data = {"name": "Test Create", "title": "Test Create", "resource_type": "geostory", "owner": "bobby"} + data = { + "name": "Test Create", + "title": "Test Create", + "resource_type": "geostory", + "owner": "bobby", + "extent": {"coords": [1123692.0, 5338214.0, 1339852.0, 5482615.0], "srid": "EPSG:3857"}, + } response = self.client.post(url, data=data, format="json") self.assertEqual(response.status_code, 201) # 201 - Created + x = GeoApp.objects.filter(title="Test Create").first() + self.assertEqual(x.srid, "EPSG:3857") + self.assertEqual(response.json()["geoapp"].get("extent")["srid"], "EPSG:4326") + self.assertEqual( + response.json()["geoapp"].get("extent")["coords"], + [10.094296982428332, 43.1721654049465, 12.03609530058109, 44.11086592050112], + ) + response = self.client.get(url, format="json") self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) diff --git a/geonode/security/utils.py b/geonode/security/utils.py index 96a9d763062..91b567562de 100644 --- a/geonode/security/utils.py +++ b/geonode/security/utils.py @@ -22,7 +22,6 @@ import collections from itertools import chain -from django.apps import apps from django.db.models import Q from django.conf import settings from django.contrib.auth import get_user_model @@ -216,14 +215,9 @@ def get_geoapp_subtypes(): """ from geonode.geoapps.models import GeoApp - subtypes = [] - for label, app in apps.app_configs.items(): - if hasattr(app, "type") and app.type == "GEONODE_APP": - if hasattr(app, "default_model"): - _model = apps.get_model(label, app.default_model) - if issubclass(_model, GeoApp): - subtypes.append(_model.__name__.lower()) - return subtypes + if not globals().get("geoapp_subtypes"): + globals()["geoapp_subtypes"] = list(GeoApp.objects.values_list("resource_type", flat=True).distinct()) + return globals().get("geoapp_subtypes", []) def skip_registered_members_common_group(user_group): diff --git a/geonode/utils.py b/geonode/utils.py index 26feb074218..d9c67345344 100755 --- a/geonode/utils.py +++ b/geonode/utils.py @@ -493,6 +493,12 @@ def _split_query(query): return [kw.strip() for kw in keywords if kw.strip()] +# Swaps coords order from xmin,ymin,xmax,ymax to xmin,xmax,ymin,ymax and viceversa +def bbox_swap(bbox): + _bbox = [float(o) for o in bbox] + return [_bbox[0], _bbox[2], _bbox[1], _bbox[3]] + + def bbox_to_wkt(x0, x1, y0, y1, srid="4326", include_srid=True): if srid and str(srid).startswith("EPSG:"): srid = srid[5:] From 79ac6e70419c2e0261548bed91c159b54ff35b8d Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Thu, 14 Sep 2023 13:17:12 +0200 Subject: [PATCH 177/330] Merge pull request from GHSA-pxg5-h34r-7q8p * fixes advisory * - fix tests --- geonode/proxy/tests.py | 10 ++++++++++ geonode/utils.py | 21 +++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/geonode/proxy/tests.py b/geonode/proxy/tests.py index f44315c52a7..807a64f3f7e 100644 --- a/geonode/proxy/tests.py +++ b/geonode/proxy/tests.py @@ -213,6 +213,16 @@ class Response: response = self.client.get(f"{self.proxy_url}?url={url}") self.assertEqual(response.status_code, 403) + url = f"{settings.SITEURL.rstrip('/')}@geoserver:8080/web/index.html" + + response = self.client.get(f"{self.proxy_url}?url={url}") + self.assertEqual(response.status_code, 403) + + url = f"{settings.SITEURL.rstrip('/')}%40geoserver:8080/web/index.html" + + response = self.client.get(f"{self.proxy_url}?url={url}") + self.assertEqual(response.status_code, 403) + # Legit requests using the local host (SITEURL) url = f"/\\@%23{urlsplit(settings.SITEURL).hostname}" diff --git a/geonode/utils.py b/geonode/utils.py index d9c67345344..d15dafe3162 100755 --- a/geonode/utils.py +++ b/geonode/utils.py @@ -88,6 +88,7 @@ urlparse, urlsplit, urlencode, + urlunparse, parse_qsl, ParseResult, ) @@ -1907,11 +1908,27 @@ def build_absolute_uri(url): return url +def remove_credentials_from_url(url): + # Parse the URL + parsed_url = urlparse(url) + + # Remove the username and password from the parsed URL + parsed_url = parsed_url._replace(netloc=parsed_url.netloc.split("@")[-1]) + + # Reconstruct the URL without credentials + cleaned_url = urlunparse(parsed_url) + + return cleaned_url + + def extract_ip_or_domain(url): + # Decode the URL to handle percent-encoded characters + _url = remove_credentials_from_url(unquote(url)) + ip_regex = re.compile("^(?:http://|https://)(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})") domain_regex = re.compile("^(?:http://|https://)([a-zA-Z0-9.-]+)") - match = ip_regex.findall(url) + match = ip_regex.findall(_url) if len(match): ip_address = match[0] try: @@ -1920,7 +1937,7 @@ def extract_ip_or_domain(url): except ValueError: pass - match = domain_regex.findall(url) + match = domain_regex.findall(_url) if len(match): return match[0] From 6503e4242dbc1e524af4c13e8d8259ef89968ce5 Mon Sep 17 00:00:00 2001 From: afabiani Date: Thu, 14 Sep 2023 14:03:25 +0200 Subject: [PATCH 178/330] - Test fix --- .env_test | 2 +- geonode/proxy/tests.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env_test b/.env_test index cfbc1098d97..9bb0ed09481 100644 --- a/.env_test +++ b/.env_test @@ -43,7 +43,7 @@ ASYNC_SIGNALS=True SITEURL=http://localhost:8000/ -ALLOWED_HOSTS="['django', '*']" +ALLOWED_HOSTS="['django', 'localhost']" # Data Uploader DEFAULT_BACKEND_UPLOADER=geonode.importer diff --git a/geonode/proxy/tests.py b/geonode/proxy/tests.py index 807a64f3f7e..637fa54b8c1 100644 --- a/geonode/proxy/tests.py +++ b/geonode/proxy/tests.py @@ -213,12 +213,12 @@ class Response: response = self.client.get(f"{self.proxy_url}?url={url}") self.assertEqual(response.status_code, 403) - url = f"{settings.SITEURL.rstrip('/')}@geoserver:8080/web/index.html" + url = f"{settings.SITEURL.rstrip('/')}@db/" response = self.client.get(f"{self.proxy_url}?url={url}") self.assertEqual(response.status_code, 403) - url = f"{settings.SITEURL.rstrip('/')}%40geoserver:8080/web/index.html" + url = f"{settings.SITEURL.rstrip('/')}%40db/" response = self.client.get(f"{self.proxy_url}?url={url}") self.assertEqual(response.status_code, 403) From 66ccb3cb9484ed91f03890677a3fd2db76a533d0 Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Fri, 15 Sep 2023 16:25:04 +0200 Subject: [PATCH 179/330] [Fixes #11430] Improve metadata template for geoapp (#11489) * [Fixes #11430] Improve metadata template for geoapp * [Fixes #11430] Remove typo * [Fixes #11430] add block for doc --- geonode/documents/templates/layouts/doc_panels.html | 2 ++ geonode/geoapps/templates/layouts/app_panels.html | 2 ++ 2 files changed, 4 insertions(+) diff --git a/geonode/documents/templates/layouts/doc_panels.html b/geonode/documents/templates/layouts/doc_panels.html index d02d01085fc..2a3a7b08004 100644 --- a/geonode/documents/templates/layouts/doc_panels.html +++ b/geonode/documents/templates/layouts/doc_panels.html @@ -514,6 +514,7 @@ {% endblock doc_supplemental_information %}
    + {% block doc_temporal_block %}
    {% block doc_temporal_extent_start %}
    @@ -532,6 +533,7 @@
    {% endblock doc_temporal_extent_end %}
    + {% endblock %}
    {% block doc_maintenance_frequency %}
    diff --git a/geonode/geoapps/templates/layouts/app_panels.html b/geonode/geoapps/templates/layouts/app_panels.html index cb8aa41f766..e5ea2378882 100644 --- a/geonode/geoapps/templates/layouts/app_panels.html +++ b/geonode/geoapps/templates/layouts/app_panels.html @@ -498,6 +498,7 @@ {% endblock maintenance_block %}
    + {% block geoapp_poc %}
    {% trans "Responsible Parties" %}
    @@ -505,6 +506,7 @@ {{ geoapp_form.poc }}
    + {% endblock %}
    {% trans "Responsible and Permissions" %}
    From 88658844e734d4bf26a3e6781826941c29e04d0b Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Fri, 15 Sep 2023 18:42:09 +0200 Subject: [PATCH 180/330] Update .env_test (#11495) --- .env_test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env_test b/.env_test index 9bb0ed09481..a5d6e909abd 100644 --- a/.env_test +++ b/.env_test @@ -43,7 +43,7 @@ ASYNC_SIGNALS=True SITEURL=http://localhost:8000/ -ALLOWED_HOSTS="['django', 'localhost']" +ALLOWED_HOSTS="['django', 'localhost', '127.0.0.1']" # Data Uploader DEFAULT_BACKEND_UPLOADER=geonode.importer From 96566be47c75b6932e36bb565b33ae97ddc39d93 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Fri, 15 Sep 2023 18:42:36 +0200 Subject: [PATCH 181/330] [Fixes #11491 abd #11492] Fix alpha blending and remote layers in thumbnails (#11493) * Fix alpha blending and remote layers in thumbs * fix black formatting --- geonode/thumbs/thumbnails.py | 34 +++++++++++++++++++--------------- geonode/thumbs/utils.py | 2 +- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/geonode/thumbs/thumbnails.py b/geonode/thumbs/thumbnails.py index 45819775408..822815780da 100644 --- a/geonode/thumbs/thumbnails.py +++ b/geonode/thumbs/thumbnails.py @@ -25,6 +25,7 @@ from django.conf import settings from django.utils.module_loading import import_string +from geonode.base.enumerations import SOURCE_TYPE_REMOTE from geonode.documents.models import Document from geonode.geoapps.models import GeoApp from geonode.maps.models import Map, MapLayer @@ -42,7 +43,7 @@ def create_gs_thumbnail_geonode(instance, overwrite=False, check_bbox=False): """ Create a thumbnail with a GeoServer request. """ - wms_version = getattr(ogc_server_settings, "WMS_VERSION") or "1.1.1" + wms_version = getattr(ogc_server_settings, "WMS_VERSION") or "1.3.0" create_thumbnail( instance, @@ -53,7 +54,7 @@ def create_gs_thumbnail_geonode(instance, overwrite=False, check_bbox=False): def create_thumbnail( instance: Union[Dataset, Map], - wms_version: str = settings.OGC_SERVER["default"].get("WMS_VERSION", "1.1.1"), + wms_version: str = settings.OGC_SERVER["default"].get("WMS_VERSION", "1.3.0"), bbox: Optional[Union[List, Tuple]] = None, forced_crs: Optional[str] = None, styles: Optional[List] = None, @@ -138,8 +139,10 @@ def create_thumbnail( partial_thumbs = [] for ogc_server, datasets, _styles in locations: - if isinstance(instance, Map) and len(datasets) == len(_styles): - styles = _styles + if isinstance(instance, Map): + styles = [] + if len(datasets) == len(_styles): + styles = _styles try: partial_thumbs.append( utils.get_map( @@ -171,7 +174,8 @@ def create_thumbnail( img = Image.open(content) img.verify() # verify that it is, in fact an image img = Image.open(BytesIO(image)) # "re-open" the file (required after running verify method) - merged_partial_thumbs.paste(img, mask=img.convert("RGBA").split()[-1]) + # merged_partial_thumbs.paste(img, mask=img.convert("RGBA").split()[-1]) + merged_partial_thumbs = Image.alpha_composite(merged_partial_thumbs, img.convert("RGBA")) except UnidentifiedImageError as e: logger.error(f"Thumbnail generation. Error occurred while fetching dataset image: {image}") logger.exception(e) @@ -186,12 +190,12 @@ def create_thumbnail( background = None # --- overlay image with background --- - thumbnail = Image.new("RGB", (width, height), (250, 250, 250)) + thumbnail = Image.new("RGBA", (width, height), (250, 250, 250)) if background is not None: thumbnail.paste(background, (0, 0)) - thumbnail.paste(merged_partial_thumbs, (0, 0), merged_partial_thumbs) + thumbnail = Image.alpha_composite(thumbnail, merged_partial_thumbs) # convert image to the format required by save_thumbnail with BytesIO() as output: @@ -271,25 +275,25 @@ def _datasets_locations( else: bbox = utils.transform_bbox(instance.bbox, target_crs) elif isinstance(instance, Map): - for map_dataset in instance.maplayers.filter(visibility=True).order_by("order").iterator(): - if not map_dataset.local and not map_dataset.ows_url: + for maplayer in instance.maplayers.filter(visibility=True).order_by("order").iterator(): + if maplayer.dataset and maplayer.dataset.sourcetype == SOURCE_TYPE_REMOTE and not maplayer.dataset.ows_url: logger.warning( "Incorrectly defined remote dataset encountered (no OWS URL defined)." "Skipping it in the thumbnail generation." ) continue - name = get_dataset_name(map_dataset) - store = map_dataset.store - workspace = get_dataset_workspace(map_dataset) - map_dataset_style = map_dataset.current_style + name = get_dataset_name(maplayer) + store = maplayer.store + workspace = get_dataset_workspace(maplayer) + map_dataset_style = maplayer.current_style if store and Dataset.objects.filter(store=store, workspace=workspace, name=name).exists(): dataset = Dataset.objects.filter(store=store, workspace=workspace, name=name).first() elif workspace and Dataset.objects.filter(workspace=workspace, name=name).exists(): dataset = Dataset.objects.filter(workspace=workspace, name=name).first() - elif Dataset.objects.filter(alternate=map_dataset.name).exists(): - dataset = Dataset.objects.filter(alternate=map_dataset.name).first() + elif Dataset.objects.filter(alternate=maplayer.name).exists(): + dataset = Dataset.objects.filter(alternate=maplayer.name).first() else: logger.warning(f"Dataset for MapLayer {name} was not found. Skipping it in the thumbnail.") continue diff --git a/geonode/thumbs/utils.py b/geonode/thumbs/utils.py index a33135a4b29..a7463fc037c 100644 --- a/geonode/thumbs/utils.py +++ b/geonode/thumbs/utils.py @@ -149,7 +149,7 @@ def get_map( ogc_server_location: str, layers: List, bbox: List, - wms_version: str = settings.OGC_SERVER["default"].get("WMS_VERSION", "1.1.1"), + wms_version: str = settings.OGC_SERVER["default"].get("WMS_VERSION", "1.3.0"), mime_type: str = "image/png", styles: List = None, width: int = 240, From 80e2bbfef601ae3c209950f6c659c5976bd1c4f1 Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Mon, 18 Sep 2023 11:07:13 +0200 Subject: [PATCH 182/330] fix: scripts/docker/nginx/Dockerfile to reduce vulnerabilities (#11500) Co-authored-by: snyk-bot --- scripts/docker/nginx/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/docker/nginx/Dockerfile b/scripts/docker/nginx/Dockerfile index 2e3a8fea7cd..5559b30fe74 100644 --- a/scripts/docker/nginx/Dockerfile +++ b/scripts/docker/nginx/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:1.25.1-alpine +FROM nginx:1.25.2-alpine RUN apk add --no-cache openssl inotify-tools vim From 24203707877a344bce058a816dd79eeb78b3dd5e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 29 Sep 2022 14:22:48 +0200 Subject: [PATCH 183/330] [Fixes #10066][Depencendies] Security audit and checks (#10067) (#10072) * [Fixes #10066][Depencendies] Security audit and checks * -SNYK security fix Co-authored-by: Alessio Fabiani --- setup.cfg | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/setup.cfg b/setup.cfg index f6d5f90abf2..68e7cfb03fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -202,6 +202,11 @@ install_requires = certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability jwcrypto>=1.4 # not directly required, pinned by Snyk to avoid a vulnerability + # Security and audit + mistune==2.0.3 + protobuf==4.21.6 + mako==1.2.3 + [options.packages.find] exclude = tests From d67189df76853cf5d4958f9be80c07f15ad17893 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Oct 2022 10:10:12 +0200 Subject: [PATCH 184/330] [Fixes #10055] Modify Metadata form with permissions check (#10057) (#10076) * -[Fixes #10055] Modify Metadata form with permissions check * - check user in form * - update tests * - add tests Co-authored-by: Giovanni Allegri Co-authored-by: NAGGINDA MARTHA Co-authored-by: Giovanni Allegri --- geonode/base/forms.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/geonode/base/forms.py b/geonode/base/forms.py index c888d816670..b0c91ff7d0f 100644 --- a/geonode/base/forms.py +++ b/geonode/base/forms.py @@ -470,6 +470,9 @@ def __init__(self, *args, **kwargs): if field in ["poc", "owner"] and not self.can_change_perms: self.fields[field].disabled = True + if field in ['poc', 'owner'] and not self.can_change_perms: + self.fields[field].disabled = True + def disable_keywords_widget_for_non_superuser(self, user): if settings.FREETEXT_KEYWORDS_READONLY and not user.is_superuser: self["keywords"].field.disabled = True From 4324f80602a6e8d40e34d9961bf13d0f7c647ce3 Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Mon, 10 Oct 2022 16:08:20 +0200 Subject: [PATCH 185/330] [Dependencies] Align setup.cfg to requirements.txt (#10124) (#10128) (cherry picked from commit e66dd985393d7728ddce2cd1f33b737cbb2b5f6e) # Conflicts: # requirements.txt --- setup.cfg | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 68e7cfb03fd..5421d529fdf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -203,8 +203,9 @@ install_requires = jwcrypto>=1.4 # not directly required, pinned by Snyk to avoid a vulnerability # Security and audit - mistune==2.0.3 - protobuf==4.21.6 + mistune==2.0.4 + wandb==0.12.17 + protobuf==3.20.3 mako==1.2.3 [options.packages.find] From e6809ad3656bb0e22e2cab567caaf172eb46d7b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Oct 2022 09:27:35 +0200 Subject: [PATCH 186/330] [Fixes #7852] Update catalog and background layers url configuration in settings.py and related files (#9950) (#10129) * -[Fixes #7852] Update catalog and background layers url configuration in settings.py and related files * - update geoid url Co-authored-by: NAGGINDA MARTHA --- geonode/local_settings.py.geoserver.sample | 8 -------- 1 file changed, 8 deletions(-) diff --git a/geonode/local_settings.py.geoserver.sample b/geonode/local_settings.py.geoserver.sample index 733589d6d36..7b45e3d77af 100644 --- a/geonode/local_settings.py.geoserver.sample +++ b/geonode/local_settings.py.geoserver.sample @@ -316,14 +316,6 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': GEONODE_CATALOGUE_SERVICE = get_geonode_catalogue_service() - MAPSTORE_CATALOGUE_SERVICES = {} - - MAPSTORE_CATALOGUE_SELECTED_SERVICE = "" - - if GEONODE_CATALOGUE_SERVICE: - MAPSTORE_CATALOGUE_SERVICES[list(GEONODE_CATALOGUE_SERVICE.keys())[0]] = GEONODE_CATALOGUE_SERVICE[list(GEONODE_CATALOGUE_SERVICE.keys())[0]] - MAPSTORE_CATALOGUE_SELECTED_SERVICE = list(GEONODE_CATALOGUE_SERVICE.keys())[0] - DEFAULT_MS2_BACKGROUNDS = [ { "type": "tileprovider", From 2dd52654b2e27181aa0bc3dd28b467571451271b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:54:00 +0200 Subject: [PATCH 187/330] =?UTF-8?q?[Fixes=20#10142]=20storage=5Fmanager=20?= =?UTF-8?q?copy=20dont=20assign=20the=20folder/file=20permi=E2=80=A6=20(#1?= =?UTF-8?q?0143)=20(#10154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fixes #10142] storage_manager copy dont assign the folder/file permissions correcly * [Fixes #10142] storage_manager copy dont assign the folder/file permissions correcly * [Fixes #10142] storage_manager copy dont assign the folder/file permissions correcly * [Fixes #10142] storage_manager copy dont assign the folder/file permissions correcly * [Fixes #10142] storage_manager copy dont assign the folder/file permissions correcly Co-authored-by: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> --- geonode/storage/tests.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/geonode/storage/tests.py b/geonode/storage/tests.py index 89eea61d4ad..c2e6c93b459 100644 --- a/geonode/storage/tests.py +++ b/geonode/storage/tests.py @@ -416,6 +416,22 @@ def test_storage_manager_copy(self): os.remove(output.get("files")[0]) self.assertFalse(os.path.exists(output.get("files")[0])) + @override_settings(FILE_UPLOAD_DIRECTORY_PERMISSIONS=0o777) + @override_settings(FILE_UPLOAD_PERMISSIONS=0o777) + def test_storage_manager_copy(self): + ''' + Test that the copy works as expected and the permissions are corerct + ''' + dataset = create_single_dataset(name="test_copy") + dataset.files = [os.path.join(f"{self.project_root}", "tests/data/test_sld.sld")] + dataset.save() + output = self.sut().copy(dataset) + + self.assertTrue(os.path.exists(output.get("files")[0])) + self.assertEqual(os.stat(os.path.exists(output.get("files")[0])).st_mode, 8592) + os.remove(output.get("files")[0]) + self.assertFalse(os.path.exists(output.get("files")[0])) + class TestDataRetriever(TestCase): @classmethod From 8d7de88c83917c63491a052e15220934d11df9d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Oct 2022 11:44:16 +0200 Subject: [PATCH 188/330] Align requirements.txt comments with 4.x (#10162) (#10163) Co-authored-by: Alessio Fabiani --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 5421d529fdf..3fc13d93b40 100644 --- a/setup.cfg +++ b/setup.cfg @@ -204,7 +204,7 @@ install_requires = # Security and audit mistune==2.0.4 - wandb==0.12.17 + wandb==0.13.4 protobuf==3.20.3 mako==1.2.3 From d5ec478b2f5e5cb506ff64c18e42f18f53590ade Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Oct 2022 15:19:18 +0200 Subject: [PATCH 189/330] [Fixes #10204] restore mapstore catalog settings (#10205) (#10206) Co-authored-by: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> --- geonode/local_settings.py.geoserver.sample | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/geonode/local_settings.py.geoserver.sample b/geonode/local_settings.py.geoserver.sample index 7b45e3d77af..733589d6d36 100644 --- a/geonode/local_settings.py.geoserver.sample +++ b/geonode/local_settings.py.geoserver.sample @@ -316,6 +316,14 @@ if GEONODE_CLIENT_LAYER_PREVIEW_LIBRARY == 'mapstore': GEONODE_CATALOGUE_SERVICE = get_geonode_catalogue_service() + MAPSTORE_CATALOGUE_SERVICES = {} + + MAPSTORE_CATALOGUE_SELECTED_SERVICE = "" + + if GEONODE_CATALOGUE_SERVICE: + MAPSTORE_CATALOGUE_SERVICES[list(GEONODE_CATALOGUE_SERVICE.keys())[0]] = GEONODE_CATALOGUE_SERVICE[list(GEONODE_CATALOGUE_SERVICE.keys())[0]] + MAPSTORE_CATALOGUE_SELECTED_SERVICE = list(GEONODE_CATALOGUE_SERVICE.keys())[0] + DEFAULT_MS2_BACKGROUNDS = [ { "type": "tileprovider", From 4c1eb93869f3d156309e62099d9c43a5d392622f Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Fri, 4 Nov 2022 16:55:34 +0100 Subject: [PATCH 190/330] [4.0.x][Fixes #10208] Add a custom hook at the end of the permissions assign (#10212) * [Fixes #10208] Add a custom hook at the end of the permissions assign --- geonode/geoserver/signals.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/geonode/geoserver/signals.py b/geonode/geoserver/signals.py index 70b96060303..591a1e12552 100644 --- a/geonode/geoserver/signals.py +++ b/geonode/geoserver/signals.py @@ -42,6 +42,8 @@ geofence_rule_assign = Signal(providing_args=["instance"]) +post_set_permissions = Signal(providing_args=['instance']) + def geoserver_delete(typename): # cascading_delete should only be called if From fad9b26744c184fcce283802ac6ffdf696806866 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Nov 2022 10:04:04 +0100 Subject: [PATCH 191/330] Fix broken test (#10266) (#10267) * Fix broken test build * Fix broken test build Co-authored-by: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> --- geonode/security/tests.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/geonode/security/tests.py b/geonode/security/tests.py index 52b43a38551..3865528f803 100644 --- a/geonode/security/tests.py +++ b/geonode/security/tests.py @@ -1748,6 +1748,15 @@ def test_admin_whitelisted_access_middleware(self): middleware.process_request(request) self.assertTrue(request.user.is_superuser) + # Test valid IP in second element + with self.settings(ADMIN_IP_WHITELIST=['88.88.88.88', '127.0.0.1']): + request = HttpRequest() + request.user = admin + request.path = reverse('home') + request.META['REMOTE_ADDR'] = '127.0.0.1' + middleware.process_request(request) + self.assertTrue(request.user.is_superuser) + class SecurityRulesTests(TestCase): """ From e1e17dc06a0a4fbaff03019c922457c03efc5f68 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Nov 2022 15:04:09 +0100 Subject: [PATCH 192/330] [Fixes #10251] improve feedback to the user and UI experience of batch permisisons assignment (#10281) (#10282) * [Fixes #10251] improve feedback to the user and UI experience * [Fixes #10251] improve feedback to the user and UI experience * [Fixes #10251] improve feedback to the user and UI experience Co-authored-by: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> --- geonode/base/views.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/geonode/base/views.py b/geonode/base/views.py index 7d5d08aa692..0a7f6fa8b43 100644 --- a/geonode/base/views.py +++ b/geonode/base/views.py @@ -333,6 +333,20 @@ def get_results(self, context): ] +class DatasetsAutocomplete(SimpleSelect2View): + model = Dataset + filter_arg = 'title__icontains' + + def get_results(self, context): + return [ + { + 'id': self.get_result_value(result), + 'text': self.get_result_label(result.title), + 'selected_text': self.get_selected_result_label(result.title), + } for result in context['object_list'] + ] + + class ThesaurusAvailable(autocomplete.Select2QuerySetView): def get_queryset(self): tid = self.request.GET.get("sysid") From 41de59aec19ef016cac91d07f8fe0f2fffe4eae7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Nov 2022 17:10:36 +0100 Subject: [PATCH 193/330] [Dependencies] Align setup.cfg to requirements.txt (#10284) (#10286) Co-authored-by: Alessio Fabiani --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 3fc13d93b40..04c8832e443 100644 --- a/setup.cfg +++ b/setup.cfg @@ -204,7 +204,7 @@ install_requires = # Security and audit mistune==2.0.4 - wandb==0.13.4 + wandb==0.13.5 protobuf==3.20.3 mako==1.2.3 From ac19068938c8a893e1cdaf411bdac93d9ad94b30 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 14 Nov 2022 22:05:05 +0100 Subject: [PATCH 194/330] [Fixes #10270] Document creation via API v2 (#10271) (#10298) * [Fixes #10270] Document creation via API v2 * [Fixes #10270] Document creation via API v2 * [Fixes #10270] Document creation via API v2 * [Fixes #10270] Document creation via API v2 * [Fixes #10270] Document creation via API v2 * [Fixes #10270] Document creation via API v2 * [Fixes #10270] Document creation via API v2 Co-authored-by: Alessio Fabiani Co-authored-by: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Co-authored-by: Alessio Fabiani --- geonode/documents/api/tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/geonode/documents/api/tests.py b/geonode/documents/api/tests.py index dbb3b13588b..5ab59dfbbb4 100644 --- a/geonode/documents/api/tests.py +++ b/geonode/documents/api/tests.py @@ -16,6 +16,8 @@ # along with this program. If not, see . # ######################################################################### +import os +from django.contrib.auth import get_user_model import logging from django.contrib.auth import get_user_model From ff264770acf114fd69a8ed59a969ce4cc431f417 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Nov 2022 17:33:14 +0100 Subject: [PATCH 195/330] [Fixes #10303] automatic periodic TaskResult removal (#10306) (#10319) * [Fixes #10303] automatic periodic TaskResult removal * [Fixes #10303] automatic periodic TaskResult removal * [Fixes #10303] automatic periodic TaskResult removal * [Fixes #10303] fix flake8 formatting Co-authored-by: Alessio Fabiani Co-authored-by: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Co-authored-by: Alessio Fabiani --- geonode/upload/__init__.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/geonode/upload/__init__.py b/geonode/upload/__init__.py index c50c3f5609f..ec9e9b79a26 100644 --- a/geonode/upload/__init__.py +++ b/geonode/upload/__init__.py @@ -63,6 +63,19 @@ def run_setup_hooks(sender, **kwargs): start_time=timezone.now(), ), ) + daily_interval, _ = IntervalSchedule.objects.get_or_create( + every=1, + period="days" + ) + PeriodicTask.objects.update_or_create( + name="clean-up-old-task-result", + defaults=dict( + task="geonode.upload.tasks.cleanup_celery_task_entries", + interval=daily_interval, + args='', + start_time=timezone.now() + ) + ) class UploadAppConfig(AppConfig): @@ -79,6 +92,10 @@ def ready(self): "task": "geonode.upload.tasks.cleanup_celery_task_entries", "schedule": 86400.0, } + settings.CELERY_BEAT_SCHEDULE['clean-up-old-task-result'] = { + 'task': 'geonode.upload.tasks.cleanup_celery_task_entries', + 'schedule': 86400.0, + } default_app_config = "geonode.upload.UploadAppConfig" From 1803b7866e0fad49c9f118bf20fc569af68bb68e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Nov 2022 12:22:48 +0100 Subject: [PATCH 196/330] [Dependencies] Align setup.cfg with requirements.txt (#10339) (#10341) (#10344) Co-authored-by: Alessio Fabiani Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 04c8832e443..4fedf52c369 100644 --- a/setup.cfg +++ b/setup.cfg @@ -206,7 +206,7 @@ install_requires = mistune==2.0.4 wandb==0.13.5 protobuf==3.20.3 - mako==1.2.3 + mako==1.2.4 [options.packages.find] exclude = tests From d4bda0f14339ae3e5185b6f032dd7344ab634dcd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 14 Dec 2022 12:25:33 +0100 Subject: [PATCH 197/330] [Dependencies] Align setup.cfg to requirements.txt (#10417) (#10419) Co-authored-by: Alessio Fabiani --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 4fedf52c369..9d972a40f7c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -204,7 +204,7 @@ install_requires = # Security and audit mistune==2.0.4 - wandb==0.13.5 + wandb==0.13.6 protobuf==3.20.3 mako==1.2.4 From bb4e53c3721e2fbe64aa63e0f72bd0e28ab628af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Dec 2022 12:15:39 +0100 Subject: [PATCH 198/330] [Dependencies] Align setup.cfg to requirements.txt (#10444) (#10445) Co-authored-by: Alessio Fabiani --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 9d972a40f7c..91c2bfcedd9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -204,7 +204,7 @@ install_requires = # Security and audit mistune==2.0.4 - wandb==0.13.6 + wandb==0.13.7 protobuf==3.20.3 mako==1.2.4 From 47035eb9b66b52116bb8e6358e6bb78815ffffc4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Jan 2023 10:27:51 +0100 Subject: [PATCH 199/330] [Dependencies] Align "setup.cfg" to "requirements.txt" (#10555) (#10558) (cherry picked from commit 33d4aa9b4fca04f5cc282b5607ff5d1e72e80e6e) Co-authored-by: Alessio Fabiani --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 91c2bfcedd9..3509d6bfae7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -204,7 +204,7 @@ install_requires = # Security and audit mistune==2.0.4 - wandb==0.13.7 + wandb==0.13.9 protobuf==3.20.3 mako==1.2.4 From 6ebdd0902bf5583510756868d9b59d0225270c6b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 23 Jan 2023 11:21:53 +0100 Subject: [PATCH 200/330] [Fixes #10537] Improve rules creation using GeoFence batch (#10538) (#10567) * [Fixes #10537] Improve rules creation using GeoFence batch * - code improvements accordingly to the PR comments * - code improvements accordingly to the PR comments * - Test fixes Co-authored-by: afabiani (cherry picked from commit d101ead2e2552f3d4f9b6cd355ce1eced3dbaecf) Co-authored-by: Emanuele Tajariol --- geonode/geoserver/helpers.py | 1 + geonode/security/tests.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/geonode/geoserver/helpers.py b/geonode/geoserver/helpers.py index e46179532c1..30dc6bffa06 100755 --- a/geonode/geoserver/helpers.py +++ b/geonode/geoserver/helpers.py @@ -1874,6 +1874,7 @@ def get_time_info(layer): url, _user, _password, retries=ogc_server_settings.MAX_RETRIES, backoff_factor=ogc_server_settings.BACKOFF_FACTOR ) gs_uploader = Client(url, _user, _password) +gf_client = GeofenceClient(url, _user, _password) def _create_geofence_client(): diff --git a/geonode/security/tests.py b/geonode/security/tests.py index 3865528f803..4651e1f28d0 100644 --- a/geonode/security/tests.py +++ b/geonode/security/tests.py @@ -88,6 +88,16 @@ def _log(msg, *args): logger.debug(msg, *args) +def get_geofence_rules_count(): + from geonode.geoserver.helpers import gf_client + return gf_client.get_rules_count() + + +def get_geofence_rules(): + from geonode.geoserver.helpers import gf_client + return gf_client.get_rules() + + class StreamToLogger: """ Fake file-like stream object that redirects writes to a logger instance. From 554df0b7ada76a0897f48db30034ef0cc1e514cc Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Tue, 24 Jan 2023 12:06:22 +0100 Subject: [PATCH 201/330] Backport 10575 to 4.1.x (#10579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump oauthlib from 3.2.0 to 3.2.1 (#9998) Bumps [oauthlib](https://github.com/oauthlib/oauthlib) from 3.2.0 to 3.2.1. - [Release notes](https://github.com/oauthlib/oauthlib/releases) - [Changelog](https://github.com/oauthlib/oauthlib/blob/master/CHANGELOG.rst) - [Commits](https://github.com/oauthlib/oauthlib/compare/v3.2.0...v3.2.1) --- updated-dependencies: - dependency-name: oauthlib dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump jsonschema from 4.15.0 to 4.16.0 (#9999) Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.15.0 to 4.16.0. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.15.0...v4.16.0) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pytest-splinter from 3.3.1 to 3.3.2 (#10000) Bumps [pytest-splinter](https://github.com/pytest-dev/pytest-splinter) from 3.3.1 to 3.3.2. - [Release notes](https://github.com/pytest-dev/pytest-splinter/releases) - [Changelog](https://github.com/pytest-dev/pytest-splinter/blob/master/CHANGES.rst) - [Commits](https://github.com/pytest-dev/pytest-splinter/compare/3.3.1...3.3.2) --- updated-dependencies: - dependency-name: pytest-splinter dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump dropbox from 11.33.0 to 11.34.0 (#10001) Bumps [dropbox](https://github.com/dropbox/dropbox-sdk-python) from 11.33.0 to 11.34.0. - [Release notes](https://github.com/dropbox/dropbox-sdk-python/releases) - [Commits](https://github.com/dropbox/dropbox-sdk-python/compare/v11.33.0...v11.34.0) --- updated-dependencies: - dependency-name: dropbox dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump django-select2 from 7.10.0 to 7.10.1 (#10006) Bumps [django-select2](https://github.com/codingjoe/django-select2) from 7.10.0 to 7.10.1. - [Release notes](https://github.com/codingjoe/django-select2/releases) - [Commits](https://github.com/codingjoe/django-select2/compare/7.10.0...7.10.1) --- updated-dependencies: - dependency-name: django-select2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update pyproj requirement from <3.3.0 to <3.5.0 (#10002) Updates the requirements on [pyproj](https://github.com/pyproj4/pyproj) to permit the latest version. - [Release notes](https://github.com/pyproj4/pyproj/releases) - [Changelog](https://github.com/pyproj4/pyproj/blob/main/docs/history.rst) - [Commits](https://github.com/pyproj4/pyproj/compare/v1.9.4rel...3.4.0) --- updated-dependencies: - dependency-name: pyproj dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump sqlalchemy from 1.4.40 to 1.4.41 (#10005) Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 1.4.40 to 1.4.41. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump twisted from 22.4.0 to 22.8.0 (#10007) Bumps [twisted](https://github.com/twisted/twisted) from 22.4.0 to 22.8.0. - [Release notes](https://github.com/twisted/twisted/releases) - [Changelog](https://github.com/twisted/twisted/blob/trunk/NEWS.rst) - [Commits](https://github.com/twisted/twisted/compare/twisted-22.4.0...twisted-22.8.0) --- updated-dependencies: - dependency-name: twisted dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump ipython from 8.4.0 to 8.5.0 (#10004) Bumps [ipython](https://github.com/ipython/ipython) from 8.4.0 to 8.5.0. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/compare/8.4.0...8.5.0) --- updated-dependencies: - dependency-name: ipython dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.24.66 to 1.24.70 (#10003) Bumps [boto3](https://github.com/boto/boto3) from 1.24.66 to 1.24.70. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.24.66...1.24.70) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Dependencies] Align setup.cfg to requirements.txt (#10019) * Bump to version 4.0.1 (cherry picked from commit 43cb8049b488b23bbc4886ca9e28d95f8329cf65) * Bump to version 4.0.2 dev 0 (cherry picked from commit c4bd604e1aa453cd9c6b718792e156e8bdf86e3b) # Conflicts: # requirements.txt # setup.cfg * Bump boto3 from 1.24.70 to 1.24.75 (#10031) Bumps [boto3](https://github.com/boto/boto3) from 1.24.70 to 1.24.75. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.24.70...1.24.75) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump smart-open from 6.1.0 to 6.2.0 (#10030) Bumps [smart-open](https://github.com/piskvorky/smart_open) from 6.1.0 to 6.2.0. - [Release notes](https://github.com/piskvorky/smart_open/releases) - [Changelog](https://github.com/RaRe-Technologies/smart_open/blob/develop/CHANGELOG.md) - [Commits](https://github.com/piskvorky/smart_open/compare/v6.1.0...v6.2.0) --- updated-dependencies: - dependency-name: smart-open dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump drf-spectacular from 0.23.1 to 0.24.0 (#10029) Bumps [drf-spectacular](https://github.com/tfranzel/drf-spectacular) from 0.23.1 to 0.24.0. - [Release notes](https://github.com/tfranzel/drf-spectacular/releases) - [Changelog](https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tfranzel/drf-spectacular/compare/0.23.1...0.24.0) --- updated-dependencies: - dependency-name: drf-spectacular dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update idna requirement from <2.11,>=2.5 to >=2.5,<3.5 (#10028) Updates the requirements on [idna](https://github.com/kjd/idna) to permit the latest version. - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst) - [Commits](https://github.com/kjd/idna/compare/v2.5...v3.4) --- updated-dependencies: - dependency-name: idna dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pyjwt from 2.4.0 to 2.5.0 (#10027) Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.4.0 to 2.5.0. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/compare/2.4.0...2.5.0) --- updated-dependencies: - dependency-name: pyjwt dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * [Fixes #10024] Wrong default style when creating layer (#10035) (cherry picked from commit 40fae5b3240ddf5b033688ce1891d7e7f1cfc304) Co-authored-by: Alessio Fabiani * add installation type to issue template (#10042) * [Dependencies] Align setup.cfg with requirements.txt (#10038) * [Fixes #10040] Remove auto-generated thumbnail for documents (#10045) * -[Fixes #10040] Remove auto-generated thumbnail for documents * - add migration to clean available thumbs * - fix migration * - modify functionality * Bump drf-spectacular from 0.24.0 to 0.24.1 (#10051) Bumps [drf-spectacular](https://github.com/tfranzel/drf-spectacular) from 0.24.0 to 0.24.1. - [Release notes](https://github.com/tfranzel/drf-spectacular/releases) - [Changelog](https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tfranzel/drf-spectacular/compare/0.24.0...0.24.1) --- updated-dependencies: - dependency-name: drf-spectacular dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * Update setuptools requirement from <65.4.0,>=59.1.1 to >=59.1.1,<65.5.0 (#10047) Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/CHANGES.rst) - [Commits](https://github.com/pypa/setuptools/compare/v59.1.1...v65.4.0) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.24.75 to 1.24.80 (#10050) Bumps [boto3](https://github.com/boto/boto3) from 1.24.75 to 1.24.80. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.24.75...1.24.80) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pyopenssl from 22.0.0 to 22.1.0 (#10049) Bumps [pyopenssl](https://github.com/pyca/pyopenssl) from 22.0.0 to 22.1.0. - [Release notes](https://github.com/pyca/pyopenssl/releases) - [Changelog](https://github.com/pyca/pyopenssl/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/pyopenssl/compare/22.0.0...22.1.0) --- updated-dependencies: - dependency-name: pyopenssl dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * Bump djangorestframework from 3.12.0 to 3.14.0 (#10048) Bumps [djangorestframework](https://github.com/encode/django-rest-framework) from 3.12.0 to 3.14.0. - [Release notes](https://github.com/encode/django-rest-framework/releases) - [Commits](https://github.com/encode/django-rest-framework/compare/3.12.0...3.14.0) --- updated-dependencies: - dependency-name: djangorestframework dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Revert "Bump djangorestframework from 3.12.0 to 3.14.0 (#10048)" (#10062) This reverts commit 75566560538b38c59873ed9fb7951acc3ba0edfa. * [Dependencies] Align setup.cfg with requirements.txt (#10061) * [Dependencies] Align setup.cfg with requirements.txt * Revert "Bump djangorestframework from 3.12.0 to 3.14.0" * [CLA] Add "edsonflavio" to .clabot * Complete Translate pt_BR (#10056) Tradução completa da localização para Português Brasileiro. Complete translate for pt_BR localization. Co-authored-by: Alessio Fabiani * [Fixes #10041] Review the thumbnail scaling process (#10046) * -[Fixes #10040] Remove auto-generated thumbnail for documents * - update thumbnail pixels * - add tests * - fix-tests * - fix-tests * [Fixes #10066][Depencendies] Security audit and checks (#10067) * [Fixes #10066][Depencendies] Security audit and checks * -SNYK security fix * [Fixes #10055] Modify Metadata form with permissions check (#10057) * -[Fixes #10055] Modify Metadata form with permissions check * - check user in form * - update tests * - add tests Co-authored-by: Giovanni Allegri * Bump django-mptt from 0.13.4 to 0.14.0 (#10081) Bumps [django-mptt](https://github.com/django-mptt/django-mptt) from 0.13.4 to 0.14.0. - [Release notes](https://github.com/django-mptt/django-mptt/releases) - [Changelog](https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst) - [Commits](https://github.com/django-mptt/django-mptt/compare/0.13.4...0.14) --- updated-dependencies: - dependency-name: django-mptt dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Fixes #10070] Let the resource manager handle also raw sld (#10071) * [Fixes #10070] Let the resource manager handle also raw sld * [Fixes #10070] Let the resource manager handle also raw sld * [Fixes #10070] Let the resource manager handle also raw sld * Update django-invitations requirement from <1.9.4 to <2.0.1 (#10084) * Update django-invitations requirement from <1.9.4 to <2.0.1 Updates the requirements on [django-invitations](https://github.com/jazzband/django-invitations) to permit the latest version. - [Release notes](https://github.com/jazzband/django-invitations/releases) - [Changelog](https://github.com/jazzband/django-invitations/blob/master/CHANGELOG.md) - [Commits](https://github.com/jazzband/django-invitations/compare/0.1...2.0.0) --- updated-dependencies: - dependency-name: django-invitations dependency-type: direct:production ... Signed-off-by: dependabot[bot] * - fix the adapter Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani * fix: requirements.txt to reduce vulnerabilities (#10099) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-DJANGO-3039675 * Bump drf-spectacular from 0.24.1 to 0.24.2 (#10088) Bumps [drf-spectacular](https://github.com/tfranzel/drf-spectacular) from 0.24.1 to 0.24.2. - [Release notes](https://github.com/tfranzel/drf-spectacular/releases) - [Changelog](https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tfranzel/drf-spectacular/compare/0.24.1...0.24.2) --- updated-dependencies: - dependency-name: drf-spectacular dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * Bump invoke from 1.7.1 to 1.7.3 (#10086) Bumps [invoke](https://github.com/pyinvoke/invoke) from 1.7.1 to 1.7.3. - [Release notes](https://github.com/pyinvoke/invoke/releases) - [Commits](https://github.com/pyinvoke/invoke/compare/1.7.1...1.7.3) --- updated-dependencies: - dependency-name: invoke dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump protobuf from 4.21.6 to 4.21.7 (#10085) Bumps [protobuf](https://github.com/protocolbuffers/protobuf) from 4.21.6 to 4.21.7. - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Changelog](https://github.com/protocolbuffers/protobuf/blob/main/generate_changelog.py) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: protobuf dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pytz from 2022.2.1 to 2022.4 (#10083) Bumps [pytz](https://github.com/stub42/pytz) from 2022.2.1 to 2022.4. - [Release notes](https://github.com/stub42/pytz/releases) - [Commits](https://github.com/stub42/pytz/commits) --- updated-dependencies: - dependency-name: pytz dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * -[Fixes #10073] Unable to view Maps tab in Recent activities (#10074) Co-authored-by: Giovanni Allegri Co-authored-by: Alessio Fabiani * Bump mistune from 2.0.3 to 2.0.4 (#10082) Bumps [mistune](https://github.com/lepture/mistune) from 2.0.3 to 2.0.4. - [Release notes](https://github.com/lepture/mistune/releases) - [Changelog](https://github.com/lepture/mistune/blob/v2.0.4/docs/changes.rst) - [Commits](https://github.com/lepture/mistune/compare/v2.0.3...v2.0.4) --- updated-dependencies: - dependency-name: mistune dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * [Fixes #10091] improve thumbnails quality (#10092) * improve thumbnails quality * force convertion to jpeg * - update tests * - fix tests Co-authored-by: marthamareal Co-authored-by: Alessio Fabiani * [Fixes #10075] Improvements to the upload time step UI (#10094) * -[Fixes #10075] Improvements to the upload time step UI * clarify why user is inside this step Co-authored-by: Giovanni Allegri Co-authored-by: Alessio Fabiani * Bump coverage from 6.4.4 to 6.5.0 (#10079) Bumps [coverage](https://github.com/nedbat/coveragepy) from 6.4.4 to 6.5.0. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/6.4.4...6.5.0) --- updated-dependencies: - dependency-name: coverage dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * [Fixes #10113] Data retriver keep kmz files even if is unzipped (#10114) * [Fixes #10113] Data retriever keeps kmz files even if are unzipped * Bump sherlock from 0.3.2 to 0.4.0 (#10116) Bumps [sherlock](https://github.com/py-sherlock/sherlock) from 0.3.2 to 0.4.0. - [Release notes](https://github.com/py-sherlock/sherlock/releases) - [Changelog](https://github.com/py-sherlock/sherlock/blob/master/CHANGELOG.rst) - [Commits](https://github.com/py-sherlock/sherlock/commits/v0.4.0) --- updated-dependencies: - dependency-name: sherlock dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * -[Fixes #10104] Sort resource APIs with created date (#10105) Co-authored-by: Alessio Fabiani * Bump requests-toolbelt from 0.9.1 to 0.10.0 (#10118) Bumps [requests-toolbelt](https://github.com/requests/toolbelt) from 0.9.1 to 0.10.0. - [Release notes](https://github.com/requests/toolbelt/releases) - [Changelog](https://github.com/requests/toolbelt/blob/master/HISTORY.rst) - [Commits](https://github.com/requests/toolbelt/compare/0.9.1...0.10.0) --- updated-dependencies: - dependency-name: requests-toolbelt dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * Bump psycopg2 from 2.9.3 to 2.9.4 (#10117) Bumps [psycopg2](https://github.com/psycopg/psycopg2) from 2.9.3 to 2.9.4. - [Release notes](https://github.com/psycopg/psycopg2/releases) - [Changelog](https://github.com/psycopg/psycopg2/blob/master/NEWS) - [Commits](https://github.com/psycopg/psycopg2/commits) --- updated-dependencies: - dependency-name: psycopg2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.24.80 to 1.24.87 (#10107) Bumps [boto3](https://github.com/boto/boto3) from 1.24.80 to 1.24.87. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.24.80...1.24.87) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * [Dependencies] Align setup.cfg to requirements.txt (#10124) * [Fixes #10120] Celery autoscale values are too low and wrongly positioned (#10121) * [Fixes #7852] Update catalog and background layers url configuration in settings.py and related files (#9950) * -[Fixes #7852] Update catalog and background layers url configuration in settings.py and related files * - update geoid url * [Fixes #10130] Data retriever dont assign the folder/file permissions correcly (#10131) Co-authored-by: Alessio Fabiani * [Fixes #10134] New simple renderer to generate thumbnails for PDFs (#10135) * PDF thumbnail renderer * - add unit tests * command to generate thumbnails for docs * flake fix * renamed management command * add requirement to setup.cfg * make command similar to other sync commands * removed unused import * fix flake8 Co-authored-by: marthamareal * [Fixes #10138] Render pdf thumbnails from top margin (#10139) * render pdf thumbnails from top margin * fix flake * [Fixes #10142] storage_manager copy dont assign the folder/file permi… (#10143) * [Fixes #10142] storage_manager copy dont assign the folder/file permissions correcly * [Fixes #10142] storage_manager copy dont assign the folder/file permissions correcly * [Fixes #10142] storage_manager copy dont assign the folder/file permissions correcly * [Fixes #10142] storage_manager copy dont assign the folder/file permissions correcly * [Fixes #10142] storage_manager copy dont assign the folder/file permissions correcly * Bump wandb from 0.12.17 to 0.13.4 (#10152) Bumps [wandb](https://github.com/wandb/wandb) from 0.12.17 to 0.13.4. - [Release notes](https://github.com/wandb/wandb/releases) - [Changelog](https://github.com/wandb/wandb/blob/main/CHANGELOG.md) - [Commits](https://github.com/wandb/wandb/compare/v0.12.17...v0.13.4) --- updated-dependencies: - dependency-name: wandb dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * Update setuptools requirement from <65.5.0,>=59.1.1 to >=59.1.1,<65.6.0 (#10150) Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/CHANGES.rst) - [Commits](https://github.com/pypa/setuptools/compare/v59.1.1...v65.5.0) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pip from 22.2.2 to 22.3 (#10149) Bumps [pip](https://github.com/pypa/pip) from 22.2.2 to 22.3. - [Release notes](https://github.com/pypa/pip/releases) - [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/pip/compare/22.2.2...22.3) --- updated-dependencies: - dependency-name: pip dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump shapely from 1.8.4 to 1.8.5.post1 (#10147) Bumps [shapely](https://github.com/shapely/shapely) from 1.8.4 to 1.8.5.post1. - [Release notes](https://github.com/shapely/shapely/releases) - [Changelog](https://github.com/shapely/shapely/blob/1.8.5.post1/CHANGES.txt) - [Commits](https://github.com/shapely/shapely/compare/1.8.4...1.8.5.post1) --- updated-dependencies: - dependency-name: shapely dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump sqlalchemy from 1.4.41 to 1.4.42 (#10145) Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 1.4.41 to 1.4.42. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump dropbox from 11.34.0 to 11.35.0 (#10146) Bumps [dropbox](https://github.com/dropbox/dropbox-sdk-python) from 11.34.0 to 11.35.0. - [Release notes](https://github.com/dropbox/dropbox-sdk-python/releases) - [Commits](https://github.com/dropbox/dropbox-sdk-python/compare/v11.34.0...v11.35.0) --- updated-dependencies: - dependency-name: dropbox dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.24.87 to 1.24.91 (#10151) Bumps [boto3](https://github.com/boto/boto3) from 1.24.87 to 1.24.91. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.24.87...1.24.91) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Align requirements.txt comments with 4.x (#10162) * [Fixes #10168] Add 'executions' field to concrete instance endpoints (#10169) * [Fixes #10168] Add 'executions' field to concrete instance endpoints * [Fixes #10168] Add 'executions' field to concrete instance endpoints * [Fixes #10171] Dynamic rendering of document_detail view template (#10172) * add preview mode to document metadata page * Remove Return to button inside metadata detail page * [Hardening] Avoid "backup.py" failing while trying to set "w" perms on the GeoServer catalog file * Fix migrations 4x (#10176) * - Fixing migrations upg 33x -> 4x * fix migration * missing files * - Fixing migrations upg 33x -> 4x * - Fixing migrations upg 33x -> 4x Co-authored-by: mattiagiupponi * [Hardening] Make the migration '0074_drop_curated_thumbs' more robust * [Fixes #10198] uwsgi library update (#10199) * [Fixes #10195] Backup and restore procedure is not successful (#10196) * [Fixes #10195] Backup and restore procedure is not successful * [Fixes #10195] Backup and restore procedure is not successful * [Fixes #10195] Backup and restore procedure is not successful * [Fixes #10195] Backup and restore procedure is not successful * [Fixes #10195] Backup and restore procedure is not successful * [Fixes #10195] test fix build * [Fixes #10204] restore mapstore catalog settings (#10205) * [Fixes #10192] Include source into the ExecutionRequest model (#10193) Co-authored-by: Alessio Fabiani * Bump oauthlib from 3.2.1 to 3.2.2 (#10189) Bumps [oauthlib](https://github.com/oauthlib/oauthlib) from 3.2.1 to 3.2.2. - [Release notes](https://github.com/oauthlib/oauthlib/releases) - [Changelog](https://github.com/oauthlib/oauthlib/blob/v3.2.2/CHANGELOG.rst) - [Commits](https://github.com/oauthlib/oauthlib/compare/v3.2.1...v3.2.2) --- updated-dependencies: - dependency-name: oauthlib dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * fix ip whitelisting (#10218) * Update httplib2 requirement from <0.20.5 to <0.21.1 (#10227) Updates the requirements on [httplib2](https://github.com/httplib2/httplib2) to permit the latest version. - [Release notes](https://github.com/httplib2/httplib2/releases) - [Changelog](https://github.com/httplib2/httplib2/blob/master/CHANGELOG) - [Commits](https://github.com/httplib2/httplib2/compare/0.9...v0.21.0) --- updated-dependencies: - dependency-name: httplib2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * Bump pillow from 9.1.1 to 9.3.0 (#10229) Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.1.1 to 9.3.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/9.1.1...9.3.0) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump twisted from 22.8.0 to 22.10.0 (#10233) Bumps [twisted](https://github.com/twisted/twisted) from 22.8.0 to 22.10.0. - [Release notes](https://github.com/twisted/twisted/releases) - [Changelog](https://github.com/twisted/twisted/blob/trunk/NEWS.rst) - [Commits](https://github.com/twisted/twisted/compare/twisted-22.8.0...twisted-22.10.0) --- updated-dependencies: - dependency-name: twisted dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.24.91 to 1.26.0 (#10235) Bumps [boto3](https://github.com/boto/boto3) from 1.24.91 to 1.26.0. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.24.91...1.26.0) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pytz from 2022.4 to 2022.6 (#10234) Bumps [pytz](https://github.com/stub42/pytz) from 2022.4 to 2022.6. - [Release notes](https://github.com/stub42/pytz/releases) - [Commits](https://github.com/stub42/pytz/compare/release_2022.4...release_2022.6) --- updated-dependencies: - dependency-name: pytz dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump ipython from 8.5.0 to 8.6.0 (#10228) Bumps [ipython](https://github.com/ipython/ipython) from 8.5.0 to 8.6.0. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/compare/8.5.0...8.6.0) --- updated-dependencies: - dependency-name: ipython dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump requests-toolbelt from 0.10.0 to 0.10.1 (#10225) Bumps [requests-toolbelt](https://github.com/requests/toolbelt) from 0.10.0 to 0.10.1. - [Release notes](https://github.com/requests/toolbelt/releases) - [Changelog](https://github.com/requests/toolbelt/blob/master/HISTORY.rst) - [Commits](https://github.com/requests/toolbelt/compare/0.10.0...0.10.1) --- updated-dependencies: - dependency-name: requests-toolbelt dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pytest from 7.1.3 to 7.2.0 (#10224) Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.1.3 to 7.2.0. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/7.1.3...7.2.0) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump django-select2 from 7.10.1 to 7.11.0 (#10223) Bumps [django-select2](https://github.com/codingjoe/django-select2) from 7.10.1 to 7.11.0. - [Release notes](https://github.com/codingjoe/django-select2/releases) - [Commits](https://github.com/codingjoe/django-select2/compare/7.10.1...7.11.0) --- updated-dependencies: - dependency-name: django-select2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump psycopg2 from 2.9.4 to 2.9.5 (#10222) Bumps [psycopg2](https://github.com/psycopg/psycopg2) from 2.9.4 to 2.9.5. - [Release notes](https://github.com/psycopg/psycopg2/releases) - [Changelog](https://github.com/psycopg/psycopg2/blob/master/NEWS) - [Commits](https://github.com/psycopg/psycopg2/commits) --- updated-dependencies: - dependency-name: psycopg2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pyjwt from 2.5.0 to 2.6.0 (#10186) Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.5.0 to 2.6.0. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/commits) --- updated-dependencies: - dependency-name: pyjwt dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump psutil from 5.9.2 to 5.9.3 (#10185) Bumps [psutil](https://github.com/giampaolo/psutil) from 5.9.2 to 5.9.3. - [Release notes](https://github.com/giampaolo/psutil/releases) - [Changelog](https://github.com/giampaolo/psutil/blob/master/HISTORY.rst) - [Commits](https://github.com/giampaolo/psutil/compare/release-5.9.2...release-5.9.3) --- updated-dependencies: - dependency-name: psutil dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump webdriver-manager from 3.8.3 to 3.8.4 (#10184) Bumps [webdriver-manager](https://github.com/SergeyPirogov/webdriver_manager) from 3.8.3 to 3.8.4. - [Release notes](https://github.com/SergeyPirogov/webdriver_manager/releases) - [Changelog](https://github.com/SergeyPirogov/webdriver_manager/blob/master/CHANGELOG.md) - [Commits](https://github.com/SergeyPirogov/webdriver_manager/compare/v3.8.3...v3.8.4) --- updated-dependencies: - dependency-name: webdriver-manager dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: scripts/docker/nginx/Dockerfile to reduce vulnerabilities (#10180) The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-ALPINE316-CURL-3011748 - https://snyk.io/vuln/SNYK-ALPINE316-LIBXML2-3040799 - https://snyk.io/vuln/SNYK-ALPINE316-LIBXML2-3050523 - https://snyk.io/vuln/SNYK-ALPINE316-LIBXML2-3050527 - https://snyk.io/vuln/SNYK-ALPINE316-ZLIB-2976176 Co-authored-by: Alessio Fabiani * [Dependencies] Align "setup.cfg" to "requirements.txt" (#10242) * [Fixes #10251] Review geonode management command set_layers_permissions (#10252) * [Fixes #10251] Review geonode management command set_layers_permissions * [Fixes #10251] Review geonode management command set_layers_permissions * [Fixes #10251] Review geonode management command set_layers_permissions * [Fixes #10251] Review geonode management command set_layers_permissions * [Fixes #10251] Review geonode management command set_layers_permissions * [Fixes #10251] Review geonode management command set_layers_permissions * [Fixes #10251] Review geonode management command set_layers_permissions * [Hardening] Prevent migration 0034_maplayer_extra_params_and_current_style to fail abruptly in the case the maplayer has no styles associated with it * [Fixes #10263] non admin user in fresh instance cannot create resources_ (#10264) * [Fixes #10208] Add a custom hook at the end of the permissions assign (#10213) Co-authored-by: Alessio Fabiani * Fix broken test (#10266) * Fix broken test build * Fix broken test build * Bump GeoServer to version 2.20.6 (#10164) * [Fixes #10214] metadata_only filter not working properly (#10215) * [Fixes #10214] metadata_only filter not working properly * [Fixes #10214] metadata_only filter not working properly * [Fixes #10214] metadata_only filter not working properly Co-authored-by: Alessio Fabiani * Bump jsonschema from 4.16.0 to 4.17.0 (#10262) Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.16.0 to 4.17.0. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.16.0...v4.17.0) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * Bump pip from 22.3 to 22.3.1 (#10261) Bumps [pip](https://github.com/pypa/pip) from 22.3 to 22.3.1. - [Release notes](https://github.com/pypa/pip/releases) - [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/pip/compare/22.3...22.3.1) --- updated-dependencies: - dependency-name: pip dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pytest-bdd from 6.0.1 to 6.1.0 (#10260) Bumps [pytest-bdd](https://github.com/pytest-dev/pytest-bdd) from 6.0.1 to 6.1.0. - [Release notes](https://github.com/pytest-dev/pytest-bdd/releases) - [Changelog](https://github.com/pytest-dev/pytest-bdd/blob/master/CHANGES.rst) - [Commits](https://github.com/pytest-dev/pytest-bdd/compare/6.0.1...6.1.0) --- updated-dependencies: - dependency-name: pytest-bdd dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump django-select2 from 7.11.0 to 8.0.0 (#10259) Bumps [django-select2](https://github.com/codingjoe/django-select2) from 7.11.0 to 8.0.0. - [Release notes](https://github.com/codingjoe/django-select2/releases) - [Commits](https://github.com/codingjoe/django-select2/compare/7.11.0...8.0.0) --- updated-dependencies: - dependency-name: django-select2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump sqlalchemy from 1.4.42 to 1.4.43 (#10258) Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 1.4.42 to 1.4.43. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump docker from 6.0.0 to 6.0.1 (#10256) Bumps [docker](https://github.com/docker/docker-py) from 6.0.0 to 6.0.1. - [Release notes](https://github.com/docker/docker-py/releases) - [Commits](https://github.com/docker/docker-py/compare/6.0.0...6.0.1) --- updated-dependencies: - dependency-name: docker dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump wandb from 0.13.4 to 0.13.5 (#10255) Bumps [wandb](https://github.com/wandb/wandb) from 0.13.4 to 0.13.5. - [Release notes](https://github.com/wandb/wandb/releases) - [Changelog](https://github.com/wandb/wandb/blob/main/CHANGELOG.md) - [Commits](https://github.com/wandb/wandb/compare/v0.13.4...v0.13.5) --- updated-dependencies: - dependency-name: wandb dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Fixes #10251] improve feedback to the user and UI experience of batch permisisons assignment (#10281) * [Fixes #10251] improve feedback to the user and UI experience * [Fixes #10251] improve feedback to the user and UI experience * [Fixes #10251] improve feedback to the user and UI experience * Bump boto3 from 1.26.0 to 1.26.4 (#10273) Bumps [boto3](https://github.com/boto/boto3) from 1.26.0 to 1.26.4. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.0...1.26.4) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * [Dependencies] Align setup.cfg to requirements.txt (#10284) * [Fixes #10287] The "set_layer_permissions" management command does not behave correctly with "AnonyousUser" (#10288) * [Fixes #10287] The "set_layer_permissions" management command does not behave correctly with "AnonyousUser" * [Fixes #10270] Document creation via API v2 (#10271) * [Fixes #10270] Document creation via API v2 * [Fixes #10270] Document creation via API v2 * [Fixes #10270] Document creation via API v2 * [Fixes #10270] Document creation via API v2 * [Fixes #10270] Document creation via API v2 * [Fixes #10270] Document creation via API v2 * [Fixes #10270] Document creation via API v2 Co-authored-by: Alessio Fabiani * fix: requirements_tests.txt to reduce vulnerabilities (#10299) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-SETUPTOOLS-3113904 * Bump pytest-bdd from 6.1.0 to 6.1.1 (#10297) Bumps [pytest-bdd](https://github.com/pytest-dev/pytest-bdd) from 6.1.0 to 6.1.1. - [Release notes](https://github.com/pytest-dev/pytest-bdd/releases) - [Changelog](https://github.com/pytest-dev/pytest-bdd/blob/master/CHANGES.rst) - [Commits](https://github.com/pytest-dev/pytest-bdd/compare/6.1.0...6.1.1) --- updated-dependencies: - dependency-name: pytest-bdd dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alessio Fabiani * Bump pymupdf from 1.20.2 to 1.21.0 (#10296) Bumps [pymupdf](https://github.com/pymupdf/pymupdf) from 1.20.2 to 1.21.0. - [Release notes](https://github.com/pymupdf/pymupdf/releases) - [Changelog](https://github.com/pymupdf/PyMuPDF/blob/master/changes.txt) - [Commits](https://github.com/pymupdf/pymupdf/compare/1.20.2...1.21.0) --- updated-dependencies: - dependency-name: pymupdf dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump sqlalchemy from 1.4.43 to 1.4.44 (#10294) Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 1.4.43 to 1.4.44. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump psutil from 5.9.3 to 5.9.4 (#10293) Bumps [psutil](https://github.com/giampaolo/psutil) from 5.9.3 to 5.9.4. - [Release notes](https://github.com/giampaolo/psutil/releases) - [Changelog](https://github.com/giampaolo/psutil/blob/master/HISTORY.rst) - [Commits](https://github.com/giampaolo/psutil/compare/release-5.9.3...release-5.9.4) --- updated-dependencies: - dependency-name: psutil dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump dropbox from 11.35.0 to 11.36.0 (#10292) Bumps [dropbox](https://github.com/dropbox/dropbox-sdk-python) from 11.35.0 to 11.36.0. - [Release notes](https://github.com/dropbox/dropbox-sdk-python/releases) - [Commits](https://github.com/dropbox/dropbox-sdk-python/compare/v11.35.0...v11.36.0) --- updated-dependencies: - dependency-name: dropbox dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump google-cloud-storage from 2.5.0 to 2.6.0 (#10291) Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 2.5.0 to 2.6.0. - [Release notes](https://github.com/googleapis/python-storage/releases) - [Changelog](https://github.com/googleapis/python-storage/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/python-storage/compare/v2.5.0...v2.6.0) --- updated-dependencies: - dependency-name: google-cloud-storage dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.26.4 to 1.26.11 (#10312) Bumps [boto3](https://github.com/boto/boto3) from 1.26.4 to 1.26.11. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.4...1.26.11) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Dependencies] Align setup.cfg with requirements.txt (#10315) * [Fixes #10303] automatic periodic TaskResult removal (#10306) * [Fixes #10303] automatic periodic TaskResult removal * [Fixes #10303] automatic periodic TaskResult removal * [Fixes #10303] automatic periodic TaskResult removal * [Fixes #10303] fix flake8 formatting Co-authored-by: Alessio Fabiani * [Fixes #10302] Incorrect permissions assigned on cloning a resource (#10309) * [Fixes #10302] Incorrect permissions assigned on cloning a resource * [Fixes #10302] Incorrect permissions assigned on cloning a resource * [Fixes #10302] Incorrect permissions assigned on cloning a resource * [Fixes #10302] test fix broken tests * [Fixes #10302] fix flake8 formatting * [Fixes #10302] test fix broken tests * [Fixes #10302] fix flake8 * - Fix test case * [Fixes #10302] fix flake8 Co-authored-by: Alessio Fabiani * Create SECURITY.md (#10285) * Create SECURITY.md * Update SECURITY.md fix minor typo Co-authored-by: Florian Hoedt * Update setuptools requirement from <65.6.0,>=59.1.1 to >=59.1.1,<65.7.0 (#10327) Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/CHANGES.rst) - [Commits](https://github.com/pypa/setuptools/compare/v65.5.1...v65.6.0) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump python-slugify from 6.1.2 to 7.0.0 (#10322) Bumps [python-slugify](https://github.com/un33k/python-slugify) from 6.1.2 to 7.0.0. - [Release notes](https://github.com/un33k/python-slugify/releases) - [Changelog](https://github.com/un33k/python-slugify/blob/master/CHANGELOG.md) - [Commits](https://github.com/un33k/python-slugify/compare/v6.1.2...v7.0.0) --- updated-dependencies: - dependency-name: python-slugify dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.26.11 to 1.26.14 (#10330) Bumps [boto3](https://github.com/boto/boto3) from 1.26.11 to 1.26.14. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.11...1.26.14) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump mako from 1.2.3 to 1.2.4 (#10325) Bumps [mako](https://github.com/sqlalchemy/mako) from 1.2.3 to 1.2.4. - [Release notes](https://github.com/sqlalchemy/mako/releases) - [Changelog](https://github.com/sqlalchemy/mako/blob/main/CHANGES) - [Commits](https://github.com/sqlalchemy/mako/commits) --- updated-dependencies: - dependency-name: mako dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump selenium-requests from 2.0.0 to 2.0.1 (#10324) Bumps [selenium-requests]() from 2.0.0 to 2.0.1. --- updated-dependencies: - dependency-name: selenium-requests dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump webdriver-manager from 3.8.4 to 3.8.5 (#10323) Bumps [webdriver-manager](https://github.com/SergeyPirogov/webdriver_manager) from 3.8.4 to 3.8.5. - [Release notes](https://github.com/SergeyPirogov/webdriver_manager/releases) - [Changelog](https://github.com/SergeyPirogov/webdriver_manager/blob/master/CHANGELOG.md) - [Commits](https://github.com/SergeyPirogov/webdriver_manager/compare/v3.8.4...v3.8.5) --- updated-dependencies: - dependency-name: webdriver-manager dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Fixes #9041] Docker NGINX listen on ports 80/443 (#10338) * [Dependencies] Align setup.cfg with requirements.txt (#10339) * Bump django-grappelli from 3.0.3 to 3.0.4 (#10351) Bumps [django-grappelli](https://github.com/sehmaschine/django-grappelli) from 3.0.3 to 3.0.4. - [Release notes](https://github.com/sehmaschine/django-grappelli/releases) - [Changelog](https://github.com/sehmaschine/django-grappelli/blob/master/docs/changelog.rst) - [Commits](https://github.com/sehmaschine/django-grappelli/compare/3.0.3...3.0.4) --- updated-dependencies: - dependency-name: django-grappelli dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: requirements_dev.txt to reduce vulnerabilities (#10300) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-SETUPTOOLS-3113904 Co-authored-by: snyk-bot * changed bbox_polygon and llbox_polygone to read only in serializer for #10316 (#10317) Co-authored-by: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Co-authored-by: Giovanni Allegri * Bump urllib3 from 1.26.12 to 1.26.13 (#10350) Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.12 to 1.26.13. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/1.26.13/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.26.12...1.26.13) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump jsonschema from 4.17.0 to 4.17.1 (#10349) Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.17.0 to 4.17.1. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.17.0...v4.17.1) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.26.14 to 1.26.17 (#10354) Bumps [boto3](https://github.com/boto/boto3) from 1.26.14 to 1.26.17. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.14...1.26.17) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump flake8 from 5.0.4 to 6.0.0 (#10348) Bumps [flake8](https://github.com/pycqa/flake8) from 5.0.4 to 6.0.0. - [Release notes](https://github.com/pycqa/flake8/releases) - [Commits](https://github.com/pycqa/flake8/compare/5.0.4...6.0.0) --- updated-dependencies: - dependency-name: flake8 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Dependencies] Align "setup.cfg" to "requirements.txt" (#10363) * [CLA] Add MalteIwanicki to clabot (#10381) * Bump jsonschema from 4.17.1 to 4.17.3 (#10372) Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.17.1 to 4.17.3. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.17.1...v4.17.3) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump ipython from 8.6.0 to 8.7.0 (#10371) Bumps [ipython](https://github.com/ipython/ipython) from 8.6.0 to 8.7.0. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/compare/8.6.0...8.7.0) --- updated-dependencies: - dependency-name: ipython dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.26.17 to 1.26.26 (#10386) Bumps [boto3](https://github.com/boto/boto3) from 1.26.17 to 1.26.26. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.17...1.26.26) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Fixes #10376] Time serie dataset, missing permissions (#10377) * fix: requirements_dev.txt to reduce vulnerabilities (#10369) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-IPYTHON-2348630 - https://snyk.io/vuln/SNYK-PYTHON-PYGMENTS-1086606 - https://snyk.io/vuln/SNYK-PYTHON-PYGMENTS-1088505 - https://snyk.io/vuln/SNYK-PYTHON-SETUPTOOLS-3113904 * fix: upgrade react-hot-loader from 4.13.0 to 4.13.1 (#10370) Snyk has created this PR to upgrade react-hot-loader from 4.13.0 to 4.13.1. See this package in npm: https://www.npmjs.com/package/react-hot-loader See this project in Snyk: https://app.snyk.io/org/afabiani/project/4217120c-f173-454b-b620-3b8eaebc4a07?utm_source=github&utm_medium=referral&page=upgrade-pr * [Dependencies] Align setup.cfg to requirements.txt (#10397) * Bump sqlalchemy from 1.4.44 to 1.4.45 (#10402) Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 1.4.44 to 1.4.45. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump wandb from 0.13.5 to 0.13.6 (#10399) Bumps [wandb](https://github.com/wandb/wandb) from 0.13.5 to 0.13.6. - [Release notes](https://github.com/wandb/wandb/releases) - [Changelog](https://github.com/wandb/wandb/blob/main/CHANGELOG.md) - [Commits](https://github.com/wandb/wandb/compare/v0.13.5...v0.13.6) --- updated-dependencies: - dependency-name: wandb dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump selenium-requests from 2.0.1 to 2.0.2 (#10401) Bumps [selenium-requests]() from 2.0.1 to 2.0.2. --- updated-dependencies: - dependency-name: selenium-requests dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump google-cloud-storage from 2.6.0 to 2.7.0 (#10398) Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 2.6.0 to 2.7.0. - [Release notes](https://github.com/googleapis/python-storage/releases) - [Changelog](https://github.com/googleapis/python-storage/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/python-storage/compare/v2.6.0...v2.7.0) --- updated-dependencies: - dependency-name: google-cloud-storage dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.26.26 to 1.26.28 (#10407) Bumps [boto3](https://github.com/boto/boto3) from 1.26.26 to 1.26.28. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.26...1.26.28) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Dependencies] Align setup.cfg to requirements.txt (#10417) * [Fixes #10374] UUID resolver endpoint (#10375) * Bump lxml from 4.9.1 to 4.9.2 (#10433) Bumps [lxml](https://github.com/lxml/lxml) from 4.9.1 to 4.9.2. - [Release notes](https://github.com/lxml/lxml/releases) - [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt) - [Commits](https://github.com/lxml/lxml/compare/lxml-4.9.1...lxml-4.9.2) --- updated-dependencies: - dependency-name: lxml dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pymupdf from 1.21.0 to 1.21.1 (#10432) Bumps [pymupdf](https://github.com/pymupdf/pymupdf) from 1.21.0 to 1.21.1. - [Release notes](https://github.com/pymupdf/pymupdf/releases) - [Changelog](https://github.com/pymupdf/PyMuPDF/blob/1.21.1/changes.txt) - [Commits](https://github.com/pymupdf/pymupdf/compare/1.21.0...1.21.1) --- updated-dependencies: - dependency-name: pymupdf dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pytz from 2022.6 to 2022.7 (#10431) Bumps [pytz](https://github.com/stub42/pytz) from 2022.6 to 2022.7. - [Release notes](https://github.com/stub42/pytz/releases) - [Commits](https://github.com/stub42/pytz/compare/release_2022.6...release_2022.7) --- updated-dependencies: - dependency-name: pytz dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump wandb from 0.13.6 to 0.13.7 (#10430) Bumps [wandb](https://github.com/wandb/wandb) from 0.13.6 to 0.13.7. - [Release notes](https://github.com/wandb/wandb/releases) - [Changelog](https://github.com/wandb/wandb/blob/main/CHANGELOG.md) - [Commits](https://github.com/wandb/wandb/compare/v0.13.6...v0.13.7) --- updated-dependencies: - dependency-name: wandb dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump coverage from 6.5.0 to 7.0.0 (#10429) Bumps [coverage](https://github.com/nedbat/coveragepy) from 6.5.0 to 7.0.0. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/6.5.0...7.0.0) --- updated-dependencies: - dependency-name: coverage dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump drf-spectacular from 0.24.2 to 0.25.1 (#10428) Bumps [drf-spectacular](https://github.com/tfranzel/drf-spectacular) from 0.24.2 to 0.25.1. - [Release notes](https://github.com/tfranzel/drf-spectacular/releases) - [Changelog](https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tfranzel/drf-spectacular/compare/0.24.2...0.25.1) --- updated-dependencies: - dependency-name: drf-spectacular dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update numpy requirement from ==1.23.* to ==1.24.* (#10427) Updates the requirements on [numpy](https://github.com/numpy/numpy) to permit the latest version. - [Release notes](https://github.com/numpy/numpy/releases) - [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst) - [Commits](https://github.com/numpy/numpy/compare/v1.23.0rc1...v1.24.0) --- updated-dependencies: - dependency-name: numpy dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump dj-database-url from 1.0.0 to 1.2.0 (#10426) Bumps [dj-database-url](https://github.com/jazzband/dj-database-url) from 1.0.0 to 1.2.0. - [Release notes](https://github.com/jazzband/dj-database-url/releases) - [Changelog](https://github.com/jazzband/dj-database-url/blob/master/CHANGELOG.md) - [Commits](https://github.com/jazzband/dj-database-url/compare/v1.0.0...v1.2.0) --- updated-dependencies: - dependency-name: dj-database-url dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.26.28 to 1.26.32 (#10425) Bumps [boto3](https://github.com/boto/boto3) from 1.26.28 to 1.26.32. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.28...1.26.32) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump smart-open from 6.2.0 to 6.3.0 (#10424) Bumps [smart-open](https://github.com/piskvorky/smart_open) from 6.2.0 to 6.3.0. - [Release notes](https://github.com/piskvorky/smart_open/releases) - [Changelog](https://github.com/RaRe-Technologies/smart_open/blob/develop/CHANGELOG.md) - [Commits](https://github.com/piskvorky/smart_open/compare/v6.2.0...v6.3.0) --- updated-dependencies: - dependency-name: smart-open dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Dependencies] Align setup.cfg to requirements.txt (#10444) * Bump GeoNode to version 4.1.0 dev (#10461) * Bump GeoNode to version 4.1.0 dev (cherry picked from commit e6dd1f3e92f070d578a4a9fe29881e0f87abda3b) # Conflicts: # geonode/__init__.py * Bump GeoNode to version 4.1.0 dev (cherry picked from commit e6dd1f3e92f070d578a4a9fe29881e0f87abda3b) # Conflicts: # geonode/__init__.py * [Fixes #10462] GeoNode is vulnerable to an XML External Entity (XXE) injection (#10463) * [Fixes #10464] Fix code scanning alert - Uncontrolled data used in path expression (#10465) * [Fixes #10462] GeoNode is vulnerable to an XML External Entity (XXE) injection * [Fixes #10464] Fix code scanning alert - Uncontrolled data used in path expression * fix: Dockerfile to reduce vulnerabilities (#10470) The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-DEBIAN10-DPKG-2847944 - https://snyk.io/vuln/SNYK-DEBIAN10-OPENSSL-2807585 - https://snyk.io/vuln/SNYK-DEBIAN10-OPENSSL-2933515 - https://snyk.io/vuln/SNYK-DEBIAN10-PYTHON37-3090928 - https://snyk.io/vuln/SNYK-DEBIAN10-XZUTILS-2444279 Co-authored-by: snyk-bot * Create codeql.yml * Create pyre.yml * Create pysa.yml * [Fixes #10472] Fix code scanning alert - Polynomial regular expression used on uncontrolled data (#10473) * [Fixes #10472] Fix code scanning alert - Polynomial regular expression used on uncontrolled data * - Get rid of pyre and pysa workflows * Update mock requirement from <5.0.0 to <6.0.0 (#10480) Updates the requirements on [mock](https://github.com/testing-cabal/mock) to permit the latest version. - [Release notes](https://github.com/testing-cabal/mock/releases) - [Changelog](https://github.com/testing-cabal/mock/blob/master/CHANGELOG.rst) - [Commits](https://github.com/testing-cabal/mock/compare/release-0.5.0...5.0.0) --- updated-dependencies: - dependency-name: mock dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump django-storages from 1.13.1 to 1.13.2 (#10477) Bumps [django-storages](https://github.com/jschneier/django-storages) from 1.13.1 to 1.13.2. - [Release notes](https://github.com/jschneier/django-storages/releases) - [Changelog](https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jschneier/django-storages/compare/1.13.1...1.13.2) --- updated-dependencies: - dependency-name: django-storages dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump django-allauth from 0.51.0 to 0.52.0 (#10479) Bumps [django-allauth](https://github.com/pennersr/django-allauth) from 0.51.0 to 0.52.0. - [Release notes](https://github.com/pennersr/django-allauth/releases) - [Changelog](https://github.com/pennersr/django-allauth/blob/master/ChangeLog.rst) - [Commits](https://github.com/pennersr/django-allauth/compare/0.51.0...0.52.0) --- updated-dependencies: - dependency-name: django-allauth dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump whitenoise from 6.2.0 to 6.3.0 (#10496) Bumps [whitenoise](https://github.com/evansd/whitenoise) from 6.2.0 to 6.3.0. - [Release notes](https://github.com/evansd/whitenoise/releases) - [Changelog](https://github.com/evansd/whitenoise/blob/main/docs/changelog.rst) - [Commits](https://github.com/evansd/whitenoise/compare/6.2.0...6.3.0) --- updated-dependencies: - dependency-name: whitenoise dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pyopenssl from 22.1.0 to 23.0.0 (#10489) Bumps [pyopenssl](https://github.com/pyca/pyopenssl) from 22.1.0 to 23.0.0. - [Release notes](https://github.com/pyca/pyopenssl/releases) - [Changelog](https://github.com/pyca/pyopenssl/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/pyopenssl/compare/22.1.0...23.0.0) --- updated-dependencies: - dependency-name: pyopenssl dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pillow from 9.3.0 to 9.4.0 (#10495) Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.3.0 to 9.4.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/9.3.0...9.4.0) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump sqlalchemy from 1.4.45 to 1.4.46 (#10490) Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 1.4.45 to 1.4.46. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump ipython from 8.7.0 to 8.8.0 (#10491) Bumps [ipython](https://github.com/ipython/ipython) from 8.7.0 to 8.8.0. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/compare/8.7.0...8.8.0) --- updated-dependencies: - dependency-name: ipython dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump django-treebeard from 4.5.1 to 4.6.0 (#10492) Bumps [django-treebeard](https://github.com/django-treebeard/django-treebeard) from 4.5.1 to 4.6.0. - [Release notes](https://github.com/django-treebeard/django-treebeard/releases) - [Changelog](https://github.com/django-treebeard/django-treebeard/blob/master/CHANGES.md) - [Commits](https://github.com/django-treebeard/django-treebeard/compare/4.5.1...4.6.0) --- updated-dependencies: - dependency-name: django-treebeard dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump coverage from 7.0.0 to 7.0.4 (#10493) Bumps [coverage](https://github.com/nedbat/coveragepy) from 7.0.0 to 7.0.4. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.0.0...7.0.4) --- updated-dependencies: - dependency-name: coverage dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.26.32 to 1.26.45 (#10494) Bumps [boto3](https://github.com/boto/boto3) from 1.26.32 to 1.26.45. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.32...1.26.45) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Dependencies] Align "setup.cfg" to "requirements.txt" (#10507) * Update backport.yml * [Fixes #10509] Configure UTF-8 encoding for shapefiles in Geoserver by default (#10510) * [CLA] add 52North staff to .clabot (#10512) * fix: scripts/docker/nginx/Dockerfile to reduce vulnerabilities (#10513) The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-ALPINE316-CURL-3179541 - https://snyk.io/vuln/SNYK-ALPINE316-CURL-3179541 - https://snyk.io/vuln/SNYK-ALPINE316-CURL-3179542 - https://snyk.io/vuln/SNYK-ALPINE316-CURL-3179542 * Update README.md * [Fixes #7181] fix ISO 19139 metadata validity (#10488) * Adding "ahmdthr" to .clabot * Bump requests from 2.28.1 to 2.28.2 (#10528) Bumps [requests](https://github.com/psf/requests) from 2.28.1 to 2.28.2. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.28.1...v2.28.2) --- updated-dependencies: - dependency-name: requests dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update setuptools requirement from <65.7.0,>=59.1.1 to >=59.1.1,<66.1.0 (#10542) Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/CHANGES.rst) - [Commits](https://github.com/pypa/setuptools/compare/v65.5.1...v66.0.0) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump urllib3 from 1.26.13 to 1.26.14 (#10531) Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.13 to 1.26.14. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.26.13...1.26.14) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump selenium-requests from 2.0.2 to 2.0.3 (#10536) Bumps [selenium-requests]() from 2.0.2 to 2.0.3. --- updated-dependencies: - dependency-name: selenium-requests dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Fixes #10524] Resource permissions are not returned in the users/groups endpoint (#10544) * [Fixes #10524] Resource permissions are not returned in the users/groups endpoint * [Fixes #10524] Resource permissions are not returned in the users/groups endpoint * [Fixes #10482] Upload ISO-19115 xml metadata via the API (#10483) * ISO-19115 xml metadata file can be uploaded via the API * Small changes for PEP8 compliance * Added tests for checking permissions for metadata upload Co-authored-by: Alessio Fabiani * [Fixes #10525] set/unset batch permissions on groups raise an error (#10526) * [Fixes #10525] set/unset batch permissions on groups raise an error * [Fixes #10525] set/unset batch permissions on groups raise an error * Bump pytz from 2022.7 to 2022.7.1 (#10533) Bumps [pytz](https://github.com/stub42/pytz) from 2022.7 to 2022.7.1. - [Release notes](https://github.com/stub42/pytz/releases) - [Commits](https://github.com/stub42/pytz/compare/release_2022.7...release_2022.7.1) --- updated-dependencies: - dependency-name: pytz dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump wandb from 0.13.7 to 0.13.9 (#10530) Bumps [wandb](https://github.com/wandb/wandb) from 0.13.7 to 0.13.9. - [Release notes](https://github.com/wandb/wandb/releases) - [Changelog](https://github.com/wandb/wandb/blob/main/CHANGELOG.md) - [Commits](https://github.com/wandb/wandb/compare/v0.13.7...v0.13.9) --- updated-dependencies: - dependency-name: wandb dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump coverage from 7.0.4 to 7.0.5 (#10529) Bumps [coverage](https://github.com/nedbat/coveragepy) from 7.0.4 to 7.0.5. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.0.4...7.0.5) --- updated-dependencies: - dependency-name: coverage dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pytest from 7.2.0 to 7.2.1 (#10534) Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.2.0 to 7.2.1. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/7.2.0...7.2.1) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.26.45 to 1.26.50 (#10535) Bumps [boto3](https://github.com/boto/boto3) from 1.26.45 to 1.26.50. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.45...1.26.50) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Dependencies] Align "setup.cfg" to "requirements.txt" (#10555) * [Fixes #10537] Improve rules creation using GeoFence batch (#10538) * [Fixes #10537] Improve rules creation using GeoFence batch * - code improvements accordingly to the PR comments * - code improvements accordingly to the PR comments * - Test fixes Co-authored-by: afabiani * Update setuptools requirement from <66.1.0,>=59.1.1 to >=59.1.1,<66.2.0 (#10563) Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/CHANGES.rst) - [Commits](https://github.com/pypa/setuptools/compare/v65.5.1...v66.1.1) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump invoke from 1.7.3 to 2.0.0 (#10565) Bumps [invoke](https://github.com/pyinvoke/invoke) from 1.7.3 to 2.0.0. - [Release notes](https://github.com/pyinvoke/invoke/releases) - [Commits](https://github.com/pyinvoke/invoke/compare/1.7.3...2.0.0) --- updated-dependencies: - dependency-name: invoke dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump splinter from 0.18.1 to 0.19.0 (#10564) Bumps [splinter](https://github.com/cobrateam/splinter) from 0.18.1 to 0.19.0. - [Release notes](https://github.com/cobrateam/splinter/releases) - [Changelog](https://github.com/cobrateam/splinter/blob/master/docs/news.rst) - [Commits](https://github.com/cobrateam/splinter/compare/0.18.1...0.19.0) --- updated-dependencies: - dependency-name: splinter dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump boto3 from 1.26.50 to 1.26.54 (#10566) Bumps [boto3](https://github.com/boto/boto3) from 1.26.50 to 1.26.54. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.26.50...1.26.54) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [Dependencies] Align "setup.cfg" to "requirements.txt" (#10572) * [Fixes #10574] Code formatting with Black (#10575) * auto reformatting by black * relax flake8 * Add black to requirements * Add black to CircleCI * [Fixes #10574] Code formatting with Black (#10575) * auto reformatting by black * relax flake8 * Add black to requirements * Add black to CircleCI (cherry picked from commit e0b7ec94ebb97b0f3eb4fbe5cf82c71f11d0a8d3) # Conflicts: # geonode/base/api/serializers.py # geonode/catalogue/tests.py # geonode/catalogue/urls.py # geonode/catalogue/views.py # geonode/geoserver/manager.py # geonode/geoserver/signals.py * - Upgrade GeoServer to version 2.20.6 Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Emanuele Tajariol Co-authored-by: Giovanni Allegri Co-authored-by: NAGGINDA MARTHA Co-authored-by: Edson Flavio de Souza <44442399+edsonflavio@users.noreply.github.com> Co-authored-by: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Co-authored-by: Snyk bot Co-authored-by: mattiagiupponi Co-authored-by: Florian Hoedt Co-authored-by: Francesco Frassinelli Co-authored-by: Malte Iwanicki <45853662+MalteIwanicki@users.noreply.github.com> Co-authored-by: Toni Co-authored-by: Christian Autermann Co-authored-by: matthesrieke Co-authored-by: ahmdthr <116570171+ahmdthr@users.noreply.github.com> --- geonode/base/forms.py | 2 +- geonode/base/views.py | 11 ++++++----- geonode/geoserver/signals.py | 2 +- geonode/security/tests.py | 8 +++++--- geonode/storage/tests.py | 4 ++-- geonode/upload/__init__.py | 13 +++++++------ 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/geonode/base/forms.py b/geonode/base/forms.py index b0c91ff7d0f..b24ad8e935f 100644 --- a/geonode/base/forms.py +++ b/geonode/base/forms.py @@ -470,7 +470,7 @@ def __init__(self, *args, **kwargs): if field in ["poc", "owner"] and not self.can_change_perms: self.fields[field].disabled = True - if field in ['poc', 'owner'] and not self.can_change_perms: + if field in ["poc", "owner"] and not self.can_change_perms: self.fields[field].disabled = True def disable_keywords_widget_for_non_superuser(self, user): diff --git a/geonode/base/views.py b/geonode/base/views.py index 0a7f6fa8b43..30a55e8bff7 100644 --- a/geonode/base/views.py +++ b/geonode/base/views.py @@ -335,15 +335,16 @@ def get_results(self, context): class DatasetsAutocomplete(SimpleSelect2View): model = Dataset - filter_arg = 'title__icontains' + filter_arg = "title__icontains" def get_results(self, context): return [ { - 'id': self.get_result_value(result), - 'text': self.get_result_label(result.title), - 'selected_text': self.get_selected_result_label(result.title), - } for result in context['object_list'] + "id": self.get_result_value(result), + "text": self.get_result_label(result.title), + "selected_text": self.get_selected_result_label(result.title), + } + for result in context["object_list"] ] diff --git a/geonode/geoserver/signals.py b/geonode/geoserver/signals.py index 591a1e12552..cfe1ad6f85b 100644 --- a/geonode/geoserver/signals.py +++ b/geonode/geoserver/signals.py @@ -42,7 +42,7 @@ geofence_rule_assign = Signal(providing_args=["instance"]) -post_set_permissions = Signal(providing_args=['instance']) +geofence_rule_assign = Signal(providing_args=["instance"]) def geoserver_delete(typename): diff --git a/geonode/security/tests.py b/geonode/security/tests.py index 4651e1f28d0..546dbf4ab15 100644 --- a/geonode/security/tests.py +++ b/geonode/security/tests.py @@ -90,11 +90,13 @@ def _log(msg, *args): def get_geofence_rules_count(): from geonode.geoserver.helpers import gf_client + return gf_client.get_rules_count() def get_geofence_rules(): from geonode.geoserver.helpers import gf_client + return gf_client.get_rules() @@ -1759,11 +1761,11 @@ def test_admin_whitelisted_access_middleware(self): self.assertTrue(request.user.is_superuser) # Test valid IP in second element - with self.settings(ADMIN_IP_WHITELIST=['88.88.88.88', '127.0.0.1']): + with self.settings(ADMIN_IP_WHITELIST=["88.88.88.88", "127.0.0.1"]): request = HttpRequest() request.user = admin - request.path = reverse('home') - request.META['REMOTE_ADDR'] = '127.0.0.1' + request.path = reverse("home") + request.META["REMOTE_ADDR"] = "127.0.0.1" middleware.process_request(request) self.assertTrue(request.user.is_superuser) diff --git a/geonode/storage/tests.py b/geonode/storage/tests.py index c2e6c93b459..c4ba57817df 100644 --- a/geonode/storage/tests.py +++ b/geonode/storage/tests.py @@ -419,9 +419,9 @@ def test_storage_manager_copy(self): @override_settings(FILE_UPLOAD_DIRECTORY_PERMISSIONS=0o777) @override_settings(FILE_UPLOAD_PERMISSIONS=0o777) def test_storage_manager_copy(self): - ''' + """ Test that the copy works as expected and the permissions are corerct - ''' + """ dataset = create_single_dataset(name="test_copy") dataset.files = [os.path.join(f"{self.project_root}", "tests/data/test_sld.sld")] dataset.save() diff --git a/geonode/upload/__init__.py b/geonode/upload/__init__.py index ec9e9b79a26..f5c5c542718 100644 --- a/geonode/upload/__init__.py +++ b/geonode/upload/__init__.py @@ -67,14 +67,15 @@ def run_setup_hooks(sender, **kwargs): every=1, period="days" ) + daily_interval, _ = IntervalSchedule.objects.get_or_create(every=1, period="days") PeriodicTask.objects.update_or_create( name="clean-up-old-task-result", defaults=dict( task="geonode.upload.tasks.cleanup_celery_task_entries", interval=daily_interval, - args='', - start_time=timezone.now() - ) + args="", + start_time=timezone.now(), + ), ) @@ -92,9 +93,9 @@ def ready(self): "task": "geonode.upload.tasks.cleanup_celery_task_entries", "schedule": 86400.0, } - settings.CELERY_BEAT_SCHEDULE['clean-up-old-task-result'] = { - 'task': 'geonode.upload.tasks.cleanup_celery_task_entries', - 'schedule': 86400.0, + settings.CELERY_BEAT_SCHEDULE["clean-up-old-task-result"] = { + "task": "geonode.upload.tasks.cleanup_celery_task_entries", + "schedule": 86400.0, } From 8d2908ebf7e821849447cad1e0dab977a495c85b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Jan 2023 17:11:04 +0100 Subject: [PATCH 202/330] =?UTF-8?q?[Fixes=20#10537]=20Improve=20rules=20cr?= =?UTF-8?q?eation=20using=20GeoFence=20batch=20-=20more=20imp=E2=80=A6=20(?= =?UTF-8?q?#10585)=20(#10588)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Code Formatting] Revert formatting on conflicting files * [Fixes #10537] Improve rules creation using GeoFence batch - more improvements * [#10574] Align code formatting with black Co-authored-by: afabiani (cherry picked from commit 36f414d02a054ecd328e3d16358992b0a503cc36) Co-authored-by: Emanuele Tajariol --- geonode/geoserver/helpers.py | 16 +++++++++++++++- geonode/geoserver/manager.py | 1 + geonode/security/tests.py | 12 ------------ 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/geonode/geoserver/helpers.py b/geonode/geoserver/helpers.py index 30dc6bffa06..b3326246867 100755 --- a/geonode/geoserver/helpers.py +++ b/geonode/geoserver/helpers.py @@ -1874,7 +1874,21 @@ def get_time_info(layer): url, _user, _password, retries=ogc_server_settings.MAX_RETRIES, backoff_factor=ogc_server_settings.BACKOFF_FACTOR ) gs_uploader = Client(url, _user, _password) -gf_client = GeofenceClient(url, _user, _password) + + +def create_geofence_client(): + gs_url = settings.OGC_SERVER["default"]["LOCATION"] + user = settings.OGC_SERVER["default"]["USER"] + passwd = settings.OGC_SERVER["default"]["PASSWORD"] + + gf_rest_url = f'{gs_url.rstrip("/")}/rest/geofence/' + client = GeoFenceClient(gf_rest_url, user, passwd) + client.set_timeout(settings.OGC_SERVER["default"].get("GEOFENCE_TIMEOUT", 60)) + return client + + +geofence = create_geofence_client() +gf_utils = GeoFenceUtils(geofence) def _create_geofence_client(): diff --git a/geonode/geoserver/manager.py b/geonode/geoserver/manager.py index 18634e06f58..3762b50c89f 100644 --- a/geonode/geoserver/manager.py +++ b/geonode/geoserver/manager.py @@ -442,6 +442,7 @@ def set_permissions( approval_status_changed: bool = False, group_status_changed: bool = False, ) -> bool: + _resource = instance or ResourceManager._get_instance(uuid) try: diff --git a/geonode/security/tests.py b/geonode/security/tests.py index 546dbf4ab15..39dcc36a7a6 100644 --- a/geonode/security/tests.py +++ b/geonode/security/tests.py @@ -88,18 +88,6 @@ def _log(msg, *args): logger.debug(msg, *args) -def get_geofence_rules_count(): - from geonode.geoserver.helpers import gf_client - - return gf_client.get_rules_count() - - -def get_geofence_rules(): - from geonode.geoserver.helpers import gf_client - - return gf_client.get_rules() - - class StreamToLogger: """ Fake file-like stream object that redirects writes to a logger instance. From 1082bc273f4eb80612bf600caa837c85c5ca9c36 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Jan 2023 09:27:32 +0100 Subject: [PATCH 203/330] [Code Format] Minor refactoring of geofence create options (#10589) (#10596) * [Code Format] Minor refactoring of geofence create options * - fix test cases (cherry picked from commit 118b8e7c40fcfc55bdd01f669fecb14ef6974918) Co-authored-by: Alessio Fabiani --- geonode/geoserver/helpers.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/geonode/geoserver/helpers.py b/geonode/geoserver/helpers.py index b3326246867..5f0d5a627f5 100755 --- a/geonode/geoserver/helpers.py +++ b/geonode/geoserver/helpers.py @@ -1876,18 +1876,14 @@ def get_time_info(layer): gs_uploader = Client(url, _user, _password) -def create_geofence_client(): - gs_url = settings.OGC_SERVER["default"]["LOCATION"] - user = settings.OGC_SERVER["default"]["USER"] - passwd = settings.OGC_SERVER["default"]["PASSWORD"] - - gf_rest_url = f'{gs_url.rstrip("/")}/rest/geofence/' - client = GeoFenceClient(gf_rest_url, user, passwd) - client.set_timeout(settings.OGC_SERVER["default"].get("GEOFENCE_TIMEOUT", 60)) +def _create_geofence_client(): + gf_rest_url = f'{url.rstrip("/")}/geofence/' + client = GeoFenceClient(gf_rest_url, _user, _password) + client.set_timeout(ogc_server_settings.GEOFENCE_TIMEOUT) return client -geofence = create_geofence_client() +geofence = _create_geofence_client() gf_utils = GeoFenceUtils(geofence) From a45666e76088ad1f337fe4559417b1ce217c9289 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Feb 2023 09:27:26 +0100 Subject: [PATCH 204/330] [Dependencies] Align "setup.cfg" to "requirements.txt" (#10623) (#10624) (cherry picked from commit cb8d2aac6dae90ab61a266d902b5b1a38f957431) Co-authored-by: Alessio Fabiani --- setup.cfg | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.cfg b/setup.cfg index 3509d6bfae7..12b727c9d08 100644 --- a/setup.cfg +++ b/setup.cfg @@ -207,6 +207,8 @@ install_requires = wandb==0.13.9 protobuf==3.20.3 mako==1.2.4 + certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability + jwcrypto>=1.4 # not directly required, pinned by Snyk to avoid a vulnerability [options.packages.find] exclude = tests From 9324304827c078a5b18bb7dd35af7d3ae8fb99de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Feb 2023 18:34:23 +0100 Subject: [PATCH 205/330] [Dependencies] Align "setup.cfg" to "requirements.txt" (#10642) (#10643) * [Dependencies] Align "setup.cfg" to "requirements.txt" * - Fix black formatting (cherry picked from commit b73cc24226568d010bffb862edef6706ff1c98c5) Co-authored-by: Alessio Fabiani --- geonode/geoserver/manager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/geonode/geoserver/manager.py b/geonode/geoserver/manager.py index 3762b50c89f..18634e06f58 100644 --- a/geonode/geoserver/manager.py +++ b/geonode/geoserver/manager.py @@ -442,7 +442,6 @@ def set_permissions( approval_status_changed: bool = False, group_status_changed: bool = False, ) -> bool: - _resource = instance or ResourceManager._get_instance(uuid) try: From 29b8e544e8860adb733fc8894c59949b471c0b46 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Feb 2023 17:26:02 +0100 Subject: [PATCH 206/330] [Fixes #10521] Make metadata wizard more flexible for custom metadata (#10592) (#10653) * [Fixes: 10521] Make metadata wizard more flexible for custom metadata * add tests for extrametadata query; pep8 format imports * fix map test for metadata response (cherry picked from commit 5ccc0d6a225115ef05c8121cbed6616888f2134c) Co-authored-by: Toni --- geonode/documents/api/tests.py | 1 - geonode/layers/api/serializers.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/geonode/documents/api/tests.py b/geonode/documents/api/tests.py index 5ab59dfbbb4..856e72efd34 100644 --- a/geonode/documents/api/tests.py +++ b/geonode/documents/api/tests.py @@ -17,7 +17,6 @@ # ######################################################################### import os -from django.contrib.auth import get_user_model import logging from django.contrib.auth import get_user_model diff --git a/geonode/layers/api/serializers.py b/geonode/layers/api/serializers.py index 3d39fdefe20..27b1e6a1d6a 100644 --- a/geonode/layers/api/serializers.py +++ b/geonode/layers/api/serializers.py @@ -181,6 +181,7 @@ class Meta: "subtype", "ptype", "executions", + "metadata", ) name = serializers.CharField(read_only=True) From 2fce14f41c7bb4eecc2a895ef7b9f2ec448b187d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Feb 2023 16:57:52 +0100 Subject: [PATCH 207/330] [Dependencies] align "setup.cfg" to "requirements.txt" (#10665) (#10666) (cherry picked from commit 0e89afe806f359a1a0b700c233f292999249af5f) Co-authored-by: Alessio Fabiani --- setup.cfg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 12b727c9d08..736f81d85cb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -203,8 +203,8 @@ install_requires = jwcrypto>=1.4 # not directly required, pinned by Snyk to avoid a vulnerability # Security and audit - mistune==2.0.4 - wandb==0.13.9 + mistune==2.0.5 + wandb==0.13.10 protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability From 688528469fd138f01e66b478437c316fb1c071a2 Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Fri, 17 Mar 2023 11:42:24 +0100 Subject: [PATCH 208/330] [Dependencies] Align setup.cfg to requirements.txt (#10788) (#10789) (cherry picked from commit e7620effb065ca8b4bf9a0e4969adfd454ed36b3) # Conflicts: # setup.cfg --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 736f81d85cb..39709bce937 100644 --- a/setup.cfg +++ b/setup.cfg @@ -204,7 +204,7 @@ install_requires = # Security and audit mistune==2.0.5 - wandb==0.13.10 + wandb==0.13.11 protobuf==3.20.3 mako==1.2.4 certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability From 6e33c12ec0c862f96432f0d9fb1000f7c098c7ee Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Tue, 28 Feb 2023 14:17:12 +0100 Subject: [PATCH 209/330] Make sure to keep all unzipped files as file_paths CSV and JSON are both valid `base_file`s. However, the content of a ZIP file containing a JSON file along with one or multiple CSV files will not be stored as `data_retriever` will override all allowed `base_file`s until just one file remain. --- geonode/storage/data_retriever.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/geonode/storage/data_retriever.py b/geonode/storage/data_retriever.py index 059a3122a74..7d35f93bf29 100644 --- a/geonode/storage/data_retriever.py +++ b/geonode/storage/data_retriever.py @@ -211,12 +211,25 @@ def _unzip(self, zip_name: str) -> Mapping: not_main_files = ["xml", "sld", "zip", "kmz"] base_file_choices = [x for x in available_choices if x not in not_main_files] for _file in Path(self.temporary_folder).iterdir(): + if not zipfile.is_zipfile(str(_file)): if any([_file.name.endswith(_ext) for _ext in base_file_choices]): self.file_paths["base_file"] = Path(str(_file)) elif not zipfile.is_zipfile(str(_file)): ext = _file.name.split(".")[-1] + if f"{ext}_file" in self.file_paths: + existing = self.file_paths[f"{ext}_file"] + self.file_paths[f"{ext}_file"] = [ Path(str(_file)), *(existing if type(existing) == list else [existing]) ] + else: self.file_paths[f"{ext}_file"] = Path(str(_file)) + tmp = self.file_paths.copy() + for key, value in self.file_paths.items(): + if type(value) == list: + for index, file_path in enumerate(value): + n = f"{key}_{index}" if index > 0 else key + tmp[n] = file_path + self.file_paths = tmp + # remiving the zip file os.remove(zip_name) From 875398195b6ad042d4090296bb5bf6ab9a8ffc49 Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Thu, 23 Mar 2023 16:34:17 +0100 Subject: [PATCH 210/330] Keep all uploaded zip content accessible iterdir() is platform dependent, that is the order of the returned items may be different on different platforms. In cases where a zip file contains multiple base_file candidates it will be overridden by the last one found (which varies on different platforms). Also, different files with the same extension (file1.csv, file2.csv) will not be accessible from file_paths as they get overridden, too. The fix enumerates all files to make them accessible from file_paths. --- geonode/storage/data_retriever.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/geonode/storage/data_retriever.py b/geonode/storage/data_retriever.py index 7d35f93bf29..9395b4ed827 100644 --- a/geonode/storage/data_retriever.py +++ b/geonode/storage/data_retriever.py @@ -213,8 +213,7 @@ def _unzip(self, zip_name: str) -> Mapping: for _file in Path(self.temporary_folder).iterdir(): if not zipfile.is_zipfile(str(_file)): if any([_file.name.endswith(_ext) for _ext in base_file_choices]): - self.file_paths["base_file"] = Path(str(_file)) - elif not zipfile.is_zipfile(str(_file)): + self.file_paths['base_file'] = Path(str(_file)) ext = _file.name.split(".")[-1] if f"{ext}_file" in self.file_paths: existing = self.file_paths[f"{ext}_file"] From e53440a396baebda800b1683603bb5dfad624f79 Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Mon, 17 Apr 2023 09:03:54 +0200 Subject: [PATCH 211/330] Use context manager during unzip --- geonode/storage/data_retriever.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/geonode/storage/data_retriever.py b/geonode/storage/data_retriever.py index 9395b4ed827..29cf8beb51c 100644 --- a/geonode/storage/data_retriever.py +++ b/geonode/storage/data_retriever.py @@ -205,8 +205,9 @@ def _unzip(self, zip_name: str) -> Mapping: at the end the zip is deleted """ zip_file = self.file_paths["base_file"] - the_zip = zipfile.ZipFile(zip_file, allowZip64=True) + with zipfile.ZipFile(zip_file, allowZip64=True) as the_zip: the_zip.extractall(self.temporary_folder) + available_choices = get_allowed_extensions() not_main_files = ["xml", "sld", "zip", "kmz"] base_file_choices = [x for x in available_choices if x not in not_main_files] From f24457bb2ec86c43d9baa9fd1ee056516e9aeddd Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Tue, 18 Apr 2023 19:43:45 +0200 Subject: [PATCH 212/330] Sorts files during unzip Ensures that related content are unpacked accordingly in a mixed content case --- geonode/storage/data_retriever.py | 3 +- geonode/storage/tests.py | 27 ++++++++++++++++++ .../storage/tests/data/data_collection.zip | Bin 0 -> 14261 bytes 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 geonode/storage/tests/data/data_collection.zip diff --git a/geonode/storage/data_retriever.py b/geonode/storage/data_retriever.py index 29cf8beb51c..b535f948328 100644 --- a/geonode/storage/data_retriever.py +++ b/geonode/storage/data_retriever.py @@ -211,7 +211,8 @@ def _unzip(self, zip_name: str) -> Mapping: available_choices = get_allowed_extensions() not_main_files = ["xml", "sld", "zip", "kmz"] base_file_choices = [x for x in available_choices if x not in not_main_files] - for _file in Path(self.temporary_folder).iterdir(): + sorted_files = sorted(Path(self.temporary_folder).iterdir()) + for _file in sorted_files: if not zipfile.is_zipfile(str(_file)): if any([_file.name.endswith(_ext) for _ext in base_file_choices]): self.file_paths['base_file'] = Path(str(_file)) diff --git a/geonode/storage/tests.py b/geonode/storage/tests.py index c4ba57817df..e76c3b6feaa 100644 --- a/geonode/storage/tests.py +++ b/geonode/storage/tests.py @@ -624,3 +624,30 @@ def test_zip_file_should_correctly_recognize_main_extension_with_shp(self): self.assertIsNotNone(storage_manager.data_retriever.temporary_folder) _files = storage_manager.get_retrieved_paths() self.assertTrue("single_point.shp" in _files.get("base_file")) + + def test_zip_file_should_correctly_relate_mixed_content(self): + zip_file = os.path.join(f"{self.project_root}", "tests/data/data_collection.zip") + storage_manager = self.sut( + remote_files={"base_file": zip_file} + ) + storage_manager.clone_remote_files() + self.assertIsNotNone(storage_manager.data_retriever.temporary_folder) + _files = storage_manager.get_retrieved_paths() + base_file = _files.get("base_file") + + def strip_extension(filename): + return filename.split(".")[0] if filename else filename + + base_name = strip_extension(base_file) + available_files = [ + _files.get("shp_file"), + _files.get("prj_file"), + _files.get("shx_file"), + _files.get("dbf_file") + ] + + def assert_same_base_name(base_name, file_name): + self.assertEqual(base_name, strip_extension(file_name)) + + for file in available_files: + assert_same_base_name(base_name, file) diff --git a/geonode/storage/tests/data/data_collection.zip b/geonode/storage/tests/data/data_collection.zip new file mode 100644 index 0000000000000000000000000000000000000000..0702327be361a2bfda7c9c2a6eb13bd08ca5a9b4 GIT binary patch literal 14261 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 Cq!$1H literal 0 HcmV?d00001 From 4cc4bfae9890a603b0e3c96dfd306471d3357273 Mon Sep 17 00:00:00 2001 From: Snyk bot Date: Thu, 11 May 2023 15:21:56 +0200 Subject: [PATCH 213/330] fix: requirements.txt to reduce vulnerabilities (#11029) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-DJANGO-5496950 Co-authored-by: Alessio Fabiani From 3d20ed1cbdd029319f214fa3cbe74017400a6e7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 May 2023 15:40:26 +0200 Subject: [PATCH 214/330] Bump django-autocomplete-light from 3.5.1 to 3.9.7 (#11056) * Bump django-autocomplete-light from 3.5.1 to 3.9.7 Bumps [django-autocomplete-light](https://github.com/yourlabs/django-autocomplete-light) from 3.5.1 to 3.9.7. - [Release notes](https://github.com/yourlabs/django-autocomplete-light/releases) - [Changelog](https://github.com/yourlabs/django-autocomplete-light/blob/master/CHANGELOG) - [Commits](https://github.com/yourlabs/django-autocomplete-light/compare/3.5.1...3.9.7) --- updated-dependencies: - dependency-name: django-autocomplete-light dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * [Dependencies] Align setup.cfg to requirements.txt (#11048) --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0470468edb4..9ba2ebd9057 100644 --- a/requirements.txt +++ b/requirements.txt @@ -70,7 +70,7 @@ dj-pagination==2.5.0 django-select2==8.1.1 django-floppyforms<1.10.0 django-forms-bootstrap<=3.1.0 -django-autocomplete-light==3.5.1 +django-autocomplete-light==3.9.7 django-invitations<2.0.1 django-recaptcha==3.0.0 diff --git a/setup.cfg b/setup.cfg index 39709bce937..850b57543f3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -96,7 +96,7 @@ install_requires = django-select2==8.1.1 django-floppyforms<1.10.0 django-forms-bootstrap<=3.1.0 - django-autocomplete-light==3.5.1 + django-autocomplete-light==3.9.7 django-invitations<2.0.1 django-recaptcha==3.0.0 From 9c62b014b700e06b255825fa209bce077205f7e6 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Tue, 16 May 2023 13:02:24 +0200 Subject: [PATCH 215/330] revert django-autocomplete-light upgrade (#11073) --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9ba2ebd9057..0470468edb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -70,7 +70,7 @@ dj-pagination==2.5.0 django-select2==8.1.1 django-floppyforms<1.10.0 django-forms-bootstrap<=3.1.0 -django-autocomplete-light==3.9.7 +django-autocomplete-light==3.5.1 django-invitations<2.0.1 django-recaptcha==3.0.0 diff --git a/setup.cfg b/setup.cfg index 850b57543f3..39709bce937 100644 --- a/setup.cfg +++ b/setup.cfg @@ -96,7 +96,7 @@ install_requires = django-select2==8.1.1 django-floppyforms<1.10.0 django-forms-bootstrap<=3.1.0 - django-autocomplete-light==3.9.7 + django-autocomplete-light==3.5.1 django-invitations<2.0.1 django-recaptcha==3.0.0 From fd04ce3f33b8b976f274d9ffab66db922b28c430 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 09:52:47 +0200 Subject: [PATCH 216/330] [Fixes #11100] Bump to Geoserver 2.23.1 (#11104) (#11107) * Bump to Geoserver 2.23.1 * fixed typo (cherry picked from commit a5d75529921a14bee833912c6b3c1283aed1f21d) Co-authored-by: Giovanni Allegri --- dev_config.yml | 4 ++-- docker-compose-geoserver-server.yml | 4 ++-- docker-compose-test.yml | 4 ++-- docker-compose.yml | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dev_config.yml b/dev_config.yml index 3fa02cd768e..b6c89644210 100644 --- a/dev_config.yml +++ b/dev_config.yml @@ -1,6 +1,6 @@ --- -GEOSERVER_URL: "https://artifacts.geonode.org/geoserver/2.23.0/geoserver.war" -DATA_DIR_URL: "https://artifacts.geonode.org/geoserver/2.23.0/geonode-geoserver-ext-web-app-data.zip" +GEOSERVER_URL: "https://artifacts.geonode.org/geoserver/2.23.1/geoserver.war" +DATA_DIR_URL: "https://artifacts.geonode.org/geoserver/2.23.1/geonode-geoserver-ext-web-app-data.zip" JETTY_RUNNER_URL: "https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-runner/9.4.31.v20200723/jetty-runner-9.4.31.v20200723.jar" WINDOWS: py2exe: "http://downloads.sourceforge.net/project/py2exe/py2exe/0.6.9/py2exe-0.6.9.win32-py2.7.exe" diff --git a/docker-compose-geoserver-server.yml b/docker-compose-geoserver-server.yml index 10785a5794a..8dfc08b06c0 100644 --- a/docker-compose-geoserver-server.yml +++ b/docker-compose-geoserver-server.yml @@ -2,7 +2,7 @@ version: '2.2' services: data-dir-conf: - image: geonode/geoserver_data:2.23.0 + image: geonode/geoserver_data:2.23.1 restart: on-failure container_name: gsconf4${COMPOSE_PROJECT_NAME} labels: @@ -13,7 +13,7 @@ services: - geoserver-data-dir:/geoserver_data/data geoserver: - image: geonode/geoserver:2.23.0 + image: geonode/geoserver:2.23.1 restart: unless-stopped container_name: geoserver4${COMPOSE_PROJECT_NAME} stdin_open: true diff --git a/docker-compose-test.yml b/docker-compose-test.yml index de398f099a7..9fed05cbb8d 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -89,7 +89,7 @@ services: # Geoserver backend geoserver: - image: geonode/geoserver:2.23.0 + image: geonode/geoserver:2.23.1 container_name: geoserver4${COMPOSE_PROJECT_NAME} healthcheck: test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8080/geoserver/ows" @@ -113,7 +113,7 @@ services: condition: service_healthy data-dir-conf: - image: geonode/geoserver_data:2.23.0 + image: geonode/geoserver_data:2.23.1 container_name: gsconf4${COMPOSE_PROJECT_NAME} entrypoint: sleep infinity volumes: diff --git a/docker-compose.yml b/docker-compose.yml index a4fc63d9b88..5bf831b1ff5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,7 +89,7 @@ services: # Geoserver backend geoserver: - image: geonode/geoserver:2.23.0 + image: geonode/geoserver:2.23.1 container_name: geoserver4${COMPOSE_PROJECT_NAME} healthcheck: test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8080/geoserver/ows" @@ -113,7 +113,7 @@ services: condition: service_healthy data-dir-conf: - image: geonode/geoserver_data:2.23.0 + image: geonode/geoserver_data:2.23.1 container_name: gsconf4${COMPOSE_PROJECT_NAME} entrypoint: sleep infinity volumes: From cd8746bb713634a5c659b61de8ad3217634597d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 17:07:05 +0200 Subject: [PATCH 217/330] Remove doc_file and file_path from document serializer (#11117) (#11119) (cherry picked from commit 927a302bb4494a3d7dce5c829d7c6a646247704c) Co-authored-by: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> --- geonode/documents/api/tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/geonode/documents/api/tests.py b/geonode/documents/api/tests.py index 856e72efd34..dbb3b13588b 100644 --- a/geonode/documents/api/tests.py +++ b/geonode/documents/api/tests.py @@ -16,7 +16,6 @@ # along with this program. If not, see . # ######################################################################### -import os import logging from django.contrib.auth import get_user_model From 0c3653ee00d007e7129e8f2efb9420c978a225bc Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 1 Jun 2023 15:04:31 +0200 Subject: [PATCH 218/330] Revert "[Fixes #11100] Bump to Geoserver 2.23.1 (#11104) (#11107)" (#11136) This reverts commit 91e68714ecf5bfd3afe9262855337e7f959f752c. --- dev_config.yml | 4 ++-- docker-compose-geoserver-server.yml | 4 ++-- docker-compose-test.yml | 4 ++-- docker-compose.yml | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dev_config.yml b/dev_config.yml index b6c89644210..3fa02cd768e 100644 --- a/dev_config.yml +++ b/dev_config.yml @@ -1,6 +1,6 @@ --- -GEOSERVER_URL: "https://artifacts.geonode.org/geoserver/2.23.1/geoserver.war" -DATA_DIR_URL: "https://artifacts.geonode.org/geoserver/2.23.1/geonode-geoserver-ext-web-app-data.zip" +GEOSERVER_URL: "https://artifacts.geonode.org/geoserver/2.23.0/geoserver.war" +DATA_DIR_URL: "https://artifacts.geonode.org/geoserver/2.23.0/geonode-geoserver-ext-web-app-data.zip" JETTY_RUNNER_URL: "https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-runner/9.4.31.v20200723/jetty-runner-9.4.31.v20200723.jar" WINDOWS: py2exe: "http://downloads.sourceforge.net/project/py2exe/py2exe/0.6.9/py2exe-0.6.9.win32-py2.7.exe" diff --git a/docker-compose-geoserver-server.yml b/docker-compose-geoserver-server.yml index 8dfc08b06c0..10785a5794a 100644 --- a/docker-compose-geoserver-server.yml +++ b/docker-compose-geoserver-server.yml @@ -2,7 +2,7 @@ version: '2.2' services: data-dir-conf: - image: geonode/geoserver_data:2.23.1 + image: geonode/geoserver_data:2.23.0 restart: on-failure container_name: gsconf4${COMPOSE_PROJECT_NAME} labels: @@ -13,7 +13,7 @@ services: - geoserver-data-dir:/geoserver_data/data geoserver: - image: geonode/geoserver:2.23.1 + image: geonode/geoserver:2.23.0 restart: unless-stopped container_name: geoserver4${COMPOSE_PROJECT_NAME} stdin_open: true diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 9fed05cbb8d..de398f099a7 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -89,7 +89,7 @@ services: # Geoserver backend geoserver: - image: geonode/geoserver:2.23.1 + image: geonode/geoserver:2.23.0 container_name: geoserver4${COMPOSE_PROJECT_NAME} healthcheck: test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8080/geoserver/ows" @@ -113,7 +113,7 @@ services: condition: service_healthy data-dir-conf: - image: geonode/geoserver_data:2.23.1 + image: geonode/geoserver_data:2.23.0 container_name: gsconf4${COMPOSE_PROJECT_NAME} entrypoint: sleep infinity volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 5bf831b1ff5..a4fc63d9b88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,7 +89,7 @@ services: # Geoserver backend geoserver: - image: geonode/geoserver:2.23.1 + image: geonode/geoserver:2.23.0 container_name: geoserver4${COMPOSE_PROJECT_NAME} healthcheck: test: "curl --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://127.0.0.1:8080/geoserver/ows" @@ -113,7 +113,7 @@ services: condition: service_healthy data-dir-conf: - image: geonode/geoserver_data:2.23.1 + image: geonode/geoserver_data:2.23.0 container_name: gsconf4${COMPOSE_PROJECT_NAME} entrypoint: sleep infinity volumes: From 840124a0272a11e1ba6183a43c709e2d2c1e1d67 Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Mon, 11 Sep 2023 14:53:45 +0200 Subject: [PATCH 219/330] Add github workflow files --- .github/workflows/build-and-push.yaml | 62 +++++++++++++++++++++++++ .github/workflows/release.yaml | 66 +++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 .github/workflows/build-and-push.yaml create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/build-and-push.yaml b/.github/workflows/build-and-push.yaml new file mode 100644 index 00000000000..1ca4e975540 --- /dev/null +++ b/.github/workflows/build-and-push.yaml @@ -0,0 +1,62 @@ +name: Release GeoNode Docker Images + +env: + TITLE: "52°North GeoNode Docker Image" + VENDOR: "52°North GmbH" + AUTHORS: "https://52North.org/" + DESCRIPTION: "Builds and publishes the GeoNode Docker development image for Thuenen" + LICENSE: "GPL-3.0" + +on: + push: + branches: + - "thuenen-dev" + +jobs: + build_and_push_geonode: + runs-on: ubuntu-22.04 + env: + IMAGE: 52north/geonode_thuenen + DEVELOPMENT_VERSION: "4.x" + steps: + - + name: Checkout + uses: actions/checkout@v3 + - + name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - + name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.IMAGE }} + labels: | + "org.opencontainers.image.authors=${{ env.AUTHORS }}" + "org.opencontainers.image.vendor=${{ env.VENDOR }}" + "org.opencontainers.image.description=${{ env.DESCRIPTION }}" + "org.opencontainers.image.title=${{ env.TITLE }}" + "org.opencontainers.image.licenses=${{ env.LICENSE }}" + tags: | + latest + ${{ env.DEVELOPMENT_VERSION }} + - + name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - + name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000000..0aeac429729 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,66 @@ +name: Release GeoNode Docker Images + +env: + TITLE: "52°North GeoNode Docker Image" + VENDOR: "52°North GmbH" + AUTHORS: "https://52North.org/" + DESCRIPTION: "Builds and publishes the GeoNode Docker image for Thuenen" + LICENSE: "GPL-3.0" + +on: + push: + tags: + - "v*" + +jobs: + build_and_push_geonode: + runs-on: ubuntu-22.04 + env: + IMAGE: 52north/geonode_thuenen + steps: + - + name: Checkout + uses: actions/checkout@v3 + - + name: Parse semver string + id: semver_parser + uses: booxmedialtd/ws-action-parse-semver@v1 + with: + input_string: "${{github.ref_name}}" + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - + name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.IMAGE }} + labels: | + "org.opencontainers.image.authors=${{ env.AUTHORS }}" + "org.opencontainers.image.vendor=${{ env.VENDOR }}" + "org.opencontainers.image.description=${{ env.DESCRIPTION }}" + "org.opencontainers.image.title=${{ env.TITLE }}" + "org.opencontainers.image.licenses=${{ env.LICENSE }}" + tags: | + latest + ${{ steps.semver_parser.outputs.major }} + ${{ steps.semver_parser.outputs.minor }} + ${{ steps.semver_parser.outputs.patch }} + - + name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - + name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max From 783528c16e8effe8209eabccb267224a8b052adb Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Mon, 11 Sep 2023 15:00:26 +0200 Subject: [PATCH 220/330] Update branch to build --- .github/workflows/build-and-push.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-push.yaml b/.github/workflows/build-and-push.yaml index 1ca4e975540..49797d7ac29 100644 --- a/.github/workflows/build-and-push.yaml +++ b/.github/workflows/build-and-push.yaml @@ -10,7 +10,7 @@ env: on: push: branches: - - "thuenen-dev" + - "thuenen_4.x" jobs: build_and_push_geonode: From e286371e1134307726f95020b732cc557b3476a5 Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Mon, 11 Sep 2023 15:04:51 +0200 Subject: [PATCH 221/330] Cancels running builds on new push --- .github/workflows/build-and-push.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-push.yaml b/.github/workflows/build-and-push.yaml index 49797d7ac29..d2283a14dfb 100644 --- a/.github/workflows/build-and-push.yaml +++ b/.github/workflows/build-and-push.yaml @@ -1,4 +1,8 @@ -name: Release GeoNode Docker Images +name: Push GeoNode Docker Images + +concurrency: + group: "geonode_build" + cancel-in-progress: true env: TITLE: "52°North GeoNode Docker Image" From 4a75e6ecc78130486107d4985ae5e30ad356ca59 Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Mon, 11 Sep 2023 15:18:23 +0200 Subject: [PATCH 222/330] Push to 52north/geonode and use different versions --- .github/workflows/build-and-push.yaml | 5 ++--- .github/workflows/release.yaml | 11 +++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-and-push.yaml b/.github/workflows/build-and-push.yaml index d2283a14dfb..ca75363ac92 100644 --- a/.github/workflows/build-and-push.yaml +++ b/.github/workflows/build-and-push.yaml @@ -20,7 +20,7 @@ jobs: build_and_push_geonode: runs-on: ubuntu-22.04 env: - IMAGE: 52north/geonode_thuenen + IMAGE: 52north/geonode DEVELOPMENT_VERSION: "4.x" steps: - @@ -45,8 +45,7 @@ jobs: "org.opencontainers.image.title=${{ env.TITLE }}" "org.opencontainers.image.licenses=${{ env.LICENSE }}" tags: | - latest - ${{ env.DEVELOPMENT_VERSION }} + ${{ env.DEVELOPMENT_VERSION }}-thuenen - name: Login to Docker Hub uses: docker/login-action@v2 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0aeac429729..18bb9817a11 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -10,13 +10,13 @@ env: on: push: tags: - - "v*" + - "v*-thuenen" jobs: build_and_push_geonode: runs-on: ubuntu-22.04 env: - IMAGE: 52north/geonode_thuenen + IMAGE: 52north/geonode steps: - name: Checkout @@ -43,10 +43,9 @@ jobs: "org.opencontainers.image.title=${{ env.TITLE }}" "org.opencontainers.image.licenses=${{ env.LICENSE }}" tags: | - latest - ${{ steps.semver_parser.outputs.major }} - ${{ steps.semver_parser.outputs.minor }} - ${{ steps.semver_parser.outputs.patch }} + ${{ steps.semver_parser.outputs.major }}-thuenen + ${{ steps.semver_parser.outputs.minor }}-thuenen + ${{ steps.semver_parser.outputs.patch }}-thuenen - name: Login to Docker Hub uses: docker/login-action@v2 From b3764182a0df17b97e2adf0f4d7c844b4c9bfce8 Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Mon, 11 Sep 2023 15:33:12 +0200 Subject: [PATCH 223/330] Use dedicated image repository --- .github/workflows/build-and-push.yaml | 9 +++------ .github/workflows/release.yaml | 8 ++++---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-and-push.yaml b/.github/workflows/build-and-push.yaml index ca75363ac92..2785d012047 100644 --- a/.github/workflows/build-and-push.yaml +++ b/.github/workflows/build-and-push.yaml @@ -20,15 +20,12 @@ jobs: build_and_push_geonode: runs-on: ubuntu-22.04 env: - IMAGE: 52north/geonode + IMAGE: 52north/geonode_thuenen DEVELOPMENT_VERSION: "4.x" steps: - name: Checkout uses: actions/checkout@v3 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 @@ -45,7 +42,7 @@ jobs: "org.opencontainers.image.title=${{ env.TITLE }}" "org.opencontainers.image.licenses=${{ env.LICENSE }}" tags: | - ${{ env.DEVELOPMENT_VERSION }}-thuenen + ${{ env.DEVELOPMENT_VERSION }} - name: Login to Docker Hub uses: docker/login-action@v2 @@ -58,7 +55,7 @@ jobs: with: context: . file: ./Dockerfile - push: true + push: false tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 18bb9817a11..ec4e54cdf9e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,7 +16,7 @@ jobs: build_and_push_geonode: runs-on: ubuntu-22.04 env: - IMAGE: 52north/geonode + IMAGE: 52north/geonode_thuenen steps: - name: Checkout @@ -43,9 +43,9 @@ jobs: "org.opencontainers.image.title=${{ env.TITLE }}" "org.opencontainers.image.licenses=${{ env.LICENSE }}" tags: | - ${{ steps.semver_parser.outputs.major }}-thuenen - ${{ steps.semver_parser.outputs.minor }}-thuenen - ${{ steps.semver_parser.outputs.patch }}-thuenen + ${{ steps.semver_parser.outputs.major }} + ${{ steps.semver_parser.outputs.minor }} + ${{ steps.semver_parser.outputs.patch }} - name: Login to Docker Hub uses: docker/login-action@v2 From 686f7b33ead53a9e17a5f32192931460c5015caf Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Mon, 11 Sep 2023 16:37:25 +0200 Subject: [PATCH 224/330] push to registry after build --- .github/workflows/build-and-push.yaml | 2 +- .github/workflows/release.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-push.yaml b/.github/workflows/build-and-push.yaml index 2785d012047..0cfb52d4174 100644 --- a/.github/workflows/build-and-push.yaml +++ b/.github/workflows/build-and-push.yaml @@ -55,7 +55,7 @@ jobs: with: context: . file: ./Dockerfile - push: false + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ec4e54cdf9e..bce7da86980 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -44,8 +44,8 @@ jobs: "org.opencontainers.image.licenses=${{ env.LICENSE }}" tags: | ${{ steps.semver_parser.outputs.major }} - ${{ steps.semver_parser.outputs.minor }} - ${{ steps.semver_parser.outputs.patch }} + ${{ steps.semver_parser.outputs.major }}-${{ steps.semver_parser.outputs.minor }} + ${{ steps.semver_parser.outputs.major }}-${{ steps.semver_parser.outputs.minor }}-${{ steps.semver_parser.outputs.patch }} - name: Login to Docker Hub uses: docker/login-action@v2 From 8e3ff62b34097bb39dbc68d60285b3671481fa2c Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Wed, 13 Sep 2023 11:16:49 +0200 Subject: [PATCH 225/330] Adjust tag management on built images --- .../{build-and-push.yaml => 52n-build.yaml} | 4 ++-- .../workflows/{release.yaml => 52n-release.yaml} | 14 +++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) rename .github/workflows/{build-and-push.yaml => 52n-build.yaml} (93%) rename .github/workflows/{release.yaml => 52n-release.yaml} (72%) diff --git a/.github/workflows/build-and-push.yaml b/.github/workflows/52n-build.yaml similarity index 93% rename from .github/workflows/build-and-push.yaml rename to .github/workflows/52n-build.yaml index 0cfb52d4174..df1a12d6259 100644 --- a/.github/workflows/build-and-push.yaml +++ b/.github/workflows/52n-build.yaml @@ -1,4 +1,4 @@ -name: Push GeoNode Docker Images +name: Push Thünen Atlas Docker Image(s) concurrency: group: "geonode_build" @@ -8,7 +8,7 @@ env: TITLE: "52°North GeoNode Docker Image" VENDOR: "52°North GmbH" AUTHORS: "https://52North.org/" - DESCRIPTION: "Builds and publishes the GeoNode Docker development image for Thuenen" + DESCRIPTION: "Builds and publishes Thuenen Atlas GeoNode image(s)" LICENSE: "GPL-3.0" on: diff --git a/.github/workflows/release.yaml b/.github/workflows/52n-release.yaml similarity index 72% rename from .github/workflows/release.yaml rename to .github/workflows/52n-release.yaml index bce7da86980..c98640c39bd 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/52n-release.yaml @@ -34,6 +34,10 @@ jobs: name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v4 + env: + MAJOR_VERSION: ${{ steps.semver_parser.outputs.major }} + MAJOR_MINOR_VERSION: ${{ steps.semver_parser.outputs.major }}-${{ steps.semver_parser.outputs.minor }} + MAJOR_MINOR_PATCH_VERSION: ${{ steps.semver_parser.outputs.major }}-${{ steps.semver_parser.outputs.minor }}-${{ steps.semver_parser.outputs.patch }} with: images: ${{ env.IMAGE }} labels: | @@ -43,15 +47,19 @@ jobs: "org.opencontainers.image.title=${{ env.TITLE }}" "org.opencontainers.image.licenses=${{ env.LICENSE }}" tags: | - ${{ steps.semver_parser.outputs.major }} - ${{ steps.semver_parser.outputs.major }}-${{ steps.semver_parser.outputs.minor }} - ${{ steps.semver_parser.outputs.major }}-${{ steps.semver_parser.outputs.minor }}-${{ steps.semver_parser.outputs.patch }} + latest + ${{ env.MAJOR_VERSION }} + ${{ env.MAJOR_MINOR_VERSION }} + ${{ env.MAJOR_MINOR_PATCH_VERSION }} - name: Login to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - + name: Fail in case fully tagged version already exists + run: docker manifest inspect ${{ env.IMAGE }}:${{ steps.meta.env.MAJOR_MINOR_PATCH_VERSION }} > /dev/null ; test $? != 0 - name: Build and push uses: docker/build-push-action@v4 From 08ee49339905cd9f052c844c30e1e355fea56cda Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Mon, 18 Sep 2023 12:06:12 +0200 Subject: [PATCH 226/330] Align with upstream 4.1.x --- geonode/base/forms.py | 3 --- geonode/base/views.py | 15 ------------- geonode/geoserver/helpers.py | 11 ---------- geonode/geoserver/signals.py | 2 -- geonode/layers/api/serializers.py | 1 - geonode/security/tests.py | 9 -------- geonode/storage/data_retriever.py | 15 +++++++------ geonode/storage/tests.py | 20 ++---------------- .../storage/tests/data/data_collection.zip | Bin 14261 -> 0 bytes geonode/upload/__init__.py | 18 ---------------- setup.cfg | 8 ------- 11 files changed, 11 insertions(+), 91 deletions(-) delete mode 100644 geonode/storage/tests/data/data_collection.zip diff --git a/geonode/base/forms.py b/geonode/base/forms.py index b24ad8e935f..c888d816670 100644 --- a/geonode/base/forms.py +++ b/geonode/base/forms.py @@ -470,9 +470,6 @@ def __init__(self, *args, **kwargs): if field in ["poc", "owner"] and not self.can_change_perms: self.fields[field].disabled = True - if field in ["poc", "owner"] and not self.can_change_perms: - self.fields[field].disabled = True - def disable_keywords_widget_for_non_superuser(self, user): if settings.FREETEXT_KEYWORDS_READONLY and not user.is_superuser: self["keywords"].field.disabled = True diff --git a/geonode/base/views.py b/geonode/base/views.py index 30a55e8bff7..7d5d08aa692 100644 --- a/geonode/base/views.py +++ b/geonode/base/views.py @@ -333,21 +333,6 @@ def get_results(self, context): ] -class DatasetsAutocomplete(SimpleSelect2View): - model = Dataset - filter_arg = "title__icontains" - - def get_results(self, context): - return [ - { - "id": self.get_result_value(result), - "text": self.get_result_label(result.title), - "selected_text": self.get_selected_result_label(result.title), - } - for result in context["object_list"] - ] - - class ThesaurusAvailable(autocomplete.Select2QuerySetView): def get_queryset(self): tid = self.request.GET.get("sysid") diff --git a/geonode/geoserver/helpers.py b/geonode/geoserver/helpers.py index 5f0d5a627f5..e46179532c1 100755 --- a/geonode/geoserver/helpers.py +++ b/geonode/geoserver/helpers.py @@ -1876,17 +1876,6 @@ def get_time_info(layer): gs_uploader = Client(url, _user, _password) -def _create_geofence_client(): - gf_rest_url = f'{url.rstrip("/")}/geofence/' - client = GeoFenceClient(gf_rest_url, _user, _password) - client.set_timeout(ogc_server_settings.GEOFENCE_TIMEOUT) - return client - - -geofence = _create_geofence_client() -gf_utils = GeoFenceUtils(geofence) - - def _create_geofence_client(): gf_rest_url = f'{url.rstrip("/")}/geofence/' client = GeoFenceClient(gf_rest_url, _user, _password) diff --git a/geonode/geoserver/signals.py b/geonode/geoserver/signals.py index cfe1ad6f85b..70b96060303 100644 --- a/geonode/geoserver/signals.py +++ b/geonode/geoserver/signals.py @@ -42,8 +42,6 @@ geofence_rule_assign = Signal(providing_args=["instance"]) -geofence_rule_assign = Signal(providing_args=["instance"]) - def geoserver_delete(typename): # cascading_delete should only be called if diff --git a/geonode/layers/api/serializers.py b/geonode/layers/api/serializers.py index 27b1e6a1d6a..3d39fdefe20 100644 --- a/geonode/layers/api/serializers.py +++ b/geonode/layers/api/serializers.py @@ -181,7 +181,6 @@ class Meta: "subtype", "ptype", "executions", - "metadata", ) name = serializers.CharField(read_only=True) diff --git a/geonode/security/tests.py b/geonode/security/tests.py index 39dcc36a7a6..52b43a38551 100644 --- a/geonode/security/tests.py +++ b/geonode/security/tests.py @@ -1748,15 +1748,6 @@ def test_admin_whitelisted_access_middleware(self): middleware.process_request(request) self.assertTrue(request.user.is_superuser) - # Test valid IP in second element - with self.settings(ADMIN_IP_WHITELIST=["88.88.88.88", "127.0.0.1"]): - request = HttpRequest() - request.user = admin - request.path = reverse("home") - request.META["REMOTE_ADDR"] = "127.0.0.1" - middleware.process_request(request) - self.assertTrue(request.user.is_superuser) - class SecurityRulesTests(TestCase): """ diff --git a/geonode/storage/data_retriever.py b/geonode/storage/data_retriever.py index b535f948328..68a14c2e864 100644 --- a/geonode/storage/data_retriever.py +++ b/geonode/storage/data_retriever.py @@ -206,7 +206,7 @@ def _unzip(self, zip_name: str) -> Mapping: """ zip_file = self.file_paths["base_file"] with zipfile.ZipFile(zip_file, allowZip64=True) as the_zip: - the_zip.extractall(self.temporary_folder) + the_zip.extractall(self.temporary_folder) available_choices = get_allowed_extensions() not_main_files = ["xml", "sld", "zip", "kmz"] @@ -214,14 +214,17 @@ def _unzip(self, zip_name: str) -> Mapping: sorted_files = sorted(Path(self.temporary_folder).iterdir()) for _file in sorted_files: if not zipfile.is_zipfile(str(_file)): - if any([_file.name.endswith(_ext) for _ext in base_file_choices]): - self.file_paths['base_file'] = Path(str(_file)) + if any([_file.name.endswith(_ext) for _ext in base_file_choices]): + self.file_paths["base_file"] = Path(str(_file)) ext = _file.name.split(".")[-1] if f"{ext}_file" in self.file_paths: existing = self.file_paths[f"{ext}_file"] - self.file_paths[f"{ext}_file"] = [ Path(str(_file)), *(existing if type(existing) == list else [existing]) ] - else: - self.file_paths[f"{ext}_file"] = Path(str(_file)) + self.file_paths[f"{ext}_file"] = [ + Path(str(_file)), + *(existing if type(existing) == list else [existing]), + ] + else: + self.file_paths[f"{ext}_file"] = Path(str(_file)) tmp = self.file_paths.copy() for key, value in self.file_paths.items(): diff --git a/geonode/storage/tests.py b/geonode/storage/tests.py index e76c3b6feaa..3d496dba6f7 100644 --- a/geonode/storage/tests.py +++ b/geonode/storage/tests.py @@ -416,22 +416,6 @@ def test_storage_manager_copy(self): os.remove(output.get("files")[0]) self.assertFalse(os.path.exists(output.get("files")[0])) - @override_settings(FILE_UPLOAD_DIRECTORY_PERMISSIONS=0o777) - @override_settings(FILE_UPLOAD_PERMISSIONS=0o777) - def test_storage_manager_copy(self): - """ - Test that the copy works as expected and the permissions are corerct - """ - dataset = create_single_dataset(name="test_copy") - dataset.files = [os.path.join(f"{self.project_root}", "tests/data/test_sld.sld")] - dataset.save() - output = self.sut().copy(dataset) - - self.assertTrue(os.path.exists(output.get("files")[0])) - self.assertEqual(os.stat(os.path.exists(output.get("files")[0])).st_mode, 8592) - os.remove(output.get("files")[0]) - self.assertFalse(os.path.exists(output.get("files")[0])) - class TestDataRetriever(TestCase): @classmethod @@ -624,7 +608,7 @@ def test_zip_file_should_correctly_recognize_main_extension_with_shp(self): self.assertIsNotNone(storage_manager.data_retriever.temporary_folder) _files = storage_manager.get_retrieved_paths() self.assertTrue("single_point.shp" in _files.get("base_file")) - + def test_zip_file_should_correctly_relate_mixed_content(self): zip_file = os.path.join(f"{self.project_root}", "tests/data/data_collection.zip") storage_manager = self.sut( @@ -634,7 +618,7 @@ def test_zip_file_should_correctly_relate_mixed_content(self): self.assertIsNotNone(storage_manager.data_retriever.temporary_folder) _files = storage_manager.get_retrieved_paths() base_file = _files.get("base_file") - + def strip_extension(filename): return filename.split(".")[0] if filename else filename diff --git a/geonode/storage/tests/data/data_collection.zip b/geonode/storage/tests/data/data_collection.zip deleted file mode 100644 index 0702327be361a2bfda7c9c2a6eb13bd08ca5a9b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14261 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 Cq!$1H diff --git a/geonode/upload/__init__.py b/geonode/upload/__init__.py index f5c5c542718..c50c3f5609f 100644 --- a/geonode/upload/__init__.py +++ b/geonode/upload/__init__.py @@ -63,20 +63,6 @@ def run_setup_hooks(sender, **kwargs): start_time=timezone.now(), ), ) - daily_interval, _ = IntervalSchedule.objects.get_or_create( - every=1, - period="days" - ) - daily_interval, _ = IntervalSchedule.objects.get_or_create(every=1, period="days") - PeriodicTask.objects.update_or_create( - name="clean-up-old-task-result", - defaults=dict( - task="geonode.upload.tasks.cleanup_celery_task_entries", - interval=daily_interval, - args="", - start_time=timezone.now(), - ), - ) class UploadAppConfig(AppConfig): @@ -93,10 +79,6 @@ def ready(self): "task": "geonode.upload.tasks.cleanup_celery_task_entries", "schedule": 86400.0, } - settings.CELERY_BEAT_SCHEDULE["clean-up-old-task-result"] = { - "task": "geonode.upload.tasks.cleanup_celery_task_entries", - "schedule": 86400.0, - } default_app_config = "geonode.upload.UploadAppConfig" diff --git a/setup.cfg b/setup.cfg index 39709bce937..f6d5f90abf2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -202,14 +202,6 @@ install_requires = certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability jwcrypto>=1.4 # not directly required, pinned by Snyk to avoid a vulnerability - # Security and audit - mistune==2.0.5 - wandb==0.13.11 - protobuf==3.20.3 - mako==1.2.4 - certifi>=2022.12.7 # not directly required, pinned by Snyk to avoid a vulnerability - jwcrypto>=1.4 # not directly required, pinned by Snyk to avoid a vulnerability - [options.packages.find] exclude = tests From 0fe99b9a22bc606f84c3bb1ea2d5eabe3e99d0cc Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Mon, 18 Sep 2023 16:35:46 +0200 Subject: [PATCH 227/330] Add docker hub description --- .github/workflows/52n-build.yaml | 3 +++ .../workflows/52n-dockerhub-description.yaml | 23 +++++++++++++++++++ README_thuenen.md | 8 +++++++ 3 files changed, 34 insertions(+) create mode 100644 .github/workflows/52n-dockerhub-description.yaml create mode 100644 README_thuenen.md diff --git a/.github/workflows/52n-build.yaml b/.github/workflows/52n-build.yaml index df1a12d6259..b30a7643827 100644 --- a/.github/workflows/52n-build.yaml +++ b/.github/workflows/52n-build.yaml @@ -15,6 +15,9 @@ on: push: branches: - "thuenen_4.x" + paths: + - "!./.github/workflows/52n-dockerhub-description.yaml" + - "!./README_thuenen.md" jobs: build_and_push_geonode: diff --git a/.github/workflows/52n-dockerhub-description.yaml b/.github/workflows/52n-dockerhub-description.yaml new file mode 100644 index 00000000000..1bd1b5f84f6 --- /dev/null +++ b/.github/workflows/52n-dockerhub-description.yaml @@ -0,0 +1,23 @@ +name: Update Docker Hub Description +on: + push: + branches: + - thuenen_4.x + paths: + - README_thuenen.md + - .github/workflows/52n-dockerhub-description.yaml +jobs: + dockerHubDescription: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Docker Hub Description + uses: peter-evans/dockerhub-description@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: 52north/geonode + short-description: "Geospatial content management system" + readme-filepath: ./README_thuenen.md + enable-url-completion: true \ No newline at end of file diff --git a/README_thuenen.md b/README_thuenen.md new file mode 100644 index 00000000000..5ebf30dbe77 --- /dev/null +++ b/README_thuenen.md @@ -0,0 +1,8 @@ +# Thünen Atlas Fork of GeoNode + +This image is built from a fork of [Geonode](https://github.com/geonode/geonode). +[Thünen Institute](https://thuenen.de) maintains an own fork of GeoNode in order to make necessary adjustments within projects which are not (yet) part of GeoNode core. + +However, we are interested to stay as close to upstream as possible, to benefit from ongoing development, but also to contribute features and fixes we develop in [Thünen Atlas project](https://atlas.thuenen.de). + +This image (`52north/geonode_thuenen`) is built from the `thuenen_4.x` branch of the [`Thuenen-GeoNode-Development/geonode` repository](https://github.com/Thuenen-GeoNode-Development/geonode/tree/thuenen_4.x). From b442e94f5d2a9390ff1f9a69c061045d0b99493e Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Mon, 18 Sep 2023 16:37:21 +0200 Subject: [PATCH 228/330] Bump version to version 4.1.3 (#11506) --- geonode/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geonode/__init__.py b/geonode/__init__.py index 7884b891ac6..3f30a094da9 100644 --- a/geonode/__init__.py +++ b/geonode/__init__.py @@ -19,7 +19,7 @@ import os -__version__ = (4, 1, 2, "dev", 0) +__version__ = (4, 1, 3, "final", 0) default_app_config = "geonode.apps.AppConfig" From 6d2c174c7b48f8cda0a07b79a114d4ff098e655d Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Tue, 19 Sep 2023 10:30:15 +0200 Subject: [PATCH 229/330] Bump to dev branch (#11510) --- geonode/__init__.py | 2 +- requirements.txt | 4 ++-- setup.cfg | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/geonode/__init__.py b/geonode/__init__.py index 3f30a094da9..fd057ed56e3 100644 --- a/geonode/__init__.py +++ b/geonode/__init__.py @@ -19,7 +19,7 @@ import os -__version__ = (4, 1, 3, "final", 0) +__version__ = (4, 1, 3, "dev", 0) default_app_config = "geonode.apps.AppConfig" diff --git a/requirements.txt b/requirements.txt index 55df34605f3..0470468edb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -88,8 +88,8 @@ pinax-notifications==6.0.0 pinax-ratings==4.0.0 # GeoNode org maintained apps. -django-geonode-mapstore-client==4.1.1 -geonode-importer==1.0.5 +-e git+https://github.com/GeoNode/geonode-mapstore-client.git@4.1.x#egg=django_geonode_mapstore_client +-e git+https://github.com/GeoNode/geonode-importer.git@master#egg=geonode-importer geonode-avatar==5.0.8 geonode-oauth-toolkit==2.2.2 geonode-user-messages==2.0.2 diff --git a/setup.cfg b/setup.cfg index 59fb9ad08cb..f6d5f90abf2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -114,8 +114,8 @@ install_requires = pinax-ratings==4.0.0 # GeoNode org maintained apps. - django-geonode-mapstore-client==4.1.1 - geonode-importer==1.0.5 + django-geonode-mapstore-client>=4.1.1 + geonode-importer>=1.0.5 geonode-avatar==5.0.8 geonode-oauth-toolkit==2.2.2 geonode-user-messages==2.0.2 From 530a44ed41ec249d8843878300eb45485d313584 Mon Sep 17 00:00:00 2001 From: Emanuele Tajariol Date: Fri, 22 Sep 2023 15:52:03 +0200 Subject: [PATCH 230/330] Honor DB username as set in env file (#11522) --- tasks.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tasks.py b/tasks.py index 967c08af386..a37fe622bad 100755 --- a/tasks.py +++ b/tasks.py @@ -504,22 +504,26 @@ def _container_exposed_port(component, instname): def _update_db_connstring(): - user = os.getenv("GEONODE_DATABASE", "geonode") - pwd = os.getenv("GEONODE_DATABASE_PASSWORD", "geonode") - dbname = os.getenv("GEONODE_DATABASE", "geonode") - dbhost = os.getenv("DATABASE_HOST", "db") - dbport = os.getenv("DATABASE_PORT", 5432) - connstr = f"postgis://{user}:{pwd}@{dbhost}:{dbport}/{dbname}" + connstr = os.getenv("DATABASE_URL", None) + if not connstr: + user = os.getenv("GEONODE_DATABASE_USER", "geonode") + pwd = os.getenv("GEONODE_DATABASE_PASSWORD", "geonode") + dbname = os.getenv("GEONODE_DATABASE", "geonode") + dbhost = os.getenv("DATABASE_HOST", "db") + dbport = os.getenv("DATABASE_PORT", 5432) + connstr = f"postgis://{user}:{pwd}@{dbhost}:{dbport}/{dbname}" return connstr def _update_geodb_connstring(): - geouser = os.getenv("GEONODE_GEODATABASE", "geonode_data") - geopwd = os.getenv("GEONODE_GEODATABASE_PASSWORD", "geonode_data") - geodbname = os.getenv("GEONODE_GEODATABASE", "geonode_data") - dbhost = os.getenv("DATABASE_HOST", "db") - dbport = os.getenv("DATABASE_PORT", 5432) - geoconnstr = f"postgis://{geouser}:{geopwd}@{dbhost}:{dbport}/{geodbname}" + geoconnstr = os.getenv("GEODATABASE_URL", None) + if not geoconnstr: + geouser = os.getenv("GEONODE_GEODATABASE_USER", "geonode_data") + geopwd = os.getenv("GEONODE_GEODATABASE_PASSWORD", "geonode_data") + geodbname = os.getenv("GEONODE_GEODATABASE", "geonode_data") + dbhost = os.getenv("DATABASE_HOST", "db") + dbport = os.getenv("DATABASE_PORT", 5432) + geoconnstr = f"postgis://{geouser}:{geopwd}@{dbhost}:{dbport}/{geodbname}" return geoconnstr From 20f8ec58b8334a9a01672ab1ddaa9dcf7767b9a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 09:44:09 +0200 Subject: [PATCH 231/330] Bump django-filter from 23.2 to 23.3 (#11503) * Bump django-filter from 23.2 to 23.3 Bumps [django-filter](https://github.com/carltongibson/django-filter) from 23.2 to 23.3. - [Release notes](https://github.com/carltongibson/django-filter/releases) - [Changelog](https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst) - [Commits](https://github.com/carltongibson/django-filter/compare/23.2...23.3) --- updated-dependencies: - dependency-name: django-filter dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0ec3426887d..7c714faa1ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,7 +32,7 @@ pathvalidate==3.1.0 django-allauth==0.54.0 django-appconf==1.0.5 django-celery-results==2.5.1 -django-filter==23.2 +django-filter==23.3 django-imagekit==4.1.0 django-taggit==1.5.1 django-markdownify==0.9.3 diff --git a/setup.cfg b/setup.cfg index 08de7a9da04..88775b742b1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,7 +58,7 @@ install_requires = django-allauth==0.54.0 django-appconf==1.0.5 django-celery-results==2.5.1 - django-filter==23.2 + django-filter==23.3 django-imagekit==4.1.0 django-taggit==1.5.1 django-markdownify==0.9.3 From 101b89b22ea9342affe5c7549519ecfff21dbd9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 09:45:10 +0200 Subject: [PATCH 232/330] Bump django-grappelli from 3.0.7 to 3.0.8 (#11529) * Bump django-grappelli from 3.0.7 to 3.0.8 Bumps [django-grappelli](https://github.com/sehmaschine/django-grappelli) from 3.0.7 to 3.0.8. - [Changelog](https://github.com/sehmaschine/django-grappelli/blob/master/docs/changelog.rst) - [Commits](https://github.com/sehmaschine/django-grappelli/compare/3.0.7...3.0.8) --- updated-dependencies: - dependency-name: django-grappelli dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7c714faa1ab..b7ebf8f29e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,7 +44,7 @@ django-downloadview==2.3.0 django-polymorphic==3.1.0 django-tastypie<0.15.0 django-tinymce==3.6.1 -django-grappelli==3.0.7 +django-grappelli==3.0.8 django-uuid-upload-path==1.0.0 django-widget-tweaks==1.5.0 django-sequences==2.8 diff --git a/setup.cfg b/setup.cfg index 88775b742b1..48f7f529a54 100644 --- a/setup.cfg +++ b/setup.cfg @@ -70,7 +70,7 @@ install_requires = django-polymorphic==3.1.0 django-tastypie<0.15.0 django-tinymce==3.6.1 - django-grappelli==3.0.7 + django-grappelli==3.0.8 django-uuid-upload-path==1.0.0 django-widget-tweaks==1.5.0 django-sequences==2.8 From 10b936a33b8bfb29a3f53fa52304cbacdbaaeca2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 09:46:57 +0200 Subject: [PATCH 233/330] Bump drf-spectacular from 0.26.4 to 0.26.5 (#11525) * Bump drf-spectacular from 0.26.4 to 0.26.5 Bumps [drf-spectacular](https://github.com/tfranzel/drf-spectacular) from 0.26.4 to 0.26.5. - [Release notes](https://github.com/tfranzel/drf-spectacular/releases) - [Changelog](https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tfranzel/drf-spectacular/compare/0.26.4...0.26.5) --- updated-dependencies: - dependency-name: drf-spectacular dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Bump drf-spectacular from 0.26.4 to 0.26.5 Bumps [drf-spectacular](https://github.com/tfranzel/drf-spectacular) from 0.26.4 to 0.26.5. - [Release notes](https://github.com/tfranzel/drf-spectacular/releases) - [Changelog](https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tfranzel/drf-spectacular/compare/0.26.4...0.26.5) --- updated-dependencies: - dependency-name: drf-spectacular dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Bump drf-spectacular from 0.26.4 to 0.26.5 Bumps [drf-spectacular](https://github.com/tfranzel/drf-spectacular) from 0.26.4 to 0.26.5. - [Release notes](https://github.com/tfranzel/drf-spectacular/releases) - [Changelog](https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tfranzel/drf-spectacular/compare/0.26.4...0.26.5) --- updated-dependencies: - dependency-name: drf-spectacular dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index b7ebf8f29e7..d367a018cdc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -80,7 +80,7 @@ djangorestframework-gis==1.0 djangorestframework-guardian==0.3.0 drf-extensions==0.7.1 drf-writable-nested==0.7.0 -drf-spectacular==0.26.4 +drf-spectacular==0.26.5 dynamic-rest==2.1.2 Markdown==3.4.4 diff --git a/setup.cfg b/setup.cfg index 48f7f529a54..200a6b55b6e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -106,7 +106,7 @@ install_requires = djangorestframework-guardian==0.3.0 drf-extensions==0.7.1 drf-writable-nested==0.7.0 - drf-spectacular==0.26.4 + drf-spectacular==0.26.5 dynamic-rest==2.1.2 Markdown==3.4.4 From 7775303342e0943c537160c8eca2b7852c2ce33a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 09:48:27 +0200 Subject: [PATCH 234/330] Bump google-cloud-storage from 2.10.0 to 2.11.0 (#11524) * Bump google-cloud-storage from 2.10.0 to 2.11.0 Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 2.10.0 to 2.11.0. - [Release notes](https://github.com/googleapis/python-storage/releases) - [Changelog](https://github.com/googleapis/python-storage/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/python-storage/compare/v2.10.0...v2.11.0) --- updated-dependencies: - dependency-name: google-cloud-storage dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Bump google-cloud-storage from 2.10.0 to 2.11.0 Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 2.10.0 to 2.11.0. - [Release notes](https://github.com/googleapis/python-storage/releases) - [Changelog](https://github.com/googleapis/python-storage/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/python-storage/compare/v2.10.0...v2.11.0) --- updated-dependencies: - dependency-name: google-cloud-storage dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Bump google-cloud-storage from 2.10.0 to 2.11.0 Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 2.10.0 to 2.11.0. - [Release notes](https://github.com/googleapis/python-storage/releases) - [Changelog](https://github.com/googleapis/python-storage/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/python-storage/compare/v2.10.0...v2.11.0) --- updated-dependencies: - dependency-name: google-cloud-storage dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index d367a018cdc..4eff0ee1c6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -111,7 +111,7 @@ django-bootstrap3-datetimepicker-2==2.8.3 # storage manager dependencies django-storages==1.14 dropbox==11.36.2 -google-cloud-storage==2.10.0 +google-cloud-storage==2.11.0 google-cloud-core==2.3.3 boto3==1.28.46 diff --git a/setup.cfg b/setup.cfg index 200a6b55b6e..66488c28a26 100644 --- a/setup.cfg +++ b/setup.cfg @@ -136,7 +136,7 @@ install_requires = # storage manager dependencies django-storages==1.14 dropbox==11.36.2 - google-cloud-storage==2.10.0 + google-cloud-storage==2.11.0 google-cloud-core==2.3.3 boto3==1.28.46 From 64555c51b78801d8349ee3be1c4bbe050423fa8e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 09:50:53 +0200 Subject: [PATCH 235/330] Bump jsonschema from 4.19.0 to 4.19.1 (#11527) * Bump jsonschema from 4.19.0 to 4.19.1 Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.19.0 to 4.19.1. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.19.0...v4.19.1) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4eff0ee1c6b..13fb08302a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ vine==5.0.0 tqdm==4.66.1 Deprecated==1.2.14 wrapt==1.15.0 -jsonschema==4.19.0 +jsonschema==4.19.1 zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 diff --git a/setup.cfg b/setup.cfg index 66488c28a26..4909847c20c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -46,7 +46,7 @@ install_requires = tqdm==4.66.1 Deprecated==1.2.14 wrapt==1.15.0 - jsonschema==4.19.0 + jsonschema==4.19.1 zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 From da1939d7da8a4e468400f511f790767af18ff6d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 09:52:28 +0200 Subject: [PATCH 236/330] Update numpy requirement from ==1.25.* to ==1.26.* (#11501) * Update numpy requirement from ==1.25.* to ==1.26.* Updates the requirements on [numpy](https://github.com/numpy/numpy) to permit the latest version. - [Release notes](https://github.com/numpy/numpy/releases) - [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst) - [Commits](https://github.com/numpy/numpy/compare/v1.25.0.dev0...v1.26.0) --- updated-dependencies: - dependency-name: numpy dependency-type: direct:production ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 13fb08302a7..65eec1530db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,7 +60,7 @@ SQLAlchemy==2.0.20 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 -numpy==1.25.* +numpy==1.26.* # # Apps with packages provided in GeoNode's PPA on Launchpad. diff --git a/setup.cfg b/setup.cfg index 4909847c20c..1aa436f6740 100644 --- a/setup.cfg +++ b/setup.cfg @@ -86,7 +86,7 @@ install_requires = Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 - numpy==1.25.* + numpy==1.26.* # # Apps with packages provided in GeoNode's PPA on Launchpad. From b74e96e3c0eb60b194f3adb7e0adb25f23a24fa4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 09:54:08 +0200 Subject: [PATCH 237/330] Bump pathvalidate from 3.1.0 to 3.2.0 (#11504) * Bump pathvalidate from 3.1.0 to 3.2.0 Bumps [pathvalidate](https://github.com/thombashi/pathvalidate) from 3.1.0 to 3.2.0. - [Release notes](https://github.com/thombashi/pathvalidate/releases) - [Commits](https://github.com/thombashi/pathvalidate/compare/v3.1.0...v3.2.0) --- updated-dependencies: - dependency-name: pathvalidate dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Bump pathvalidate from 3.1.0 to 3.2.0 Bumps [pathvalidate](https://github.com/thombashi/pathvalidate) from 3.1.0 to 3.2.0. - [Release notes](https://github.com/thombashi/pathvalidate/releases) - [Commits](https://github.com/thombashi/pathvalidate/compare/v3.1.0...v3.2.0) --- updated-dependencies: - dependency-name: pathvalidate dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 65eec1530db..e6bab075ae5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,7 @@ schema==0.7.5 rdflib==6.3.2 smart_open==6.4.0 PyMuPDF==1.22.5 -pathvalidate==3.1.0 +pathvalidate==3.2.0 # Django Apps django-allauth==0.54.0 diff --git a/setup.cfg b/setup.cfg index 1aa436f6740..5f02decc6ca 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,7 +52,7 @@ install_requires = rdflib==6.3.2 smart_open==6.4.0 PyMuPDF==1.22.5 - pathvalidate==3.1.0 + pathvalidate==3.2.0 # Django Apps django-allauth==0.54.0 From c36e976792f96868898a119e51ee46ff3c4f4caf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 09:56:08 +0200 Subject: [PATCH 238/330] Bump webdriver-manager from 4.0.0 to 4.0.1 (#11530) * Bump webdriver-manager from 4.0.0 to 4.0.1 Bumps [webdriver-manager](https://github.com/SergeyPirogov/webdriver_manager) from 4.0.0 to 4.0.1. - [Release notes](https://github.com/SergeyPirogov/webdriver_manager/releases) - [Changelog](https://github.com/SergeyPirogov/webdriver_manager/blob/master/CHANGELOG.md) - [Commits](https://github.com/SergeyPirogov/webdriver_manager/compare/v4.0.0...v4.0.1) --- updated-dependencies: - dependency-name: webdriver-manager dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Bump webdriver-manager from 4.0.0 to 4.0.1 Bumps [webdriver-manager](https://github.com/SergeyPirogov/webdriver_manager) from 4.0.0 to 4.0.1. - [Release notes](https://github.com/SergeyPirogov/webdriver_manager/releases) - [Changelog](https://github.com/SergeyPirogov/webdriver_manager/blob/master/CHANGELOG.md) - [Commits](https://github.com/SergeyPirogov/webdriver_manager/compare/v4.0.0...v4.0.1) --- updated-dependencies: - dependency-name: webdriver-manager dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index e6bab075ae5..aa5e7a570a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -167,7 +167,7 @@ factory-boy==3.3.0 flaky==3.7.0 selenium>=4.1.0,<5.0.0 selenium-requests==2.0.3 -webdriver_manager==4.0.0 +webdriver_manager==4.0.1 # Security and audit mistune==3.0.1 diff --git a/setup.cfg b/setup.cfg index 5f02decc6ca..b2b810ab90c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -192,7 +192,7 @@ install_requires = flaky==3.7.0 selenium>=4.1.0,<5.0.0 selenium-requests==2.0.3 - webdriver_manager==4.0.0 + webdriver_manager==4.0.1 # Security and audit mistune==3.0.1 From 401d4a5ba4c5527b9a04872ccd38cc2edd0dcf8f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 09:57:31 +0200 Subject: [PATCH 239/330] Bump sqlalchemy from 2.0.20 to 2.0.21 (#11526) * Bump sqlalchemy from 2.0.20 to 2.0.21 Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.20 to 2.0.21. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index aa5e7a570a9..95e9ab0f4d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -56,7 +56,7 @@ pyjwt==2.8.0 pyproj<3.7.0 OWSLib==0.29.2 pycsw==2.6.1 -SQLAlchemy==2.0.20 # required by PyCSW +SQLAlchemy==2.0.21 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 diff --git a/setup.cfg b/setup.cfg index b2b810ab90c..2d23b57b60f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -82,7 +82,7 @@ install_requires = pyproj<3.7.0 OWSLib==0.29.2 pycsw==2.6.1 - SQLAlchemy==2.0.20 # required by PyCSW + SQLAlchemy==2.0.21 # required by PyCSW Shapely==1.8.5.post1 mercantile==1.2.1 geoip2==4.7.0 From 5422fd3d0e9b581f7bcdae9739d27cbc351d6407 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 10:06:47 +0200 Subject: [PATCH 240/330] Bump pillow from 10.0.0 to 10.0.1 (#11505) * Bump pillow from 10.0.0 to 10.0.1 Bumps [pillow](https://github.com/python-pillow/Pillow) from 10.0.0 to 10.0.1. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/10.0.0...10.0.1) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 95e9ab0f4d8..5c16fc20020 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # native dependencies -Pillow==10.0.0 +Pillow==10.0.1 lxml==4.9.3 psycopg2==2.9.7 Django==3.2.21 diff --git a/setup.cfg b/setup.cfg index 2d23b57b60f..b0b687d637d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,7 +25,7 @@ setup_requires = setuptools install_requires = # native dependencies - Pillow==10.0.0 + Pillow==10.0.1 lxml==4.9.3 psycopg2==2.9.7 Django==3.2.21 From ae6a48a87610c2b7ef8846291ff60659c4f1ba79 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 10:08:28 +0200 Subject: [PATCH 241/330] Bump boto3 from 1.28.46 to 1.28.53 (#11528) * Bump boto3 from 1.28.46 to 1.28.53 Bumps [boto3](https://github.com/boto/boto3) from 1.28.46 to 1.28.53. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.28.46...1.28.53) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Bump boto3 from 1.28.46 to 1.28.53 Bumps [boto3](https://github.com/boto/boto3) from 1.28.46 to 1.28.53. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.28.46...1.28.53) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5c16fc20020..62ebeda6b1e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.14 dropbox==11.36.2 google-cloud-storage==2.11.0 google-cloud-core==2.3.3 -boto3==1.28.46 +boto3==1.28.53 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index b0b687d637d..f2b7b1f19a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.11.0 google-cloud-core==2.3.3 - boto3==1.28.46 + boto3==1.28.53 # Django Caches python-memcached<=1.59 From a9afe17e6c116c5eb38e244e80374f39697824aa Mon Sep 17 00:00:00 2001 From: Francisco Vicent Date: Mon, 25 Sep 2023 05:54:19 -0300 Subject: [PATCH 242/330] Dehydrate organization field (#11498) Co-authored-by: Alessio Fabiani --- geonode/api/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/geonode/api/api.py b/geonode/api/api.py index 4303c0caa4b..c80c699ed48 100644 --- a/geonode/api/api.py +++ b/geonode/api/api.py @@ -543,6 +543,7 @@ def dehydrate(self, bundle): documents_count=bundle.data.get("documents_count", 0), maps_count=bundle.data.get("maps_count", 0), layers_count=bundle.data.get("layers_count", 0), + organization=bundle.data.get("organization", 0), ) return bundle From 0248da514eb18a5a5f27002912b76c095ecef0e6 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Tue, 26 Sep 2023 09:46:56 +0200 Subject: [PATCH 243/330] Revert to Ubuntu 22.04 LTS (#11531) --- Dockerfile | 2 +- docker-compose-test.yml | 2 +- docker-compose.yml | 2 +- scripts/docker/base/ubuntu/Dockerfile | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3fb3fe1df84..1db25a2636e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM geonode/geonode-base:latest-ubuntu-22.10 +FROM geonode/geonode-base:latest-ubuntu-22.04 LABEL GeoNode development team # add bower and grunt command diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 14a4b999fab..9bd764f8da3 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -3,7 +3,7 @@ version: '3.9' # Common Django template for GeoNode and Celery services below x-common-django: &default-common-django - image: geonode/geonode:latest-ubuntu-22.10 + image: geonode/geonode:latest-ubuntu-22.04 restart: unless-stopped env_file: - .env_test diff --git a/docker-compose.yml b/docker-compose.yml index 36d6d196fe2..7cc150f129c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ version: '3.9' # Common Django template for GeoNode and Celery services below x-common-django: &default-common-django - image: geonode/geonode:latest-ubuntu-22.10 + image: geonode/geonode:latest-ubuntu-22.04 restart: unless-stopped env_file: - .env diff --git a/scripts/docker/base/ubuntu/Dockerfile b/scripts/docker/base/ubuntu/Dockerfile index 8e406212798..0330c518ed2 100644 --- a/scripts/docker/base/ubuntu/Dockerfile +++ b/scripts/docker/base/ubuntu/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:22.10 +FROM ubuntu:22.04 RUN mkdir -p /usr/src/geonode From 5e8bc63d70de97e835e947c398a2083ee111e823 Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Tue, 26 Sep 2023 11:04:10 +0200 Subject: [PATCH 244/330] Fix parsing release version --- .github/workflows/52n-release.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/52n-release.yaml b/.github/workflows/52n-release.yaml index c98640c39bd..3c7aad9527a 100644 --- a/.github/workflows/52n-release.yaml +++ b/.github/workflows/52n-release.yaml @@ -27,6 +27,7 @@ jobs: uses: booxmedialtd/ws-action-parse-semver@v1 with: input_string: "${{github.ref_name}}" + version_extractor_regex: 'v(.*)-thuenen' - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 @@ -37,7 +38,7 @@ jobs: env: MAJOR_VERSION: ${{ steps.semver_parser.outputs.major }} MAJOR_MINOR_VERSION: ${{ steps.semver_parser.outputs.major }}-${{ steps.semver_parser.outputs.minor }} - MAJOR_MINOR_PATCH_VERSION: ${{ steps.semver_parser.outputs.major }}-${{ steps.semver_parser.outputs.minor }}-${{ steps.semver_parser.outputs.patch }} + MAJOR_MINOR_PATCH_VERSION: ${{ steps.semver_parser.outputs.fullversion }} with: images: ${{ env.IMAGE }} labels: | @@ -59,7 +60,11 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Fail in case fully tagged version already exists - run: docker manifest inspect ${{ env.IMAGE }}:${{ steps.meta.env.MAJOR_MINOR_PATCH_VERSION }} > /dev/null ; test $? != 0 + run: | + if docker manifest inspect ${{ env.IMAGE }}:${{ steps.semver_parser.outputs.fullversion }}; then + echo "tag version already exists! Will not override." + exit 1 + fi - name: Build and push uses: docker/build-push-action@v4 From 6cd67df5975214b247722388c73319d62146e9a5 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Wed, 27 Sep 2023 16:43:55 +0200 Subject: [PATCH 245/330] [Fixes #11535] Return category description in API response (#11536) * Return category description in API response * fix formatting --- geonode/base/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geonode/base/api/serializers.py b/geonode/base/api/serializers.py index 3e18a25138b..74d74e9a236 100644 --- a/geonode/base/api/serializers.py +++ b/geonode/base/api/serializers.py @@ -197,7 +197,7 @@ class SimpleTopicCategorySerializer(DynamicModelSerializer): class Meta: model = TopicCategory name = "TopicCategory" - fields = ("identifier",) + fields = ("identifier", "gn_description") class RestrictionCodeTypeSerializer(DynamicModelSerializer): From bba8cebca1e566f3e9f9c8615fd7d0c8e4bb88a0 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 28 Sep 2023 10:02:12 +0200 Subject: [PATCH 246/330] [Fixes #11533] Remote resources created by harvesters do not have remote subtype (#11537) * Set subtype remote for any remote resurce --- geonode/harvesting/harvesters/base.py | 1 + .../harvesting/harvesters/geonodeharvester.py | 1 + geonode/harvesting/tests/test_integrations.py | 100 ++++++++++++++++++ geonode/resource/utils.py | 7 -- 4 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 geonode/harvesting/tests/test_integrations.py diff --git a/geonode/harvesting/harvesters/base.py b/geonode/harvesting/harvesters/base.py index 5a2c484e9d0..c10b4c035d5 100644 --- a/geonode/harvesting/harvesters/base.py +++ b/geonode/harvesting/harvesters/base.py @@ -247,6 +247,7 @@ def get_geonode_resource_defaults( if self.should_copy_resource(harvestable_resource): defaults["sourcetype"] = enumerations.SOURCE_TYPE_COPYREMOTE else: + defaults["subtype"] = "remote" defaults["sourcetype"] = enumerations.SOURCE_TYPE_REMOTE return {key: value for key, value in defaults.items() if value is not None} diff --git a/geonode/harvesting/harvesters/geonodeharvester.py b/geonode/harvesting/harvesters/geonodeharvester.py index e7103e9bf1c..ec31b84b1d4 100644 --- a/geonode/harvesting/harvesters/geonodeharvester.py +++ b/geonode/harvesting/harvesters/geonodeharvester.py @@ -261,6 +261,7 @@ def get_geonode_resource_defaults( "thumbnail_url": harvested_info.resource_descriptor.distribution.thumbnail_url, "srid": srid, "ptype": GXP_PTYPES["GN_WMS"], + "subtype": "remote", } ) return defaults diff --git a/geonode/harvesting/tests/test_integrations.py b/geonode/harvesting/tests/test_integrations.py new file mode 100644 index 00000000000..37d3cb7aaf6 --- /dev/null +++ b/geonode/harvesting/tests/test_integrations.py @@ -0,0 +1,100 @@ +############################################## +# +# Copyright (C) 2021 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### +import uuid +import datetime as dt +from unittest import mock + +from django.test.utils import override_settings +from django.contrib.auth import get_user_model +from geonode.tests.base import GeoNodeBaseTestSupport +from geonode.layers.models import Dataset + +from geonode.harvesting.resourcedescriptor import ( + RecordDescriptionContact, + RecordDistribution, + RecordIdentification, + RecordDescription, +) +from geonode.harvesting.models import Harvester, HarvestableResource, AsynchronousHarvestingSession +from geonode.harvesting.harvesters.base import HarvestedResourceInfo +from geonode.harvesting.harvesters.geonodeharvester import GeonodeUnifiedHarvesterWorker + + +class HarvesterIntegrationsTestCase(GeoNodeBaseTestSupport): + unique_identifier = "id" + title = "Test" + remote_url = "test.com" + name = "This is a geonode harvester" + user = get_user_model().objects.get(username="AnonymousUser") + harvester_type = "geonode.harvesting.harvesters.geonodeharvester.GeonodeLegacyHarvester" + + @override_settings(ASYNC_SIGNALS=False) + def setUp(self): + super().setUp() + + self.record_description_contact = mock.MagicMock(RecordDescriptionContact) + self.record_distribution = mock.MagicMock(RecordDistribution) + self.record_distribution.thumbnail_url.return_value = "thumb.png" + self.record_distribution.wms_url.return_value = "http://test.com/geoserver" + self.record_identification = RecordIdentification(name=self.name, title=self.title, other_keywords=[]) + + self.harvester = Harvester.objects.create( + remote_url=self.remote_url, name=self.name, default_owner=self.user, harvester_type=self.harvester_type + ) + self.harvestable_resource = HarvestableResource.objects.create( + unique_identifier=self.unique_identifier, + title=self.title, + harvester=self.harvester, + last_refreshed=dt.datetime.now(), + remote_resource_type="dataset", + ) + + self.session = AsynchronousHarvestingSession.objects.create( + harvester=self.harvester, session_type=AsynchronousHarvestingSession.TYPE_HARVESTING + ) + + self.record_description = RecordDescription( + uuid=uuid.uuid4(), + author=self.record_description_contact, + date_stamp=dt.datetime.now(), + distribution=self.record_distribution, + identification=self.record_identification, + point_of_contact=self.record_description_contact, + reference_systems=["EPSG:3857"], + additional_parameters={ + "alternate": "geonode:test", + "workspace": "geonode", + "subtype": "vector", + "resource_type": "", + }, + ) + + self.harvested_info = HarvestedResourceInfo( + resource_descriptor=self.record_description, copied_resources=[], additional_information=None + ) + + @mock.patch( + "geonode.harvesting.harvesters.geonodeharvester.GeonodeCurrentHarvester.check_availability", + mock.Mock(return_value=True), + ) + def test_remote_subtype_is_set_for_harvested_dataset(self): + worker = GeonodeUnifiedHarvesterWorker(remote_url=self.remote_url, harvester_id=self.harvester.id) + worker.update_geonode_resource(self.harvested_info, self.harvestable_resource) + dataset = Dataset.objects.get(alternate="geonode:test") + self.assertEqual(dataset.subtype, "remote") diff --git a/geonode/resource/utils.py b/geonode/resource/utils.py index 5490d85cca6..5a557f8ceb9 100644 --- a/geonode/resource/utils.py +++ b/geonode/resource/utils.py @@ -233,11 +233,6 @@ def update_resource( to_update["ows_url"] = defaults.pop("ows_url", getattr(instance, "ows_url", None)) or _default_ows_url to_update.update(defaults) - try: - ResourceBase.objects.filter(id=instance.resourcebase_ptr.id).update(**defaults) - except Exception as e: - logger.error(f"{e} - {defaults}") - raise try: instance.get_real_concrete_instance_class().objects.filter(id=instance.id).update(**to_update) except Exception as e: @@ -254,8 +249,6 @@ def update_resource( _s = Service.objects.filter(harvester=_h).get() _to_update = { "remote_typename": _s.name, - # "ows_url": _s.service_url, - "subtype": "remote", } if hasattr(instance, "remote_service"): _to_update["remote_service"] = _s From 8bc6132cf5478ee4a78911ca44916fd67f01fe01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Sep 2023 10:38:59 +0200 Subject: [PATCH 247/330] Bump pymupdf from 1.22.5 to 1.23.1 (#11412) * - Align setup.cfg to requirements.txt * Bump to PyMuPDF 1.23.1 --------- Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 62ebeda6b1e..0ad9aa5ed4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 smart_open==6.4.0 -PyMuPDF==1.22.5 +PyMuPDF==1.23.1 pathvalidate==3.2.0 # Django Apps diff --git a/setup.cfg b/setup.cfg index f2b7b1f19a2..cde879727ad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -51,7 +51,7 @@ install_requires = schema==0.7.5 rdflib==6.3.2 smart_open==6.4.0 - PyMuPDF==1.22.5 + PyMuPDF==1.23.1 pathvalidate==3.2.0 # Django Apps From 93aa6100e8dc440fc150111d6603ac91caacf4b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Sep 2023 13:13:51 +0200 Subject: [PATCH 248/330] Bump pymupdf from 1.23.1 to 1.23.4 (#11540) * Bump pymupdf from 1.23.1 to 1.23.4 Bumps [pymupdf](https://github.com/pymupdf/pymupdf) from 1.23.1 to 1.23.4. - [Release notes](https://github.com/pymupdf/pymupdf/releases) - [Changelog](https://github.com/pymupdf/PyMuPDF/blob/main/changes.txt) - [Commits](https://github.com/pymupdf/pymupdf/compare/1.23.1...1.23.4) --- updated-dependencies: - dependency-name: pymupdf dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0ad9aa5ed4e..cca54c41c83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ zipstream-new==1.1.8 schema==0.7.5 rdflib==6.3.2 smart_open==6.4.0 -PyMuPDF==1.23.1 +PyMuPDF==1.23.4 pathvalidate==3.2.0 # Django Apps diff --git a/setup.cfg b/setup.cfg index cde879727ad..c80c1ab9c53 100644 --- a/setup.cfg +++ b/setup.cfg @@ -51,7 +51,7 @@ install_requires = schema==0.7.5 rdflib==6.3.2 smart_open==6.4.0 - PyMuPDF==1.23.1 + PyMuPDF==1.23.4 pathvalidate==3.2.0 # Django Apps From 616645c34113221530bba9017cc7bcea2c993652 Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Thu, 5 Oct 2023 13:12:49 +0200 Subject: [PATCH 249/330] Test fix CircleCi test (#11560) * Test fix CircleCi test * Test fix CircleCi test * Test fix CircleCi test --- geonode/upload/api/tests.py | 80 ++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/geonode/upload/api/tests.py b/geonode/upload/api/tests.py index 8f84fec6a92..81cd4a3ea3f 100644 --- a/geonode/upload/api/tests.py +++ b/geonode/upload/api/tests.py @@ -17,7 +17,9 @@ # ######################################################################### +from geonode.base.models import ResourceBase from geonode.resource.models import ExecutionRequest +from geonode.geoserver.helpers import gs_catalog import os import shutil import logging @@ -233,35 +235,67 @@ def test_rest_uploads(self): """ Ensure we can access the Local Server Uploads list. """ - # Try to upload a good raster file and check the session IDs - fname = os.path.join(GOOD_DATA, "raster", "relief_san_andres.tif") - resp, data = rest_upload_by_path(fname, self.client) - self.assertEqual(resp.status_code, 201) - - url = reverse("uploads-list") - # Anonymous - self.client.logout() - response = self.client.get(url, format="json") - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 5) - self.assertEqual(response.data["total"], 0) - # Pagination - self.assertEqual(len(response.data["uploads"]), 0) - logger.debug(response.data) + resp = None + layer_name = "relief_san_andres" + try: + self._cleanup_layer(layer_name=layer_name) + # Try to upload a good raster file and check the session IDs + fname = os.path.join(GOOD_DATA, "raster", "relief_san_andres.tif") + resp, data = rest_upload_by_path(fname, self.client) + self.assertEqual(resp.status_code, 201) + + url = reverse("uploads-list") + # Anonymous + self.client.logout() + response = self.client.get(url, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 5) + self.assertEqual(response.data["total"], 0) + # Pagination + self.assertEqual(len(response.data["uploads"]), 0) + logger.debug(response.data) + except Exception: + if resp.json().get("errors"): + layer_name = resp.json().get("errors")[0].split("for : ")[1].split(",")[0] + finally: + self._cleanup_layer(layer_name) @override_settings(CELERY_TASK_ALWAYS_EAGER=True) def test_rest_uploads_non_interactive(self): """ Ensure we can access the Local Server Uploads list. """ - # Try to upload a good raster file and check the session IDs - fname = os.path.join(GOOD_DATA, "raster", "relief_san_andres.tif") - resp, data = rest_upload_by_path(fname, self.client, non_interactive=True) - self.assertEqual(resp.status_code, 201) - - exec_id = data.get("execution_id", None) - _exec = ExecutionRequest.objects.get(exec_id=exec_id) - self.assertEqual(_exec.status, "finished") + resp = None + layer_name = "relief_san_andres" + try: + self._cleanup_layer(layer_name=layer_name) + # Try to upload a good raster file and check the session IDs + fname = os.path.join(GOOD_DATA, "raster", "relief_san_andres.tif") + resp, data = rest_upload_by_path(fname, self.client, non_interactive=True) + self.assertEqual(resp.status_code, 201) + exec_id = data.get("execution_id", None) + _exec = ExecutionRequest.objects.get(exec_id=exec_id) + self.assertEqual(_exec.status, "finished") + except Exception: + if resp.json().get("errors"): + layer_name = resp.json().get("errors")[0].split("for : ")[1].split(",")[0] + finally: + self._cleanup_layer(layer_name) + + def _cleanup_layer(self, layer_name): + # removing the layer from geonode + x = ResourceBase.objects.filter(alternate__icontains=layer_name) + if x.exists(): + for el in x.iterator(): + el.delete() + # removing the layer from geoserver + dataset = gs_catalog.get_layer(layer_name) + if dataset: + gs_catalog.delete(dataset, purge="all", recurse=True) + # removing the layer from geoserver + store = gs_catalog.get_store(layer_name, workspace="geonode") + if store: + gs_catalog.delete(store, purge="all", recurse=True) @mock.patch("geonode.upload.uploadhandler.SimpleUploadedFile") def test_rest_uploads_with_size_limit(self, mocked_uploaded_file): From 2ef2a5094f502dad0e76136f98f7217e9f29b34a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Oct 2023 13:14:26 +0200 Subject: [PATCH 250/330] Bump boto3 from 1.28.53 to 1.28.56 (#11539) * Bump boto3 from 1.28.53 to 1.28.56 Bumps [boto3](https://github.com/boto/boto3) from 1.28.53 to 1.28.56. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.28.53...1.28.56) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani Co-authored-by: Giovanni Allegri --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index cca54c41c83..5e9989c8705 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-storages==1.14 dropbox==11.36.2 google-cloud-storage==2.11.0 google-cloud-core==2.3.3 -boto3==1.28.53 +boto3==1.28.56 # Django Caches python-memcached<=1.59 diff --git a/setup.cfg b/setup.cfg index c80c1ab9c53..f69ad657dcb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,7 +138,7 @@ install_requires = dropbox==11.36.2 google-cloud-storage==2.11.0 google-cloud-core==2.3.3 - boto3==1.28.53 + boto3==1.28.56 # Django Caches python-memcached<=1.59 From 980189c0c8e9d6fb6408447c4ee0cb3730d2c080 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Oct 2023 13:14:46 +0200 Subject: [PATCH 251/330] Bump django-mptt from 0.14.0 to 0.15.0 (#11541) * Bump django-mptt from 0.14.0 to 0.15.0 Bumps [django-mptt](https://github.com/django-mptt/django-mptt) from 0.14.0 to 0.15.0. - [Release notes](https://github.com/django-mptt/django-mptt/releases) - [Changelog](https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst) - [Commits](https://github.com/django-mptt/django-mptt/compare/0.14...0.15) --- updated-dependencies: - dependency-name: django-mptt dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * - Align setup.cfg to requirements.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: afabiani --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5e9989c8705..7d1b4a9b914 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,7 +36,7 @@ django-filter==23.3 django-imagekit==4.1.0 django-taggit==1.5.1 django-markdownify==0.9.3 -django-mptt==0.14.0 +django-mptt==0.15.0 django-modeltranslation>=0.11,<0.19.0 django-treebeard==4.7 django-guardian<2.4.1 diff --git a/setup.cfg b/setup.cfg index f69ad657dcb..2b22c939e0f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -62,7 +62,7 @@ install_requires = django-imagekit==4.1.0 django-taggit==1.5.1 django-markdownify==0.9.3 - django-mptt==0.14.0 + django-mptt==0.15.0 django-modeltranslation>=0.11,<0.19.0 django-treebeard==4.7 django-guardian<2.4.1 From 0438ff3b76a4978d2be1af4e72221d440865ba4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Oct 2023 13:15:12 +0200 Subject: [PATCH 252/330] Bump django-storages from 1.14 to 1.14.1 (#11547) Bumps [django-storages](https://github.com/jschneier/django-storages) from 1.14 to 1.14.1. - [Changelog](https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jschneier/django-storages/compare/1.14...1.14.1) --- updated-dependencies: - dependency-name: django-storages dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7d1b4a9b914..1e46920c20d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -109,7 +109,7 @@ elasticsearch>=2.0.0,<9.0.0 django-bootstrap3-datetimepicker-2==2.8.3 # storage manager dependencies -django-storages==1.14 +django-storages==1.14.1 dropbox==11.36.2 google-cloud-storage==2.11.0 google-cloud-core==2.3.3 From ec0429af74cf4805ef4f21e8839bbe5948406ff4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Oct 2023 13:15:45 +0200 Subject: [PATCH 253/330] Bump psycopg2 from 2.9.7 to 2.9.8 (#11550) Bumps [psycopg2](https://github.com/psycopg/psycopg2) from 2.9.7 to 2.9.8. - [Changelog](https://github.com/psycopg/psycopg2/blob/master/NEWS) - [Commits](https://github.com/psycopg/psycopg2/compare/2.9.7...2.9.8) --- updated-dependencies: - dependency-name: psycopg2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1e46920c20d..b39fc3d8091 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # native dependencies Pillow==10.0.1 lxml==4.9.3 -psycopg2==2.9.7 +psycopg2==2.9.8 Django==3.2.21 # Other From 9f88645458c86a690f8c01572c7a7a88cee0f624 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Oct 2023 13:16:22 +0200 Subject: [PATCH 254/330] Bump mistune from 3.0.1 to 3.0.2 (#11549) Bumps [mistune](https://github.com/lepture/mistune) from 3.0.1 to 3.0.2. - [Release notes](https://github.com/lepture/mistune/releases) - [Changelog](https://github.com/lepture/mistune/blob/v3.0.2/docs/changes.rst) - [Commits](https://github.com/lepture/mistune/compare/v3.0.1...v3.0.2) --- updated-dependencies: - dependency-name: mistune dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b39fc3d8091..10f3976e521 100644 --- a/requirements.txt +++ b/requirements.txt @@ -170,7 +170,7 @@ selenium-requests==2.0.3 webdriver_manager==4.0.1 # Security and audit -mistune==3.0.1 +mistune==3.0.2 protobuf==3.20.3 mako==1.2.4 paramiko==3.3.1 # not directly required, fixes Blowfish deprecation warning From e65f4a4f9770127aba82c4b25d28f91c206ee8e1 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 5 Oct 2023 14:13:55 +0200 Subject: [PATCH 255/330] [Fixes #11554] Implement WMTS backgrounds for thumbnails (only 3857 tilematricsets) (#11555) * Implement WMTS backgrounds for thumbnails (only 3857 tilematricsets) * use fictious WMTS service url * fix E501 --- geonode/thumbs/background.py | 200 ++++++++++ .../expected_results/tiles/background.png | Bin 0 -> 118362 bytes .../expected_results/tiles/wmts_7_5_4.png | Bin 0 -> 27883 bytes .../expected_results/tiles/wmts_7_6_4.png | Bin 0 -> 34620 bytes .../expected_results/tiles/wmts_8_5_4.png | Bin 0 -> 35010 bytes .../expected_results/tiles/wmts_8_6_4.png | Bin 0 -> 35907 bytes .../expected_results/tiles/wmts_9_5_4.png | Bin 0 -> 31143 bytes .../expected_results/tiles/wmts_9_6_4.png | Bin 0 -> 60528 bytes geonode/thumbs/tests/test_backgrounds.py | 371 ++++++++++++++++++ 9 files changed, 571 insertions(+) create mode 100644 geonode/thumbs/tests/expected_results/tiles/background.png create mode 100644 geonode/thumbs/tests/expected_results/tiles/wmts_7_5_4.png create mode 100644 geonode/thumbs/tests/expected_results/tiles/wmts_7_6_4.png create mode 100644 geonode/thumbs/tests/expected_results/tiles/wmts_8_5_4.png create mode 100644 geonode/thumbs/tests/expected_results/tiles/wmts_8_6_4.png create mode 100644 geonode/thumbs/tests/expected_results/tiles/wmts_9_5_4.png create mode 100644 geonode/thumbs/tests/expected_results/tiles/wmts_9_6_4.png create mode 100644 geonode/thumbs/tests/test_backgrounds.py diff --git a/geonode/thumbs/background.py b/geonode/thumbs/background.py index 8896a5353c3..85bceb64159 100644 --- a/geonode/thumbs/background.py +++ b/geonode/thumbs/background.py @@ -21,13 +21,16 @@ import ast import typing import logging +import math import mercantile +import requests from io import BytesIO from pyproj import Transformer from abc import ABC, abstractmethod from math import ceil, floor, copysign from PIL import Image, UnidentifiedImageError +from owslib.wmts import WebMapTileService from django.conf import settings from django.utils.html import strip_tags @@ -464,3 +467,200 @@ def __init__( self.url = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" self.tile_size = 256 + + +WMTS_TILEMATRIXSET_LEVELS = None + + +class GenericWMTSBackground(BaseThumbBackground): + def __init__(self, thumbnail_width: int, thumbnail_height: int, max_retries: int = 3, retry_delay: int = 1): + super().__init__(thumbnail_width, thumbnail_height, max_retries, retry_delay) + self.options = settings.THUMBNAIL_BACKGROUND.get("options", {}) + self.levels = self.get_levels_for_tilematrix() + + self.thumbnail_width = thumbnail_width + self.thumbnail_height = thumbnail_height + + def fetch(self, bbox: typing.List, *args, **kwargs): + bbox = [bbox[0], bbox[2], bbox[1], bbox[3]] + target_pixelspan = self.get_target_pixelspan(bbox) + level = self.get_level_for_targetpixelspan(target_pixelspan) + + tilewidth = level["tilewidth"] + tileheight = level["tileheight"] + zoom = level["zoom"] + pixelspan = level["pixelspan"] + tilespanx = level["tilespanx"] + tilespany = level["tilespany"] + + pixelspan_ratio = level["pixelspan"] / target_pixelspan + + tile_rowcols = self.get_tiles_coords(level, bbox) + tiles_cols_list = set([tile_rowcol[0] for tile_rowcol in tile_rowcols]) + tiles_mincol = min(tiles_cols_list) + tiles_maxcol = max(tiles_cols_list) + tiles_minx = level["bounds"][0] + (tiles_mincol * tilespanx) + tiles_rows_list = set([tile_rowcol[1] for tile_rowcol in tile_rowcols]) + tiles_minrow = min(tiles_rows_list) + tiles_maxrow = max(tiles_rows_list) + tiles_maxy = level["bounds"][3] - (tiles_minrow * tilespany) + + tiles_width = (tiles_maxcol - tiles_mincol + 1) * tilewidth + tiles_height = (tiles_maxrow - tiles_minrow + 1) * tileheight + + background = Image.new("RGB", (tiles_width, tiles_height), (250, 250, 250)) + + for tile_coord in tile_rowcols: + try: + im = None + imgurl = self.build_request([tile_coord[0], tile_coord[1], zoom]) + resp = requests.get(imgurl) + if resp.status_code > 400: + raise Exception(f"{strip_tags(resp.content)}") + im = BytesIO(resp.content) + Image.open(im).verify() + if im: + offsetx = (tile_coord[0] - tiles_mincol) * tilewidth + offsety = (tile_coord[1] - tiles_minrow) * tileheight + image = Image.open(im) + background.paste(image, (offsetx, offsety)) + except Exception as e: + logger.error(f"Error fetching {imgurl} for thumbnail: {e}") + + left = abs(tiles_minx - bbox[0]) / pixelspan + right = left + self.thumbnail_width + top = abs(tiles_maxy - bbox[3]) / pixelspan + bottom = top + self.thumbnail_height + background = background.crop((left, top, right, bottom)) + + width = round(self.thumbnail_width * pixelspan_ratio) + height = round(self.thumbnail_height * pixelspan_ratio) + + background = background.resize((width, height)) + background.crop((left, top, right, bottom)) + + return background + + def build_kvp_request(self, baseurl, layer, style, xyz): + return f"{baseurl}?&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer={layer}&style={style} \ + &tilematrixset={self.options['tilematrixset']}&TileMatrix={xyz[2]}&TileRow={xyz[1]}&TileCol={xyz[0]}" + + def build_request(self, xyz): + request_encoding = self.options.get("requestencoding", "KVP") + baseurl = self.options["url"] + layer = self.options["layer"] + style = self.options["style"] + + imgurl = None + if request_encoding == "KVP": + imgurl = self.build_kvp_request(baseurl, layer, style, xyz) + + return imgurl + + def get_image_bbox_for_level(self, level, bbox): + image_width = self.thumbnail_width + image_height = self.thumbnail_height + + half_imagespanx = image_width * level["pixelspan"] / 2 + half_imagespany = image_height * level["pixelspan"] / 2 + + ( + boundsminx, + boundsminy, + boundsmaxx, + boundsmaxy, + ) = bbox + + bboxcentrex = boundsminx + ((boundsmaxx - boundsminx) / 2) + bboxcentrey = boundsminy + ((boundsmaxy - boundsminy) / 2) + + image_minx = bboxcentrex - half_imagespanx + image_maxx = bboxcentrex + half_imagespanx + image_miny = bboxcentrey - half_imagespany + image_maxy = bboxcentrey + half_imagespany + + return [image_minx, image_miny, image_maxx, image_maxy] + + def get_tiles_coords(self, level, bbox): + tile_coords = [] + + tilematrixminx = level["bounds"][0] + tilematrixmaxy = level["bounds"][3] + tilespanx = level["tilespanx"] + tilespany = level["tilespany"] + + boundsminx, boundsminy, boundsmaxx, boundsmaxy = bbox + + tile_coord_minx = int(math.floor(boundsminx - tilematrixminx) / tilespanx) + # min tile coord corresponds to the maxy coordinate + tile_coord_miny = int(math.floor(tilematrixmaxy - boundsmaxy) / tilespany) + tile_coord_maxx = int(math.floor(boundsmaxx - tilematrixminx) / tilespanx) + # max tile coord corresponds to the miny coordinate + tile_coord_maxy = int(math.floor(tilematrixmaxy - boundsminy) / tilespany) + + for x in range(tile_coord_minx, tile_coord_maxx + 1): + for y in range(tile_coord_miny, tile_coord_maxy + 1): + tile_coords.append([x, y]) + + return tile_coords + + def get_level_for_targetpixelspan(self, target_pixelspan): + level = None + for _level in self.levels: + is_level_under_minscaledenominator = False + minscaledenominator = self.options.get("minscaledenominator") + if minscaledenominator: + is_level_under_minscaledenominator = _level["scaledenominator"] < self.options.get( + "minscaledenominator" + ) + if _level["pixelspan"] < target_pixelspan or is_level_under_minscaledenominator: + return level + level = _level + + def get_target_pixelspan(self, bbox): + x_min, y_min, x_max, y_max = bbox + return (x_max - x_min) / self.thumbnail_width + + def get_levels_for_tilematrix(self): + url = self.options["url"] + tilematrixset = self.options["tilematrixset"] + global WMTS_TILEMATRIXSET_LEVELS + if not WMTS_TILEMATRIXSET_LEVELS: + service = WebMapTileService(url=url) + tilematrixsset = service.tilematrixsets[tilematrixset] + + levels = [] + for index, tilematrix in tilematrixsset.tilematrix.items(): + scaledenominator = tilematrix.scaledenominator * 1 # here we assume 3857 + matrixheight = tilematrix.matrixheight + matrixwidth = tilematrix.matrixwidth + tileheight = tilematrix.tileheight + tilewidth = tilematrix.tilewidth + tilematrixminx = tilematrix.topleftcorner[0] # here we assume 3857 + tilematrixmaxy = tilematrix.topleftcorner[1] # here we assume 3857 + + pixelspan = scaledenominator * 0.00028 # OGC standardized rendering pixel size + tilespanx = tilewidth * pixelspan + tilespany = tileheight * pixelspan + tilematrixmaxx = tilematrixminx + tilespanx * matrixwidth + tilematrixminy = tilematrixmaxy - tilespany * matrixheight + + levels.append( + { + "zoom": int(index), + "bounds": [ + tilematrixminx, + tilematrixminy, + tilematrixmaxx, + tilematrixmaxy, + ], + "scaledenominator": scaledenominator, + "tilewidth": tilewidth, + "tileheight": tileheight, + "pixelspan": pixelspan, + "tilespanx": tilespanx, + "tilespany": tilespany, + } + ) + WMTS_TILEMATRIXSET_LEVELS = levels + return WMTS_TILEMATRIXSET_LEVELS diff --git a/geonode/thumbs/tests/expected_results/tiles/background.png b/geonode/thumbs/tests/expected_results/tiles/background.png new file mode 100644 index 0000000000000000000000000000000000000000..d345f1ce362a31a6ae5cb0d430ffcf9171d6e9ef GIT binary patch literal 118362 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 literal 0 HcmV?d00001 diff --git a/geonode/thumbs/tests/expected_results/tiles/wmts_7_6_4.png b/geonode/thumbs/tests/expected_results/tiles/wmts_7_6_4.png new file mode 100644 index 0000000000000000000000000000000000000000..de1b9c8d700161e07939d6a6d6bfba462b76beed GIT binary patch literal 34620 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

    aH<~Cfk*WP+~QDS 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

    R_7_EE0gB23crg4a-#}71OZ$N; zhoIfM43DUGb6GGLL6|_7|1lv>k~(bU!~#MT#2dmddSHN?uxw-tL!dTAbjk{GRH=S$ z@5Y*ej2D6xn;n^V?033EMpiDLxHYW!9P&;slLa6s=Cb}`Kb2`kZf#Ju5W&o_gjD9I zu>2P`TN}vyGZT>lM_qra|2u9>3`p4F6KC+-VUjEafJMZu%z*(@`Ey9(jGu|F^8?Y6 z7_eD`ViXG>C~U~&26;V*oh%hat(=d*0aT$T$z zSJYuLfdTwC&Ed{m@%yy$SbzRMA%fW&CU6D(+eCE36(6`H1q>-*28r#EO)QC5txL7m zUI7;eQNu4+B#2Tl@voG1v)$V}zd73LXGQSN7jwQ^r`Eh(e; zaRUeeiUo^91`=i{eWOMYS0FxcPMW|d2*jufV6`Fn%-Nbx=nbIgxEn7MTen2NXCryH5DXgSQNYb>8^x9 zf(cwkfHBigNFa$t>LE6cqaVh%%sQ2c24x&vu}T1C6^Q1J@xeQWi4n*a)+rSl3$o_B z7GJP%kPV=iQjI?|!KZk*4De#mLrxv4u)z2xA@Y)HjevTW>`?~0Axe;wPp3a%l21%? zT!&z?E56MY8Bv$0pad^U(oil~y@2bRc1z^)iUJ_g)k z;w%CEu{>*`H~LKuhN<0WESQN$5i@Loc?E|xsiU^Ymt@YuLU|GoomY;-lgmb7~goQyo%S{Mro6Ph&XVT-P60k!Va{z}14?Pn92#+btwxL>}A!@$#e+*`^BJLE)k!1zaUas7>*Z?CLs8q~5!~@@t)F7}?1~=1+ zSrSagWC7u9Op-8mODp3b3S7YfkPJIWe2N&vk@2HA0HojvFL?_QNn{4HS|Nsy8Bhv- zrWio{2CSb-9hO93VFqw2K0ytDo2x01Wt0F8Ho&kmg9{S^(h0vTNnL~j;u_?uh5hoL zSst+RBY(o)GG~lTPk{!ItuSLr5CQ&_)3VBoDTLE>P{FxFwhD-Xt(`P#XqY4lhJ4Ng zArHvjA3-7LGaAw$JHdt*8BQYz7)T2FEM90=5SmD`B_#E%6|^CpCI&6w#}hrlLL*6q zW>&3o&JEyQ2t3HTr;8{NhBed7AFF(Y+X|}!g=9EI0K}C_)Wq)0i2UY->((G0HUpF+ z6X*-zC;OMOywh4Gj%36vnBcYca8DegapbQ~oKt_PQxOfjP76MEQ&~BU3_yz`9LYHE znIc2TK91zU#Nt#q2cuw#*33ZyT@h$Pfh;4O!bj{e5vwnYUQRumsupozw*c3f3rOL> z7#wlg4{a8*;q7Ev=i7~V%JQ;Vuk!&Q)PD( zP~xQe%*4io36|DkVsbae^-?vRnaY6RBqoOOwMqjgI#^#F(Pb244HJIaSD2FerpC!PAM(M|GrndM|2|htRM>W|# zhSy3ql;Z)%p$GtyCNE7Y#3gVIlcx@{Wa}Ug5Hajz zGA6Z91!j4xAtXntgPtTwL|gShEGdczs%@H>GCgR-&GDt zAVBnJmed6^99V|TzJ`o2f*}!%oXc%#jL%@?3|K2TUrL2cY#>CZ5-vda4Kfs-tUkTQ zCtWvU(ZS)GRc{IV)vE{$E?|spe6obvID?tQL;E_lFB;OH+v&NIP<74-uX6Yf~#`MoX`m!adUU+!HT`XjGw) zSVhTmbpxr;S}87D6ju^NS!N*}hsai@GaDw#o1_SN z3I0n0N>fF=0w%LD2Mn9au1$*A)e>SD^(ij0Wk#-xXPTZ(YEYKEsH&XuR$6B*GDMu# z&WOE>dHqQjM^$*nqUR&skX7-Vn=3Q&Or2j|c}DnxIF$;RdIlRq2##<)m_c#$!Vyym zzPtFVVH%gpq=yEu-{_i_A`p-ODb%NoTMEu<7n!7EYDI4HGm}A5#qEuy3S?3M>^eCMX!Ccp|W(B${DURZFFe zY0^n*lbMW*(YOFExvo5)7{JzB%MYnrv&0Krtl)qVKZ1@Nlu@l2&D4H_Y5_r-7PptA zgn>molNp;f_H)*3cNs0FBJxH(Kq3KSYOAU!sB-)Vw+)+-y7K!4AW#<;KtYZ4(HIK@ z*d7#`+SJlxR-h3nbB03z@tR#|&3zYH1mK9b@}vjS(DR^L#X^gP6APMiTShk87g9)O z9N~v`qc%{=^#2&CQ_ofCELw%AN<>T^OC(idze-u1>y(0+v}(Z1XV9_W8B^tpdFiEI zKe0DmDDTmRGE0~N?DqM2jQC8kPrCKZGZFQ9AhE|ag7XJeFRNxO(-6ge_$2JV>y zJbiVnp4_)EKQf1uyT{++L;E1aD`PpT$1IA zZx)0vMGlz&VZjJ!z)7{YOwcEyXXeBr^_1D>Wow1m`EbgRi=;wUfx3mmuRy;=3Y6s} zq=Ipc(IVesF5pd5r|^J2#m?e3!8_0{6{|e2LSZa1Y)@Jx4Kp=@Dhq`*jlf3G2x~>z zhe#%EVOXrJCa$ns;xA&rP_mOLM~j}|0SW8SpJySksZ^0GLq-|I4B#kM?2yemxttY- z#TNzg(;QudxdqmoJHib~QZ!p29KpXC)IYb@pb+XMM3(?5MOtZc%u|@9?g2NovSA;v zeu6POp+hJj4%Li3508MtsZ7$DF6s3G7qf; zoOl#17z!egq!Ef+h4!Sf4y0BBrI!xLl0s^%QsL)vjO{~6P;#YzrY31Ko7pj*_*JgG zDFYVxout5J&y_6ELZ3DP!~J~GA;_LMg*b?FPLAOOm7tg0>2fY5TX;)h650PL(uVl9 z`b}1d5x|0p7;+U^KSF{T1fzT(45?x+v!vX7Mv9EmYhH{Z@RICJBie5Y}or7GGwPgH7QEJ&E-Y(M#qZVZ3D13OBE6U7Q!C zk4|kIf`0g_jcy_OZ~)quU)UArJZvQ(oIDfTpQ|*80DlRN)YfJ*QmH#EkUjVOQhvxn0kf*u=@`2zyV_xhR`9ff2eGF@uTW~as-4Y_JM%tpFFAD>26n$AFt=mj;;e(=b z<(bjy2*W9h726#wo<4Fp- zoK;w!opq=y!^u?hx( zA$T2-uSUUdabqHKTr=dr80sHaJtb_u3DMG9vpZAyk_a#lj7osfxGYDe?|eyoka zHcZAMDX9eSwtmcsxJ=@q%r%AhIEe)j0&*5B6$T{Ygj0AHhG&^=b|iPGeHq9J7R4f; zircOF;H+&=78x!zX%crE1-#lzwv?b2FwJGiT(i|;9@4x@8k2}~tl}mDtP$f2I&k3a zs-6hlUB{x}C>BP-gv|lI<|4!%EO6o5e3<5_T(Mc11u4pCjRjK&;c$Ttv?ZJ7R-2S6 zO<4R=^<5;ut3=+jDYR-o9dAnFT$N2MrMi#ORTtpN4*-&!sAZeSKA>_nx~s$%k0m0m zTFH@3$|@hAbq#Bc2qgoXTKV^o3Pp=3@>+<oh3gB>En*qxV|wAyiM zq%wC^Mv?zywtKTmvS4|Tae!GA_F5e?VhYhA;%X2?<{mo*U7NZW@|{$ADow>7Fwn3G z@NLVyRRtI)HrTdVmBm;CA$@g{OIEukj$5T#Q9W5FPg7Y{SZ8zxBoNL**(;^DKQ3)I zI?zE;zWB7JggEf&{o~ zosCUXXOauo4~Fd6(jY~HnV(DUTp~wHZROqy#k4l%lY`q)v3%Kz(h|Lqwp`eH6SCB~ zS*!qni;t#I57}l`>ol>wQdlfcp^KBCt2C8l0e(j@Rsm$_8dSri@1 z26A1Atz>b5xoTA)gC=m3F1X-fpDg`qQmIAa<d0!fw{ zJdxBZTkmB8_&hcGp0-wawjf=7)zX8frL!ta%AX7fBlWOq6OAC9 z1mU^{F!uJrKx<)oJ)~x5VMqi+g zIDrb-Rlg~0M=r6$D;A|QPdC$5n;Ny3J*o^OQVS_!*397lYL~k>Guc@uI|>uA^s$r0 z3IH_wBRHfm>ZsXu+8uMYt0|LgE<`R7Z4xz3Dkhp-ay)EfY64jm;GmMcNZwQhW5qDR zB-hdzHjVni8{Nd8+h<~}7l1(i^CauB6g>zwtRzh0AiIkyj=4A{d+Tfvl-sJ9g3=<- zs`R&dp={9_LCTP_n`F9S5hW*&f{~G2G|DgP1{29jWJsZtj(C*<(AsiWabl_+le)~1 zIopZA+m;pZ@QP?ceYrtb*erEA$yp^uQSGgeun?VST||H`b}g^iY%c(GwEx8fj| zS}7ZC8&5L{$iLcDiOrO21t7EjaB2I9NNLq{lu92zK2tOm^)EynJYB0wgv`Vo`4XMICS;dlLmmQ{{-v{!Zl|PZbE+Y+lg?L_*dK#))HR84z{}17yO9&Aef6i+V}V=Alznwx!f#&4zZoPHAPt?J8SLU_pOmDXc~( z_{CB@@RE3lM6gLT8gl}oVxia@iK&)N#Z_LMlN>HDlT=E*G?5aD)&Vj^&a`}!%&6QM z-eBqh1$v7LrO_C}TST};puR9gCmK3)YgSwqeIi+P@5+jFNzgsdc3nkF&*g_BW!`a- zkmOXJFO*Aw4@1}t$Xx+?u3nzdp@jt+2sk!$a~CBwl?G2!P3QrUqM+^7K$%`D$DSs6 z7yu|9P6S$WQq+PH8%|lzmvK3%C~W(HJ{sBYqinI{@}PQOb~B!*WyPgBqvT7xLM)+_ z?7RtL0I3BEHm`P~NcxWN)LB#tIF3T@6s%Ay4Wd0sgPl&FJ_c-DO&Kctf!=*x`J zMh(tCuYj8r8a-*S@dWfpK)a|GRRU5KQGDqq5G)c|Zb`|q%9*JA=#^!%c6LgLTY(!0 zzw6qxt+pdny@%{Us_8U>O#TlYjnbfinz&S43XVUIabr? zVuJ>ep62%1ZZ6=5$x0<1l#2!kZ;FcVX6c7q_Rgk`krGpl!B3G%5|izz-6tcBWtD}C zfcte8g$DhRha?EWq0~Cw+?s^r7|0q1Tf;Y^0fm$nl>q?6#ziQU8p(P&X`E;V%qqxI zb4bo(TPPaIv?N(%HgL%;Ym@U2XVfCv+_Vne(3uC#bmK*&D!bGX9XhgS)UioyaTYdnE;|CCY!dn#F$Z35Roc@E>Ea&f zcb+FOz)h>#OAXdj>)T#cze7a9A`v03%Vjv1DO3XjiG-yJ<$1^R0FlTsaOzW z7K}kckY4VI$mgnkMoh3Zj^0A$_@CUE<$z_(LR8dcaUj429uhNbvghKlt6)XSIg4s( znrc^dE*dGu*%xsYb7YJTy%Rp;IVzI6@09o}xh7&T&l2Mf7FQcTmdl<-`qA|#e9QSg zG5^KHOVN&#St>nAPgh1SQw*?Bjb#paayLwgq)Lq_pU2PRj-KubJ5VPXY>;> zK!cQ)nN(^@E0apSvAdXQ$5CQqh{S3L5%sFi!MRQcrbKlkN@t)=@fegiY2}~yO zE9E2eTucAb{sAtyU_vcZ(MRQ;3siPfd=*s6k2(~B&G-cCEee_#*4LP`3x!O&s|H2 z0U@@V1)D*oJ%iS$=1P`4AB%F>WI_NUV0s*qwUuUDGM!-FL}f28^BNg zCX;^(tNltHIL@@FfpJiEvAcE+2gr~Gu@_aHAX`LTDq~Rgl2xGeWGit|OaY~ej^QMw)vqS;WD7}M=>D-wEMR7*j*2%_4G8SiAtZBo z1fRrVlJZWaLlp1_1srUrqg(a#^Q4-59A;wb4`d+)ZYy~(K8wguJk5m;$b*J zY;@baz$JBB0)~si?h5gI9j8@IP%6vmG_yP4BoZ>3lN0(qs89_JL$0=HY5icy6j#(+ z4pbFa7ag~fUdR_I2M;{QIG0?O+S)-$7JGsf7PRN-L=wdE{ zx29B<6|*wY6b*AvE}f|&B6)H`hRGQU-L(x49i_Q1rElJolGox?gRN>8koaPe>;wc? zTZ}|TQnu8xO;%MQv)#s-eh9=m7lR~p+hW<0W%NuXVuqcv`kNHpTe-CI3h|DukfJkE zx)=)trNDW%a;fJU$%@Kp71Biqd?8Rv68Gq#kiGZBgxH{AL>fw@qX)5) z8%23)W6Iu3$)NbI}!3 z;DU^0$+63?=BOe<4j2INe*UsbBZ@5yyegn137Lr4xK_ssEexZ0ftXRG(sof!axbnN z52pwvjm2Wd$^c(47Cx0W)pF6XqEodrjui=T>GicFRuz~rE0&3Jv*jOc!)O{Wa#E7h z_EZ>T6M1Es#Re>U>Y~srpm3_NSOYeV2Xt1kfSbtf83&NBiUTrjuW_YP${PYQ|$7z$lXdFDxHvP(T4~5=b^S4 z+v41Z5O`Ha)cT{`$_kSCaGAEHIaLZ=>6a4^1t03WtIM&f2#4HZ z;Mqw^i}|vZ84`^&Zmk+(n=XP*{aI(B+r9!Dq*x}ns{gJv5Da5{2>?AA>Q{Q!4_oce z^QyfLIj!7y*0I1%>yE%hv#?IlLqQil(v^k>33O#I0Qo+jIaGLPEjIR+xn)DzI0~6B zViS~7o5PL41|%}Q;u!r#e|BZuEo6M59PWaA?mG13Rs{&K@+E@D=@WyhhAgENU4Plr zoE23S3kl1`r>8QPg^ZmiO|uy`dm@Q8#)_lfA+LbG@qqzSE>bfkK3Q_5o>GsLQ;uB$ zE@tu(12Dm4no5hdYuhMT9>yx@>>?yK%WNm7ni^(}7H(YWpOljrBva|z(;1ec0;_z1 zWUIsr>Rf7WToI$FO{u&?gF?QlSdbxO5@$N@Ad|_c#2_M%XeXDz+a2^~$u`Ig5djRP zt?79R_cUEm!tDCU3dWopRABT z#er}0acV)BYX(D9;9rw?HcWV^do|t}l^LT=xsLSe3!P4){z0dsN<)_xt5PYE$ZpP; zPIsuRU?J%$Os#|W^LV;ZKFMU*Uy8l%JUHM?P2qjQx6cs|oXidsh>k zf+Q-Cu~Z?I3`Z{#;FUHjY~8O^GDI3AhgEp4b&M!U8z57RJ{Ii}z=7=noI&7LFWxH@ z7;K1QnoE#IR-oXZh!Gn`>fof1Ks}Ho93*8_%Tbpw-slVL0p zBGI`cBC;43&nh9K)`4Ytzp3EByj=FdxeZML$_%>fB`WZ8o8R!bX%mi5jJfBtoO2?_ zz=l_|;f!n(mtpca2!r80vyY*W%$3!#l3E_{w1C3TlCZm`QcX%Qus{%+o!VJPIQW3O zRTFL0NNxAFw(u|T1EdSuQ1C6Q5h!)IX(a+?(ONI!%E>NIntpTcsW-;cQaCUmo}yDK zG1wJbSv315DHI7eQo$fLZ|3qKx$-y?%vBn>u!wuE<%+p@Fp#vV!nzuPPxHifAVQ0j~>@@3SWoA%{Qk)N-K< zv_Oljt9f0-K``}_Z9uOB=VlC|9fR?TnlaHEVwZ`%;_PaRyjIm-k~m%r##lnSGX6X! z$UMT91uQv6;#XrKQ^7@^MlL4NmUW^%*VK$xeC4Wqq*R^J>?c_g^h{vZ54xz3EFQZ8 zad?H1&Poy1`r_;*$SDJ6BJci{*`D->PDz)Tn7*IN$szDJn3>HC;PF%xbtf|@!5H2L zv(tDz9iA97n-Oz{!x)xJ34#&68PQ zq!H*MpP(Dm6%U~uZ`=bTd18c4m7@8D3{pm&k{1_#!w*ux(NaH~=gsTcF-EaS&F-a2 z;vt{#G;~vHtwu5PmR;Z7>nryo!{R^#GHXFabvmY7-G1qd!fK z!_h`2xOmJ0D5AXTFb8T3;QZMfxQPXN_+pr}5`2VZ@cAhzFE}^x6otjZh@26oWU;z;-HvRVZ}GQz=ZqjxNJc&S377*in<( zo}+-`1n%153|lVqEEQED8LwMZy@0b3DT8A13Q(OamwDkBvFmZ3#-4oxe@M|F`*&CH z4M-s~Ada4_;*2E#7m-XBAQ`GG$bT~u1(z)YxrQO{aIbc^Na|iv#Il*UKz2ynKZY1* zg*D%@4G9A;q%lP{q82csd6`OYm15#l1E^eb3)5TzJTEOKCPnLlUxX}Ut|$)%O9yg@ zpr2c=p>h=9Gip<}&ttwFo@>iXNp>I|I>TYqpAz}gXg2FX3YatGCSu`4%qM~!yM~Eu zZ4~+!breZRRE|If;QJh&5EDpDHD2IZnBar2Q(z>Zu}%5{n@iq=59R;|l1uY)8I~Kv z?UoYaWc2=pI0_&P`ibcG5ft%9a4v#wh=(tc7l@`aS^FfJf{?f$ljV!2MEG-D9_Xd+80WvhALoda00~QON%gSF)&i+g^ZnY- zU801>`!N&n$d^t#w*RiozmbqMFKqxKjHmS{&X(HB!#&}t2qZnT){SQAaETNqHR?~2 zXj+ku|9R!(cnTZPk0B3<<=e1gfhLwxY574p9^NT0%LNuF?d4UL2@a0Ox&yRI<%@{) zB#}dI+{h$-iJ@@BP|6GtG$6T?46{1;=p1{@AO_}RZa$x+Wnr&W)*;b!~>QCG73U9yTgSV2!N>S*Bleb{9n#l`N{&F-;U!f)FhV3 zYnPr3eLyP<=F9=*X%t~&AMJs~BU~8EGu`_lBRvZ7_~09TfEVzQS#oxQR?8(a132Y3 zt&K*LXi8*3sY7N!KVd?9O!ic`060MO4?+C^&sG6Q*#NRzbSwijDwLiM!6~`YoZE5l zrIk?*S9SP5&V|%11QZAG=&>9HkJ2{&K;;dO3DNF5T3#RkoMgszL+m4Ey~$-$Zl>j| zg3pf&n|ETFrXvpBL(@O>2UYU9ug zgRvQPN%Uj!>9VBpY)GzvgkP3Dh9J^<0SI+gVp+F|)Ite~gIENJjz#QdsYTVH^qL46Jm%0dPZ@bYnu1*tP5 zAp`itm6yB}LE%83^B=GwSnd)Y0PQA5cO*a+APtH3A{X{N9ci{cstxxKe9v!qo*(*S zSbJD`&H^PC2LSpqKvL@urBK@dU>sNG6B%Mjl=E3ax?LX`ILQLT8z={gQV(|oE7W^y z0?Qz8C2kM7%>r%PK$ZOBBAv4+1X(sJQUv~)Gzr)?!Awsc5jheTU@#ReJGv^sjk4Ao z4vc9A!@Zrxc+z8c(4Vmd;Jq4gq%c6p=Gwsk)^Iv?1Q>D@0TIArs4JtJ4r!@9>rnu=VM8cWd5_YYOT zrpdWCh3xB4F%F3|4$%YWkOM%)jX9L@2m6Xg!kQTe?ym9o2Sd;72cvN};_#nH9`e!- zmdNK^fW|_IhV*iinMeQ|L|3NX$=MTj@j1PP6t7$8W)T^EPj+W6g$o?=$f1gWW6d6+ zrH41J7!<|z^eomCA2xo@xxHe7WAfk)GievfRQW9e^SM1tHxCjMQ=BQsOeR}!RGW_7 zM&GGBL*tnKo)>h80aJOgv$;P~}57Z>YB4W$svZfN=RARW(=3W&DY{&wPrJEHI+D zycwY(3;%6AG}OKB!!Xskr|b-7!hoeNE4TyR?v@~h2iY!h+&3Tg<@<{I3)yUAq>n+M z0P2UF-SLp_n8QDoI72H(WbI>oVs)w(C)v>^Joj`vn;26+2u6XgS#-Kc0w&I2(DU8i zp6d*DdamCja6FmLkobxj5U|<9JZWBKTJbP(5*tBRIYOzb***V`ZhTL)D;M3ywtS*l z0;5|yNSEcn)5w+&B**C$a$F)tV@a9M=R8_Th>r{?KXJ|wAMIELPEJcR1OKK;Vzdk+ z2M!j(uJEbqHY%EzAxS_ShHXP=Xy~}ZL3?Mj=b3g0JAefq!RjjmMoc&PLPxyhu#yCD zLhHzqAGHCY4l9-3L=@2zSDK{7GoXB31orf6&HDm1WJNVA82Lb+6^n)#^%r0^WQb!-7* zvH|0$H;w#3+xL1-yL+Y6>!~5&EQpQsDZ*hzPcmB`(V_|e)j$wId8#uZQ{CPS?RRx& zbe1iTkg@vCRXs1ahhCCw2Xh5P5+(EII!@$bYpR?3ILTKwY|e8|cCjc(>%EZ|%voCk z&n92sJvli}!4}I*&Zo=Mi~sKE=T4g!-|D zdNSK5mBtV#b8ZWzLooztY7Y<7!JXI)=2^=1kO@xN+hgxiI|H1ZACeaH89^gG|58%N zc_}F}Bo+#J2Y_2wQw61*nCYVpK_CLl1hT+V3;3LmS?l^G?{r# z$Mbugc4NP>yEfQ&f=Lw3RKKf)x1_34O#S4>9Zad^GO=ZW(wgZp&5!~&)BT=_boOv2 zUy{HRnRa0S{KOTqF`PmGn2a1eVDeogjd>mxbccIoQC?#d$leq>4gY80-n~WTjox!a z;ezlK-X@@TrkFFF|J~n>Mzx}d^RYkbO`E-N&}lZVcN*P|UdQ)a~%2;wPdVT=&a?T0fIhxHT#XKt)*5gz<73=r3`=6 zvIG~oJWrb<#2xT$a$6xk>dnMeX(E?FB(mIS401+%QW<|&%H|-FY+n8n1&{!634N4W zgAhPm%;9Z(u1c{29q|>`5G$s$aT59ALBG)(G#dx)c5ArPZ?&Q*wt|foI&GH4!FZ|{ z*o%yk0zb+l;IN@g>suIY?`lM#Ego`ejkd6g3Glo{3$KUb6}+`n^0Dd(`loUna+YPo zSX=tpm$IkukoYCq>D2*aB~*jN_y-bgatv4i0-~FOYy}t)*U&CQZ{Lll<9ch*GMy{U z{&3K5w>>|I2ndrWbOwu2?yjB`h|VEQuVfxLLnqOZH<_K)h8`ERqCtzb$r}rtNyWp% z92A8q921El({WUSi>2o>!?BheQc2#9VpR%O65daeAlK%j0ysxUpj6=$RWa}{N%WZ; zr2lLjGEchaju;5kWKU1x9HhhU*!PA9t;Tk5cfHrXM&VI7i|FbNr?M?Z_H|{tngD-5 zEaapU$F_Ydp-^jDuI}AH0Oco^+gSSL`zz7{xJ7w?(DRFVLc%m7Iwaa1=An&Gh5+Z!BQ zYqbVl#~Fno#Rb#pgmjLxQCP$R#8*XNQx?)=lwe1Cwant?B{#==mDMN|Qt{FRmee*x zMQBdUkPWJI3i8*p|cN)iMVYo)K*)4j1!y zAGov)VZo6cqG(o4ft4v+r~* zF6P7-Mp0u1pdDpBOwON#1u%A}MIi^bCDlF%EzW6|9LhGr0Np}~I*}*g8|SyG z+7PORCc$WU41G)WEM^Bj8P#c54i4p|F0Dx|kpo7s#S*}4E$t$uNB~1&kRSP3OcZ7u z{I>j^?x0bxcUz8eSDkHt#7e-`GL7W0walmb>j#3Kc=dK{5iv{ly1MFh94rHoLu511{Nsi)x{Dp z7a3q>ON9aDxQ20gk(bZXXk<76Jl)+}xwgB%UvCZ9oPMY4cw<6^oGi2>B{J!D&16SC zdi%3NZKIV7adB|vp3bnbryhvMw@GnMgUqWGe>u0+K))#L_BiP&m7P@l0lr32Knf~v zEQp@i%>ec%PO?R8=zx=NzcZPaiZBIaGS0D#@_ywcB|9x_sxE5 z!dW8Dm*+aKsWhlw>O_f5kDfGHoSMwDlD@QaIxiOep+%|M&MPH{9nXPaz>}G8eW0hs zGc*pNSJ1Uq1e7VJvPlMrj1LXKN6tAI5c4PW7f1!^*~$Qh>;|9$IG^5RNu#^o4Q7+k z*m0ff^}Wk0tId9|KNy$;W&no<(zBFtZ%ZPs3}ZXcM9hv1-RVaYEmr_PEw_l0_x0O0}4W~u9gU_d=!)dz%`IHCYQFr7FhX(;%Iito-7=vf8b z2hL#Np;9-28h6;;-62c3*6g^0o|&)_n8j(Lj;t)@Tn{zrStcW~Jl7sTBvu&K zjA`_t`B?J2Ja#QCHHU5UBcz8pb5(8nlrBh_sn0Ax-xFTOjYa|6&@$KTP=@!Rw+RD% zV=TX4Xm3CX6rlYGb1(p(L*WI^UKx%_)!JLT!_CHK{qkPDS#R|^eb05s7fQcyY9ybb ztVDvIxSYu$FW8N%!+WgM6Xu>K8Mws2#fKRL>wsqP8CDYI3dX5l2C% zYY&|6-rD}^`oWb}yWeyBUeFpveMSmtFHYg#3iRI5-ANV?O-k7FjoMG435uzh+as7d zPIn111ve0xvpA3<(qHi7i2rhd0BJ6pGRR^*?cEhuNx%1z_9&}_4~v!!)dSM(_gPuAGG&+JA0kZN_RLo*lz*} z%+h{5Ezod{%&Jp(L5?&%?^+X&rH$gHBx8kK;(%l$)!cD{KIfaYC*DhjR{essG-Lb% zSc=IGDNfd7z#xK5FA0>TbMSaBNlv0Vrr#VP)C<|VfHhEEBuG(yG(N)u$p=VJgPZod zg!rOqr#%Y&k<%TxX1CdFwN~q^tsU1i#)OBbaWacymG3#XCF|J%?7@ia(35S6x$2ne zz`WE2E~OS&a#7UADY(0^fK5KqJ|1XKTFBrhJeJoy_Ifvdtm%A zA_9sRaseW9?1@iO3??B0vTR;x$P5kv;v@KA^K&HB9!YNwTdG`{Au1B^y856^x(K;5 z4ihwa0Cpl1V^Vga){o4O{Q08ej_p~9zc2!GD_X4iEbueQIp-{b3a{ujTpG){t%(E6 zB%KYX5k*7eaWokRBWJtU?e$vS#(uZm>J6Q6G>zjZoy~AB`3%Xmw5kG2XEp*j5r6{1 zYR%uAtsLuuu6>|38HKSBKht0F5L_kaJe~xRnvNCpL=xf$$nFs@=X1n5ocx)WR_$T@ z!XxMfkI^!wUNjw%AUM&u9SnDS?e%N>{r&a5VYAmY zhD0EVW6nzWp@0i=yBva~;t1L0+FPinFSu&2=leTfX&8r(Lm-k!kb+^;qHGb418$eN5rjZO&oQZViCDfqA&6c?q zvI|jIAyU>ksJuR{Odxe(U_w9e1tyRRkZMc6i{=$eY!jGEVuv|JIy!&x{K=CipN2?^Do~hvJ(qBJ#LL=`xD$fmUP5MW8k02~_3Hck+h^8y znvK?8cjyd;rq8Kyu8UyUCSa}SWRMBC#v^vbf`R6R<-kZeAci^!ED5tE{Ik&HIfnm0 zusxX{W`oM2RQ|mtX zYy`P0q{r$#h{ym$(|$DJ?$BzzJh zq4w?|?2!&coHc>ToTV;D2DD^yJgD?4FzU*&NreSgB`#3%`Quaojw($6(Xi1$F5kmi zkX;K?1(LY=`7HQrrFlXAM?ZfdW{+KBSqjV!@Iki1ibN@NY%U$@@XiP{ePKYa+a7u2 zsNe5RIPuftz&GkeolY1wh6h8(bbIT)-B#0h6euzr&iJ(6bcj+x!Vm~qDv{H<2$%#Q z>Yf&AFJzMzurmr0w}RO|>X zRJ-}R>iA}#)4wl2w|Iv{e-2w6EYmNNAxh2Qh<-~?dH%(V zU%V{*PhN;eI8w(DSa|}lV5AMH2u7tnFCq7&F_J|y_pFOXINaXf-QVbT`g@IW%jXn0 zt$mNa&bpm;Z+&mKXH37o*K8a#)}0{YrjVR>5~ndme}q#M5p#>K2EBa$fhOH71HzAZ zjXtdwRuIe4vfNwqSO}O6q;&VGn7zl#%m1&Rv-KA*@(0cRX5VpIy_I%zr`2{RvwqBnyiIXj zQ^8{_=5s#T4UK3l`($-O$Yxnu6{qET@zL`aU;g~bmrw?{srk`3K3Z1&KsY0AjIQU; zo;`aZ7XL9Wu;Pm^{`?o=(TguWijRbY5SyUN_U4N5Mv8~$iUkUpg~}r&JwD~c!=@Q9 zpzj{+HFhswzItGSkw4|`;3-3*s5>4}RJhq7Bsg?ddi{fL#|h~7Bmqf0%i>v5P#m4l z15@ZGeh>16Z1Y?L6xQFzPoDlix_Hl@|01*$9+eARD3CQMkk85 zwj2@s1?XXMVPnY%Dnt)uDG9%MsGo%_VIfIe+bg&-|NFu(h zg!iKtFP`ywe#%jcMC$;@tnEc50usd^9lcn1{Kt=%=zsFjQK|7Bi$0jhPBjqw!FxjZ z)E&5e;DJ#nEEQlSGdpY#a)GDRI$MoiueW=7r|-J;F@p zt~c~r{Xu_P%(yp<1Cy|Sq8?Y*7tfxBFOGgO{|F&#SXdJngkt=U z<}qh9j$Y91K3~%K#XCSIj;cj1#8d`sQRi&%d`4lpVrp`bzIYn?ys@Aq)KPNXq$vRF zsH}Ac`|W`fwKep5t!AgaJ06F#ew5+(PcD>k8xm=* zksd@aS2KZ}wShnAbla`n(KJbC(_9ZtE_qng5kL@H zmbpW(ENq&FJ^$k7pFSZ5sQ8Mu!_QSFG1RZicK;alj~7trIRf7RPGC;>FRc6sl5M_# zQfpU6^5&s9&O*Y}T=V3_01AJ`BeS#F?rwRrlsAkFBru*#CSAW727yap*dMxE>-&Rn zOfYyFV;_=N7$?V;4{T>BWLHpO13*%Nqc4uF9?|wjN=j*c) z_MZtXIjmk_>#~&D9N4A_6bbt{!>57c;uU=fUPwvE3-o8bPGe}=ogpnRK^!uE(+Nd` z?N&JUM?tIMg>Gke%NcZ=I|t2PZ|0=AG(hl-Z*IKj41mwA+H(myN)7&=Klus`&n&Gk zUVQQ2!{i-lyB6OfoB88v|FsM#%Q$3jpxPwqBqDa?OS*KfL6m?18;8JoN?)a4Nf|&9A=nK;4GAxnBp;i5qfWkL0U>OGs(-?1r z*(3nt|a8(nW*}6;(?j~&TCcf z@9SSEOnCm$LXSd3!gEmqfI#-P+_sATHyA_;(mit ztM+A3!-*^XFiO)wm$%1sqi$caO9(Ko*X5EbI!_f0bt-W5mHyDk$MqFa91*ZbZ z3&8E0LM^~tBGrWl{6=CP3qoWDaAK40_vl*%05AGy`fPP5bBzY(TxEb3c-$#1sK>*! z3U+?BR5{OQdWVvWr6<*<<3^YJ;hnhMrii%LBK@C?JL7R{!hWE$=ZphyG#ZUtgT4KO zjW8Pf36dSY42C$$w2D<~{`KfLq8}pg?9-5AgOIRsiGQDdCj(IVDq%5L2m%{_gZtcZ zg6dyk1s(gggVH9~G&4!D5(zN!R%OlEHx%T~261zLXm*C)h>gNz5=BvW)FDS0kR1re zt!Asy*}Jgcua76>2F1b_MMTCXm&%xZ_c?p}^3(66|M9aYAL&Yl0s@H-zk2)~I#78L z&IK1N3yK1D&9NI>Y5=~URh>tfFu=DNCQZTegSE}B2?t4k>XV!6t~WbgIF2U0uG1ck zIxT`5^mBXE_6ZWVukY6noMv-6gGpr$n1Z_zU`$|n8G1e?W&ZSAR?%KBq$)mUEFWmh zQhtXBJbQ7ZvT}sL;Ycz}0g_K4vR{SI31R7%lJ+}4mwi>GY|r*pVq}|sS?fCfsFy@R z=V0Y(iA?w#_k{NWn>sR+1?Y1GjTuMHQvy4YvVaSC69F-is zczpEYJM14{A4v!wRB155x1W9+{ZF393TEQ5)PO=&0A#L%!iQsF^VI^-<-w@Up)TeyO;kd^>?IWT;m*Ze^^=}m)v|6u3j?$-6|=eDjmPUFh@mGf(> zn>(%g{(g6*(LZRk);eqbVaMT!QELhey^ltWeqi}Eo%5S4|1Sa=Z89&?LZB13h_G0f z-=YK0*!rQNg1lyFlWKt^UbQM924B(>80(BZDLh|9o7Ls8?78>?`tX|WmJB=HUVCe8 zb!+GRM!VVD+`W8ebL;B*rB;8hS>M{P?_BG+U60m(5^*Y6QWta3uGUq;o3ecJogDa_ z2OH({5DuJIaHLq3m-YFA0Z;z?e_wvtlV_#ig;+j`B%wlG7#&$kV4CX@h_Yvx8~BZD zo4Dm*y}VFhNK01h?wnuSz1-?K?(WL^=H}k8-RrgdcD=cGu(7k(Yt%0^clP(UdyV0s z>5>zqNGVf#<0B<|uKhM4?(aK1z*8Ts0~EK@U2_hr)L=#e08*gAFb zdc8e#`tPk=-PrC7P1xS+wKm%QR-4v;{Y-1CbFJCib^J-Q+vZL5fB{SKtOQ!2e*0Yv z_;pbJhvzSz|Bq;u5F4OD+nm9WaNueA;v-<%FTZ^D=RXf#JbjExa7ba0G(iv=;8qxZ zon(Z$0P2UBA%%dSduVwN^Kixk5_2?}C9VwaS3@94(NotiudOvVuI<%ZJKJk}t7{tv z9W!vYeP_@0+@0O6-B#0S^}GXuhwWLC#z|%?eWm0lv2LNJ9q86R`z{suYhKTzh4i8y zpqSgrX8Gwy6!FgmT5*8<>8JDZ1!3l+nCw45KnQCPYGU2bGtPZrJ{Lw*=Waq`KO794 zK{ANqI3@=0s4w36ioL8{{ctwE;)vZ(s%K`cyOEXpQl?_1Q-=Gi(d*A7extaM1YC z=fHjF|0zq!BgBjbjzj?{4#B7}G5S20Il&MgPVi#`EN|E=FbafE(ujGsjC36H9*WxK zo&KN}nx-xen`-ZnoDxm7Ht@3>eP2E>VRIGId&$R5{? z7m(M6q&e~;d1;N_O`LP^otj)E17S(UTQ4JdoN;A{4PdAO$5o*KxghR&&2{j7 zJewxYEDXC*zt7X|9BldEt$su7?I`U0`+3cbMiDc>A@1k|xzw3PC>2L`oT!XxlOC4R zR`f&zI3<>GlXDqLHuyh^0iVykBu=7fBlKEfBbZX8`3=u}PHrJi@>0w}DIHN#eDK3u zCh_qOEjg2@Pk^OQD%%-HoruTcOld`Ba5PTy68UFyUTX&v9*Mz@B9q}jGvWki#^k32 z$E`@V9FBEHf`|M$tS zKmW$p^2FUZ!6<1AO9BRxhHI?7!C_HEKkug;#+U%o2uHmckAEAs$Gw@H3?REVRhH*L zZJf-=z9kNCbtmJ2v4UAOEikifnoAjvd1VB+HE~|D7hrY#>^ssqj~+Zcynp!c;ll+F zp3aMW*zHC^*ZbSQKK|^7%Rd~_zhA}V@Bd;t|hf(SxTLZC7pVU-7oW{rk+9|OOAF% zu7iA=>_2)$bRXWo|H=JN4i6tJIPkReJhHo^?LLY~ho2CK9zA~a@X>?A!^4UN{8D1U z)4%y;@$@eS6DN*FF`qJ!@=Pd?cW}7|<6C&@O)}{BXXM8Fo!)FfwvcED2RF*pBliIiikQUS)ACOgZ(4^pYTmVStLmA+c&%Xr#GVOfbzem@{ckdoP ze54$B`l6tl8&B%1&eO;D@7%pZ*FApPefmPYddM#(UVL)@-rf86@S=ZRB%SUoiV_@H zos!uv%FOW05*c+xA&+(ZaXTV2&VD_b4KNQQPB1bX+n2K?2Fyw9hiJ-8^`zIL zI%CR`?=5>kWHaVRgu*y6hXzSffNxxWCjUNP_dfpk?#D#pp%sCj7l9c?9n&5B?c)cx z<)SCtzH|5P$9IVZhs?T9z_@$Fo7?or<1hX+n@xDyW5SURIp@O7hP<6=HtP>a@MqCr z((O`g-yTQ9s5P398;C~S{29yn0a$w>j47&dCw(e1P!zf$xX2dtFV_Dh8z3c4PA&Pt zw?zPt9uW;p#9gc~(Rk<1eHHJ2{n>0b8m#@*uO5GL@Agf;Zr#3pi=P2m_~Z~T#ZPx` zAKtos>(0H$PqSG*#PH{cb(IJ9k+0`%&(n~+eV-g(yA?*G5f^s}%o2{}Brn-Q3<5`2 zC$@SSVE_}x6XB#UyU;lo3LA?`{a+?uco2uBc7=X3{v#O%19|bSV><2X{pk?(&Pkms>Y)5nCSpiqA82@*<9=4hcd|%pi9?nKj1a zU_6~t6w?R}D3BSACY@$0JecrCka48Jke!4g4gO_WLpnKwa&W7g9Ai-Ui@ksT*Dyfp z(j35}95Vk(9rQQ@L;w~HxOMC1&D;0xK@9%y$NvX?J-mO1DF5)o8#fmm_!y$kD#6dc zg%{oV_|DaEkXq^zM1|WcAESPlX z78vlM@IYmNq~I>g%i^M+?tj7-nmrUBT!6+O~X1#8+(XJo(JOzv*IM?+?;}Ao^ z0<-6bBW`k@Mc530J>RZ6cMl9GF>O^_(@Wd-dz^g#7zd7|K3k$Sfq(Jh8|i=iNUZom z04)7J^8(Tx) z8T1@~6pX?H-vrH|>HDGI4g+ozVNZaGpmMY(FE`G{Bimk+{kj#k^J2jPyWK?ufVbai z-3zIJ03bl>f%9(;03IGLOh6_7dfgXq@aWzhTKu2M^`US;RN*F`eB;K4A{#fL39zE% zBER^XEH>dJ7<9csZ*^b}T0@`K+cXFLJ=gdyedjiP&kwlNIq7tx-gJg>hi+O;GvG5u zg2K}dDsAy8svTctwa?R67=TPPQ#?8IdEdWfw-HHJ>7Yw-l@gd8{z1tuD zOz8h97{F?9qk7U!C117U23rD_f(L|-2c2lpuaEZ{>w9j`?QIz2_!~pl=?wiHXJ_b` zy6X@x{J;x+cN)#?=uhk6DfXw99%H~gOxd!tq>lLsxG8~O1I=;7mId% z2G{}Y>vRWf94{od`&AWqsPcaZPj+YF0hIciAd#bN$NNN^7;z>e$2khHf+$X$`2Befz$$QA%i02h!gI_|0W~I%YV%NRR-XOFhEyzK|hPY z60OVh^MmpW_7Vga@30^JKckUpcFu2XU)WiFedYAd&Q`tA9CTZ)?fUSFvvP2ye!bCd z4?69>>jeZ0qc~P2kb^9U0gfxx^id!cn#$*~B22Npmt`MGH%um_Kq|3^T~2VaOYpVb z0P6nletdhe__vgNFyJK;e`+tw086!{-+m*`;Lh#)jHUi+JnHQa>KkWHzxU?V6PGux zZf-W3*IS$2t)1>*Z*~3b*7?hO*H^Dx>+nRJ$<(GrDkC`Kv1FKOTl!U~FKB0hf69Qj z^7CPWI6VO4GF=(!k1YAuIsvHvLiUeo{|%_~*V8XjP|<6z#m^tY8xSJ6&u*lcj=ffW zZRh>B-oJQ$@BK^l_qQ7LR=c&@T5Da~yngZO`lXe%t^Gz5CBW(Ir69K!%a)%~#u;Ju zKq(tPcte7;;T(}t7@U2V;G{hVMHULj98MG#{`~1z$$$9xlTSWT919?~Z2WG#EZ$#b zz_(rE5!nuq3_SCOT>Hs8tJltKU92|_R$4ootC!!na`y6-wcSR) zzlZ(5z{5Cz1R%H8naq+bx1)VHy5;#s9tnM!&87Ac%rn`KXvB(`&;k7|K6)7&YZh`>dfhS zeP`qS58ga~Y3=%%okn+UU_#*GrO~cl%^#020X7$p1}x0G&F5Z;iVHU1^W;2?(8oa| z@ob3MfgD1K4jhw!N96oy@nHe(-C2fyHx`Tk6{3Fn()EqRS1ce0!nTkwA#otwZeHJ6 zKl$$Ozq@tz?Kj`KaQ@QO(-$wFKec)4oj1;%yZqk8Ydeir-+1FGqd^y^NAkwMtj0TH zXNe8~lcC<&V26Y3xFeD`0&>Q1m_$w*PkTLXrR4LvM?-9FW(F*b;DZN;ihb{}{i6^V zVcbIXUnc%#!jE&{t9+bG?;HV$B5cYT268n z0iu79&62o3n02{Q(C2}Qd^(*lAg~hf;PEGt`he^I_!#(E0yW=7ze1v~A>S^)%D_!n zg2#`4ou%W=54J9>oVtAB?CDqEIQ{+Ked~=6F8t{2Qy0#i{r-C=|LEN_mp0bUt<`tJ z3ERPx^ZyJA;#qCDeZ6ByG4ffiIrD-~q44sA`-vbFg&$|bexK)R&Jt{})?)|;X_5Z& zZ@(;UD)*tJy@Y&?W$t{H{tqjQ{*^@+x&;z_69X#xC1|*P_wM1N7xTn@`&{?@-bVMz z$=Bcgowr~6y&qos<3ISxPcFaz#&7@ll^?$T&f4nf^;5e8!pfs5598~N>fVT=gW90p z*sXJ)q|3D-UeS_|rFS{oFAac2>GF7KhiqV$@{39DXB1Ej;=Yr8xvc#UnevZ6zVoqN z_*=q+W%;iJ;2VTq1plX&6W?G46hcXWc<~k99o}Vt#{X{HF#Z-q;MTF2=DQj2 z({E+K&z4{@+kv~tpgu6+=H`7#l>Azhf|Ka_6HiWNWk3g1NH@}7fU**6{mf@R?K*azEz%AnU-Fx>BzjWj2 zsN43POINRb@aC0QfA7^dU;E)7{osc`{PA1w{L$b0{Z~)Fed)EW^-aRWjc^k6x*ZaL zz|^9!)oX2SQ0&;>3`w~uUUuAMHi+G1Jc%gc)(p{rtRGjIV)76}?3Cql+(P;f+2!5R zWKKoDX8HK*jqf1;YZ&k?fMAJ!eBRyrpZxpJ|7M=&PA^z(Z(Y4g=HSfAsW)Fc^Ly|9 z-XDMO2jBZo-}(n9-hTV)`Fdw)f-yJ3wp*?6z^yeSGaPjGFYfdxUg}L_GVlX8?vJ8` zk6IW`kw)_Jj7#V32@hDvOV`Epq@wt8A^rF7SfRgV9lx5r8xq7GxBuVh_^RLX@_!p5 zQZIndyUiKYPYxeHfAQsCgwtSm*xtR=>NXB8e*eq|Ke_bg2R}adqu>5}-~Zk}I&t#! z2fO{28@9*oQL7n{M$~qKkzaSZ?dGsIa9We7(clr9Q84a}Mze%`MW19LH*wSn4@Nw8 zvNxqzs1%I+|5XBT|E@4VvzkkOU%$NM0l&ff+28%_yCh(dI)c=Z<+=Zfe_5ES5)^{Dx40p*k zY^~9nkHent?~TSGcS;cOn?%#zupiCHY>XP?e!DyE#)BDg#7&BziUJ=UlKd;ouW~t{ z`DZ`7u~6BUWc(Wi;Ag^%pME z%7qiZ`@KK<;Sc`d4}W~+>e&mco12}jUXMh-U26{pzGqx#uxIOmBnx6h5EcNi0#vy>UTwSl%No0+|KG-dpIR2-dEdzZ z0Yi80*;?Y^;o-k8JhIiZ_RiIn&FdeW{DW72^u{aiojh^k%~$@j|KiQlXU@KN;_UXN zz4iLum1|e_YTaIc%lO=6<_vl*uVqYUI2g41W^XSDn@-pY!tnutMd!dZPB`J2`^F^E zY|tGP76Ow6APG30#W}X%FFo*eg?*uyQfcGz4q3B z{`bFs^~4W;{N_LU$>o!mE}X5knmxx1hkiI3jY7XkHqy9*gKjW9w|;r+T6f^KI!@>W z{n36v+A$l>xCN((r)e?&{BQq_)d0A_uL*S)x4X=eueE>2m;b|`eFqaNIa8ZMGQbI# zPwt_p%58{6+$4d$cJ17$Q>VHsJMX>y_U8Gwu3!K058gQU${+ui-}|lKy72m|e{`bO z>GpS>{b6?yj7F`{bNs;VUOVWx(ZucTj>%z;hi((+3#NK#lAELyC1%A7yD#z~Cq8c7 z{JL20tJgOyzg2)mwEG=zv|xbBz^!|q+$C>#mum)O54n9Ur_jBArM|I#;0$)R*S8wi zSNC53-S>Wc>YYFQ(HsBh_fP%c4_`l7>-G0sXUExo@4)Rkdpp~vzS=$L9JB+97h4#s zLFm8Fqn4zx%Hwksxn2Cr3I#v}wgYWNdF#h>(*HcRs$yZ6?n{-WbJ654Y9YYW23-gPp4zYx^75e{|xtD?fVw z@4x!JH~;XJlP79@BHeE}!&ax!aU9?A{a`#AqnjXNtH6g2anNsMrZ0bwO3E*uFLnlh z!Z1ih;461pap0Rszad6onJghm{ODI)%Gc8`dD5GB7p%wHhJdUf=8wTD@++H{{XE<8UgeLmrjEGeyuiz@4m| z8AP|}{D{YcR(YU@htPp<(A$NFKW6jf;h+BO&b>P~Dzc8nxsZa2So!@|$gfo3rUG!{ z0L6wd0yu!E$de=rCr)!~v%lTnySDazee>d(3-5h!;^O++%4)4+#>NeN-)lBH9dZUk zK6!Gc{b6Z3W!=vUIj@fE$J`UJVsqWQy5?tOfR zu+!Z;H-Ba`zV^CxJb7;U@Zr~TK*DcEMchmCYtHCuu``}_LOw@l)O7YXw)VDnR#(rh zuU)=!Y3Ew4GYEKE%E86$-OHO-uY19Cxp*dn>}Bu<5}-Ur${l8Cs9BUmi2-+RTbcb1 z@>L{0yh-c+;q80(A3Q_{8Qt@PL$YBXYVAvUB5vQgacjvcF8J^g{S^zCe(6JcYTJpq z#OusB{%@My-a)f_t<^cVb!K&AW$)U>%eB?*j_3L9D`(EU@z#&txUgr!WKlaTs#)n? z&T}*Pz=)I^bh!06TMS4b9r*ap*D&DtkCpi2SGVunymRYT)iI?={_xIEQ8blaq1zvm zYX`nsmV}oCU|9mH?wb3bJeBTx%+4SwNHW5?&Q`y}rhZtzvbVi=zS-DauWi|8*%dfxe=sA{0MW3qz@vxvKb9UBtE!8;UC_T| z4?g4uk$d;=-a9RMzH$<9nI+SHpZn($ zf&{#&Aa>K-E307O9y)m{30S_rC4o>wcjGpZ&rP#0Q~&eNIkLW|{+xX4?R%d*ynp-V zhidD;5^gM1z;fW0O2C81zb*q7*ld`&&Sch%;)vjV&o!R!QoPq|v^s;qm8&)9z&PD- z=62t`wBNeCQ}6XQ4>q^=TkT%RCrLUuT_rD_I2s2@&bun66f?KS;~5`)qh{dI19F6S ztRp;jZ^8oka^nVlbMwPnw?F3gpg;L%zx;wo9ci+g*G)_!xXvDfJB z20`GDLa$Y?5kzn(5SZ+4^>zlAx3<@=tX*I2v^Lu9?PlNe#+aapO)+kql$a>Ql{Br|yP&i#9j|ME}%Bx(BuKYIgbbESP?Mtfd&Hu1XC z^jG%%4!M&M-I5O<+`m)h7Qf1X55K~IkIA$gK6w227jw70xw*1->7CPSSFc`rZEd4@ zdF$lX^{p!x8vULLL%-3zQrqqIJL}sUtT77rD-EsCt(K+@C%mz=F67bVuEn)6O8ZxT8u!BqLGC?wtFgwXyEQI% zwh4=qDjr0sjj$gdl2qNl$L8$egO6|BT!6!Gu>isVf>sQN4}YG7m#@5j>hz^I&tAHA z{@T^8`lY?CwX2ukJH4^HzrIglZe)73dc8Aj@3d#`sh^y!Q#5pLXJd1vQ{UMBV0)w4 zI|wFIVnKg8NVqk<@MVg8Ml2YTDI6BRt{fpV;MPs)>bhZbf*h#dgaNz3qIv)BKmU_{ zh0BbZ!id=ztnep`uKj;G<|qSvyI=O0%8JKZVnIc z{|jd@*t@)QW#z)=#Vb3j8waaf7uPOaJaO^-*()ow=6g*u37nd<+3BtAb(2p0-P3!f zx3{;mw{!K{_T}}R?Ru{rV%%yxOtV2IBO(fTMpBFEtV?Ea0R_0*n>-l#f+Zx!OLEiZ z?Z~42Z+Y&t58MMjIT)fx;s_5FIQ<+{;d5eq(g9v(eEM28&#B(j6I zCH?sg45&WsmcjsH+M|Ek+$BSQxq0!#^{sln+3BoZJooDR=g(fdetm6ib+1uhYr2z3 zZRQNGH#>t#YIZia+);0{)u``YUSHX2(WCorI2%M$r{7Ix3HJDsYr()&gCU;C+yl{Ty?1>8- z>s#yVYx~{dFc{Bj(JXB3tq=S>4u*UC;n*M6ceZxcw+=czr@n8-GqM8RX4sv{C|7Rqr_a z;Z~zTK4%>E8(ptk52t(xXZY+fiNN852M>9_#eHU??HOH&zBM&V3}7I1?~{K)ZfgC~ znbp;u)8|f}Up;qv_008)SFfM^@#{aiu(E!6wchAC{@5Sae8RP)kK-bX$E_Bj0>9I` z*zK>b9&Gn}o;!&N|x7X=*RvV4p_P}wC>j(92t3B|eXzcjS)@rlAKO^YI7~>&R|L8IO zeR%t(6drEev{WDCfsF?l2;IGN@A02{v#xXf-3w>lzw*i(*RQOc`pK)8cGp)gfB)?d zPOoh56C(0Lo|IA>cM}3RGvgLneXrdQhT+I`hCyp*uhVt{l93T3pMIJU>X}!Ae@i*f z*BCcVm--_J!rUi(d+&~P0pF*{RJT4H{*xpcyQMdnjO$zbTRjqi{bs$f-rL>X-R||9 z&CceY$NL#b&c&oBDZ=rdwKg%uw@xM|y_m!)1D?Qx@X8}xgvl{4qwI=OM~>W@#o z_U_4d-aK*s+NtYT-aGxqTjvSz_V#&#>MX8BqjmKty`njjQ(Y#?~CZ}Q2Wo|zlT|d9m z>vq<+uGbsa8wc&pL8rNYaQ;fO<%}m`D{wY~QPlHi;qD-2m-vfcNbd3QKFQIoV+}`N zdocs<|5?1(^rP;zH_u%<(AxBUlSP9riO6*9w@q{GkDQ1*N_9*)Z@uU9j(Mjp`i*#)>6b6> z^&|nfbN|5uY=indPopS^(_zz)(I0rj&S2Q<_cz)(I> z!o?5XzjEfpJC`q?c=Ns2PONOLT&{Ol2b1uimZfqkM* zNnJmhb;D8E9!xXdqshY=3e0@Ym}2J-mD#t0z&4Sz5d!kYvt1J?$+6JXSa5C$E^+1O$OcZR?q3z54>4B!<3~X-jDK- zQ@)3XV&llb-hszw+S%TIdlt!g}Oth1BLop95c_)1f+3w7y{w~=>LhB@dC8kmT;`!4(tqEfBf2o)y?(Q_b>1DwpVLRaU4%Z8BgTMrUB2)r+XQXGCL_{KF^(I7|#mcDeUuk z623H=^7xA{KmY8@;>(DKDShej0eIYKNN_QbZKf|i|95}(b1!kj=4K;u4hHU^yVD9N z*lhI%2leyo=dYi*vVN`S(i;!WZhiM)wb|c9tL)RS#vvIu{^3=zv z_rLc0{}i@`f`m^V{MkSGWfBcWqv*5$`oDdEEtmhxUuDx~cYE{72k*Xh{e!p2y_`RF z_N{l`I&t>w2j|~>>krRfIPuDfH_u(TaPs8&Ya2V;`)gO%FaQ7SQFy4ZQW{C=?vN6YlJ0Kl?tb@wu4SMy z^Ub{{_TFcoa3zIz*qCIP2nYz+vNDn?2ndMakBA89NZ_||hzJJ)!ak3zq?nppTFt|! zYFgR2yzrvr=D>J~KvNS_xW>`p;)~>)ClMnP6X6n><(T+yaXvV;1}oTT;w?8Tpz}3CN254V3cP! zzZfG9T;}&K{;Z#Pv>aaD$@(_TA9y^vDaefRTP4b-sQ)Zx(fFRls|K&tE{(`0!3mX( z3xbSsIS<)8Zt3bx)1csC(C}`Z3KGXQm6ey3T`$$onYtFx;-S9CwQp#`l4Ta_!g$KN zJJ`R|IBE&Q3hgW^B8u$3xNz2+hOV`2*=uNM-1B7&7Z>ebcnl8@gD+DD=?ALx%QXm} zwSfoHFYn0>m#LqBH8AFB0G~ABw5ZiiS6~>Z%FoaDX%op9ewPz_rB79(U;g>?=lKJd zIGB_&p%e#YbmD+1x>2R(@u!r$7X9+^9r^t6)YMd$=9c+`R-XqCS67XMP4EKbIE%K4 zpEy=V&Aay9kK$oW17m!Nc^38aiw7=J%B+b4t9A{u)@9}8$^NVZzpkohOBQYGwO>7Y zd3ZE=UinnM4#`nYH-X1=_1({JH8(eJ4f~!g;i3k1uG;aE6GciTnBG0KE+0woX(gC0 zE_v0qHZ}2^=xv$YJ)9Lxd%L-z(_o_lNI(u<3XZjr zR-(ugx4(`&se`uldoIm?Tf^FKz}=@SglbvV&$sA*uGMztr`TGk|GeC^$o721vNjHu zEKe^_h&5cqyH&*+D6LaEb#=7-E@#x5yScTstjLl(b#ZagxmnLFZ_@I@qot|r>Q3k* zNp#N;UyBj?>guZh$-=@yu7x&y(xusSHE-Mkef99rwo-G#*_)SKyOd~S?7(GuuhtPq z-S_VDz@=FV2F|N1K0Ri~2{Znzzj!ht)aU;C+uJFU3~&b-)3YP3tc zjYoH!9uJlu4-V$*Av9xl{OLHM5?c$acINf-CCYHPl0|}P$;GOjOk9vi@|&|B4aoHbbUR&N$0`4Y7#j7g%qB6l01EVeSN9=K>ghG^z`4VE0BnA*y-S7%tcgC z-Mg5PoxSAm1RCF`hc?}XDWiMVE9D+m?UJ_mIz>%FAe2;8M@y}aT6aoRM+g_wV&bq{ zzXn`<=*MqjRUHjPh2wQ$Ch0%s%QT87!H-qDf<69W$0fR;c7%o-<4V8I?ByI=?b6Pmh&z8b#YOVVKHHX!ovCn2BDBD)^!y6o6Ad=O3h5gqN3Z! z2LTjsZ|{~x+jtmhl(a<(Cx(>r`JMOHf*tQZxC!O`tGg;zfpmp9m^_}iWs5#-b=~|c z)&BBDTi51AN}N!x4_T-M;MHKVNXupFEL~z+35;}_VOvB}$_YQi+Q*#r%GHYp&8nA= z+#imX+g$n6K^2&_7WTe9|2LQllfAt2PA~;;1Fp;#q(1oWs6n%F%UbZYM+Ah~srrLY+w#$CqDW9sK)Nef#^O_^m3s!jI#7(2vaUqRBhW*xD4Z86H-*N53oeI(yyr=J78Kw&9>}#m=w+H<$ zDM>XcGec`pnj@HF+?44HSZ*OoUw=y&!f=x6^&nrpE?dV(tpvn*TU*k21#O1P*3BPm4QCV=1<>p@`M9|? zG&Ul(W##=@=PQwb0;l-vOe6LeWF%J2gU3aQ6zZOy$kKqsfPy;$A;{Rii*^$pkQS4Nd>(V z6cVCY|5QC|&gJ3bLy3jTIImv(({4MAFpU7usn<(bBT9|%nPZYEsoXWALEiXIWG)UO zAL7dnlBK(*GQBBg{=n%1zNfv6rp2-W?b*T9BFqSl55(RRPPO_b}>_(?oFlwZay-EvsRsBxy%w*0w%UVi-S z?9EKm^3k+2HiOsGV>)65>eba1D=xEhrKUA#I<}#eHM7>@=%B=m?|chZwq1>aWT5Y? z+J&|2uqEbY+bQr4Del_8dV7hV*TE?o~cFVGDz2M%zf2ejyjjxHG|9QIDn@&xA z={C&g>gX6~=Pxm}_qLipV;JO6N?u^c%IYej_tJrj)@?SUsP3XI&j4ph=Ac<%k|tXs zXgFMCC%5t-(fiNH$tbW;XFN@{KAK2sRyMLU6|D|EgHG&Tc?ikL}k=u&D=eQoCrI<(9eP(yYgp2r6pv zWYMIhKgzZ0C|M*XL@sXQ!y{m>f@YkH0w=T}?7u4jwTd(Oui2YF z1__&!lP~Cnxsks-JltLV_iqnARO80=^Pmsvmv5y>@PL|CQU#Os3tsa4#^6GGiF@z& z+awv(d;kpere_2#QnCx17H#8TpnZJEAMbZhtc@snC8m9MVN@+lj^qAgd3m{b8CiaT z(qK9tR)&A|RQU0YJ9(|j^uZfSA@xYFnWIpcGn@%ZBRgEKH%$MqH?M8-CiQ%m)_ADO zx>0v&d09fVfS8)8D-;>@u#eqHI%34p(a}6SJkEM7&%Gb-Zw?P}Xsz3piKYB6d!oAr zwL-W)T-`lb)&?N5i=;VL&tj1{`uYxUevzBxY054tI&r(dz7C_9P%gpNX3O5L-ArumfQjjX~GI0HEP)#<>F*IJ_Oz3?Y*7 z!#HRouC9rBJ5KxuPW<4hq?Ft5MTL(^9C0K83|yd*#h;KuU)|X$oV3J7MXp&`U#EqE zLj3RS29z70o6SVQ;SPFm(u=k^E^a5?#`e7A{Z-^q(f}s{D1F2b-?wT9aC3%;FKQPk zu^GOPd>_U^?ZXc3RLBopDc+kC5)qj`u>I~VnOpl~*z8S-QNcSXKXI&28&%oi%L&|Y zk2CDh&4@{Bha}-B>72_{+{xs1e9;WGO@~{r@jaPlPhPr*8nzC>y;&0{N1eY6o3WiQxNv$D9(4?6hgU?4qPrIuT6^DY0;Wm})R)p$j@h9CeHc#$57-i|tl z^sPD}hQ6R_{$R~VyTRtkEw(4S-+O@5hc;$Bu za<19+2z1w5h^u)1c!yDY5IeIIMc8xhklT|!(L>M_uE%3Ic^T&qF3q|4$#Fn=|L<9T z8-*le;vpt0=BrxMP(z@oM^jGxAL3BPv*!;$l+ng1{TkTB$Nu-@$B$$72pRQ5(|WO2 zoN_T#3RYXX{3LMU4{4&YVz40E(jsOhb6Zl{!iWRtk?!);2~Y4ow95U_XL2abv21?$ z?3b@It>INC_wd=tfnMN9{2(+4g1Qco?1y5*k1<@O-xMU+!5e*iwri_49b{z?8_~&o z*1u|a)@t)@tq`c8l$oYgg92mN(`|eLQoS8RUnY&Oz#2AUTaWAlLL1PR6F02FtVBRt zqEF(@Q4w@1i^RmRFm6%j#Fw`TN+vrD*~y}g$3T~OxV=ruptkiRJrq!)lKk=t{T>hp zNbI8R^o|o`UWsH4p|ZcJe%|IjXncoIAbS>AyUnRd4>*bt{4 zTwk_YI~yXre;ATVKwn#Uj;kh?{qn5au`OA#OtoHNny&0qX4-p-@^Tj;MD zAGHSQbJUAEaC#>cuxAV_XQ!NBnfh}ZluvJ))6rkNT(LGKwX3iL%_#T^6%8lg9pdf! zdJb9S2uoK)ap+N{?kIyHO<8wmV1b#SImQ|)FT{or|D|`M?i#eSVCLuw@C;L}-+kLC zFpW6a0x9tUXi$Dro{HD9l7I#n36qxqQoLwO@NdtFpH&FitY$@8kk3s9;2?zLuDcPD z3TW#(pmwQ)sDX&QNN8W1$PqU)iPw<9U!AI>V}=<1()X@2jYU`owv0FzMn_fZ zlP@Qs`d!mMaaT{ueH)){u1t$ng9+ZZ3)n-QWB4uosEI~pjL#+3Oip%|(;RbnjsV#l zrn=x&d6)Bfw}`N!hs0d#y7x1u<8GWA~ zy_E@r#)N(D(T#r$nt_JL+pWCW%N>TGH#}%RLnm%Zr9D3sRg`lLUsHqGOJBB!m&TlQ1=W|T$-UXRf zdrgkK+cW8};b}08xZv7A9rV4mTS7d)g~7o=fDga{19*7-xU<7FzzL-+Ro`1(H4>x@ zd@qpxRWc1jrN0X6q(_GRr5)sr9=`~)w2=k%tXcI9NG&+rmOuUQZv89`iDPAc(p2Sp z7LHVNq&acy!P!;W^PLOto$)!{O{rh;oa7(jg*2{PCTHgl9=zzXML0jFJSer(y()W) zkO(k2{&J)%WVvO(A`4XA-CCSTEP8bNUMNS`+YLc-r)ts1WkUa@j+EBveU5uAWqrE8VJ#O(v2n#Q?5(>A)>XD0ALYvAyRg)@q;MQrEF2D>JA1%mBAIMhhH*zO`E!fO! z@PyX95>1&^ZChTh^+@pk+J~+SjSCAC*bOyFAI``C62SfOuKBdE$d~!#&$WA>? z*&RHBcEr}b8au}&!t75qNkxFMq0lUBNGS^n3VPjN<9*$%L;svBpihx2d=z=@w~&#M zq5MPc!H`e)x?&CVqP$PVlWRlOv-^XoJm3b)eIHl|_l?Pk84!Oz7w_?rG65hwno4Ai zlfFEtklqIgmzw-LXCsj-^PKRo;}vhZg|OnfOk}#q{^djQdPb;TWB)i;sdT;b1hSYy zObbuqM_jJ-+{0(MjYaY}^F=(mzK_ND*_dvG{4=q=Uisr6{4SjMON(~xc*D?19kdcw5rU~m7?6;vs8iAV4f{YR zppOr~qI6Q5eoKlbgYAYo7qIg0j4$WaK->nST2L7E4~^m%PKw(A?Z%!3BB<=N!_Ke+ zyDrq=@ci+c%Y#(M!=%&|@=Tl;LB{SrK7aa(88gBpwGxt4AXnyG;vr2*ayeKl#+i9V z%*Fw3H4BNa4XRe*lTGB>a*5+Hxz5cR421-JF2zdZMN0*8W00>}JRUI3cCoa0r#9!I zN!i`vF;7<-iLZCln_j02isZ7N{Sv_Ef(lTh#YsCY&;+?2ExnM+()y{(0`0ni_(S+p zh#7cnAr3V5s}!&HAzXV_)w7w5euy)%AQ(hMLLmT}5nr6-$i~RBS{6^H$kPKXvDoaI zbb+Z2S;~C&pS&Q+{*g5BLLePSR>0(ldY(O$t6i%H`Eg?ioHPp(>Iw=w<&u2w1Yam( zziOCtKIw`aSsxuVoY?TJ!24Kf%LdWtM8M-%9rstSO)@~ho4L* zk;+MR-FqnN-&-*$-fr5nlTeRR8#|ZVW6XwLvrFgTMQ*3jdTh4zpwo9a zHtKT5JL|Q1UX=k*UATg{k>gw-ie>J3wKn&hQUJ9MXcYl2E5WqptD0i$y;hEDgq(@=I0*JOQ-f~ySRi0 z8^rMdZxs;{0l(7vP4B(MXlmF1Qe|_qL~9l07fi&!H#(-$6po%Fqh~jQAI5&$x{mqd`%ogkl1C`yI|70qSvS3>Y>X$~(9!}&E#A95 zIjLN<1vgwe=f8T1{ z=e8Uus!sO$4iw(G9PF4L(6r4vnvCDX?o3+NqJLEV5|9fE&ndH#Ow9YTeg*2}G4k&j z&|i{N5W}@R$SsxB;m^?hkvT{T4uG(vj`B;FE&PQjkrIPG-Z8%WzipQA5HjU_2vR1F zgGQKQ4Gy*c4sJZ{Z-9TZ3`1z3x&s+XG*_|P1s#-03i-V0X^sH%CoKl;!aU}^zF2}p zy8wau4d}FYJL3#kfq+LfmRWfr7<#;I70b6AGE%ZwNOA6Ak$Z(Fhmo*iE2%w!O*c9L zbPmAj01{^eibR+&5cz-r@}IV@5c-$VIF@mDYip~iY!S4E)>WUjqn`TRGd}wzG(6YY z-O@l9*#W<)Uz?}NrqAug8%pmapi=zX*|DgeWp~;lCzv?8s%)W_yO7FWXTMPMi&esi z=ysqMJAHhl6!e3mO(AWd#0T%D<=CQY8?|=vYL5P6=Itm^<6C_(`o%mULX(6HaDLL| zOKa|Rlhz)@;L7KVR+6|_5b^fM5EOfsLflXc*af=BCi9xyuAs6np=A`j-8E?5?=>mz z`187xB|xbjAC6Z$!Nc>P0sUkU;|Wqge{~J+y_Z-f`ODC(HIcaAXZt7Gx9!Rc?>fU6 zB}|Cyh4iO(C6*#r%9<3DY|N7EHhk|sKOL<1fVSuJ3n+QkvLz3vNjT^$xooeM+R_?q zS+5JF?tQ=AO68VCe>cUvZuPoB5(Y{wqa?ZeUz`MjjA~X1O32d|j^*pMTr|Q}P7t)P zW|lAhiRdqxM04?JCB?mc=IoSWB z^xXla;`o^OYLdUMehFV&FvI~GMi<&<01drgOgF?hgd8n^v>4+ZggU6P z+j4EXs7U6qv84qZ1~Nh%BG3&%>o~R1l>4HGfKQ2zM~a0ShH)yk+azt>X)QR!hqeZN zA1ugMfxzUE%Rn=bl6FUXh_Q>H25`0gC`RDdTYTxaLJFB{t+%lM-iE62IIA z3?>7~e$5@Au&GIh$GeNas`}F=jzvSOFap~Z_vf^GlHez)8X@m%0XjqpIXA|t$3|41 zkmu1P()wu1th6NQYhg+uhi+=)4*>`>?J%;-v2$l$g1#b4%*rTB;|MpdK*e#VTJ2y+ zuNly+LOcBfk{|?BZdT!VU+(Viz}t3*b0okp+^s`9SNEpNn->qC?nRyy5B+{Ufxa#W z-yqH@+Dp*@8gPR_WvyMxtHk$*irA1{nc5mj@8|d-3zY+66@C%}^Aie&&eq&7{~~{Y z3i5~j$jG>n_Ks+A{!!qaYdO34uOeoRW#QFl zxHQ+cBC>JJvU{)>v4dyp&h;O%!E~*igFjhbwDK=Hp0gUts&s8`mP2imT0Hca>lu|A zH8ipt$UURxLH&P1R6RMmSTV$-1@&xDg%Dp#8&7B%M~FD}DZNGgiw5I|s_La}Y5$nS zq|p=)t-5;tmxrY$>2WP!?0hJ!S(Pa=utQTN9_jKsm7Ei+W=h*G)km9UF%#YvY{Rk(9 zJ~;t-S&m$Y;WL%!?egeV5kJ%*jcModX^FXrFd#CfM$b-yQtd$g@aX&e&+(*#{)^R} z!j)+QJ{o5gGV~W~<=i%Gr4hFkisM``3t6gAw~|ls_8#XonN$el1xPEj*+`=SKTM4P zGiBmO2=6fTvzKO~xC}{9Ll&nNIAzIt2|;HlKKjWmrLUG|$Hy8pPE=1(7@PZ|9pUWe z{_s%2m|TP4rI;=pF2>>f*HU_2tTjk%;#8Ufad*PvPcfJu&8^pqxg#-$L1?RG1va}$ zlQ{jtAZY-JpkSZ8r3vwQxR_SRKfAyG8yWB6?&|vP=ZhCjT`0%I79J&%pOoWZAeev* z;Hg+B`~!oPylRcza#Ya#y>qMt>R;sN$_aE9UZz@c5KLEDls>y>mMH3o%}a}mSuuMlpf z)TON6QddM&d^1lI5*7|-YEGsi21E{ZnapRM=J?;WI|+9h zNo18Lg5)}uhi~i%HG{wZlMlmP{LAQj8%j@dyq&6J634+`JGCkR%1Ui63lvgYXe^%`>zr|Ke06D~(yY2q;xt9VG zlYFpI;CGb-)8a{}+puFY(PR@GE+iz>QL}zRVyleLfEvKsF8%HV|6gc*xZpOnEJ4>F zP+5SK!na%oV*yaD<=RmfB+!MKSN&-4kNEkI1g+>!b_gOqSlI^@j;%* z-DU|wAht)dw#92~ZT+K;+M=XTRzP76Lp2CTXGaw&cT^}YNP>E8K!6c|M0|m2VkrFv zOxc!~mdvy+4;PzZ|Lnme22Z7HOXiBEo#yjkI+)n{-YPy})bmRjX?J{?@FP0GSsnVL zs7Kfh=DDj>xyYNx-)ITeq;l4N%~^9dklK9p_4_!KT1Kb^tNv;=kulJ1EM4pi zh^05Cb>aQlDI0QGsB7=jd{WKiz0^XQ|v9z2|Ql! z%R()Vs;2mIfV`7AN79N{988-^wWkZwvWi28B_3m75ht`0eCEC$`bW&xp2@I^m1oLv zO~*wxk|@O(t_}8ZMKS{!hU0UU;m}S(72WD$ruhg-=oQzsc$Y9ISdQQ}7 z%EM+^BM#_;Ms_cC>tF9l!QmFZGDTAAhs788BE)Xe&Vq) zVH8u_&!6o%S5TQE#LpOCxW6eC4p|EOQ6Pp@YCZ*_@s`mom48vGL{g@x+tiaywKZ|M zGHQ~Aq?{STI}G~K1AOoRtHo)al3;lDJIu{)^?-`6h>{h<6T?3FgW_0aXx zg4rT39k<%Spo|D^z4k00$!wS8lA+oVsFHd{Mh&`n=oo!}U-!Wyj{KAGQ@6~Q@OshQ z0&CPmqs54?eK6=33;cOaeP!{j*V%GB!e!xH~iw=**v62DO!aGlc?iO z!5EvHmqzJ`jQaif#6#lKsR26QD5uT+KfUiJ+FCjc78F05qVa{oaup(OySdxdeP~Wd z2&-FtTe!QwVI&MLMNN`4!j{7}WJt!7-MI@evjp1jSx9H>v;10Q6VdW=mD7&|&J9iA@Q9yJiG^2bSWLL&z+Zx=(m zt@;1rZ|U;=Dl|sYwZv-+3;KAeqn`Lvalj7g;Vq9sp@EP%nfj$#`DcwR-rRKCWM|l* zq{->l)5+nYz^*|oS7>KPvO4=O5(`1o5?`r2aW2^WH`8!AsaGl{GG8?)B9pHD##J8# zwRfM9;o%`Aw|fw?yh;8r*?9el+BU#I?j)k|CK2lm7A|Jfqy=)N3WKOHIX6NbqK{$- zCO)-2g1Oi|`qgtcJ<@Px4tx(i3%(c6zx_dV?(*w1mKi5RnlvNY;4P#=`ugm?{iQbf zyXmgGJ5ODG=;>-_XrBp^=o-%Xld>%H_&LKcfty{Afz%i82l)}PXNV+XH> zHR@fZrbd0z!v*|>qImr-xxe+k3HMZf4qY1vyjmi@Gj3iTd`p(eAsC@OaUwn^Up{>| zzx)_NF0NGQUNL`AP>{KWHVXMhZSl&|UZp-OPIH_OkNaBMVqo2P0M$e#tXs~H>z~Ho ztRElHm1!mC4-$gj=_Xf{1+Yx;Ga>hjLw{efqfQCheqip$h~Qu^G3rW_Q)N{{Efh^Q zX!CghUzlTwk2#=7nreJiFi|CVBBr8;3fD4sp+DwNgXksIt ze@oN}E4obQKVy()K$DOEk_?wZyZ7(iOp;_oub$msr>!Dbi(=frFawJn>M8W}&@#TQ zCW*ejD|I}Lqav2oDsBnry}m3fveNjTE0v4AT=1(M7LF?MchCE#j|{_kt|;KIh7}j* z8>xTM{c26OE3{~JNc}Wc?OfkD$fDH#s{(6sPVZE%g0n3}k$3=tfg{##3qSL`V1j+m9ltb<@gNzBS+8%z)`UI8ND7z2kf zN$`WJ=(8)BuHR1@yzj~{zir((@xxhkjc4|N<|axkF7=HLF~O{wlu{=Gy7bFEzFQa> zP;f#C?6h$7$v@N%D1wK)>6j`szpg-yz2(Q0RQ_JzL0)xyKP>XJ@RcI#`?&-x9`X&wA=wZw54YSnM&F4u3czj zY&y^%xS;AC(#IqjA#a`~Fk+u0%yshB>z7x4%lvrMqTXfsyP^(NS@k7VvByjMR85=A zKd4bTZ%?rW5Ot(Bz-aBijx@5@G-XVUm#mPl_QCuX;=GJ9knXsRl5$|kNI=zorx5zA zgnq5ZGRs&y*8-DreJF(?z#A-o*G=7qdvxS( zcL{{Yz(A8IInLtnp$8^_ESPm~{n2 z5uUr78#Ox`WhV=J#_-NocQBV{=lHNBFqD|`lK;lic0;ik^ZxL@QjgUFEh!v5x8!y4 zTSh?C#%R8LXeV)f_Cv{mDu&Y}x;d!;Ys;ED6*RNCx_*{W59$eucVp%hp5JdZeM5;O z>m1#*_nKUu&J~{|oms2K`KBp>DJ_?^NSz0^^LUpJlU0gcx^wZhn<=^DaSc@;?uH_A z4EBGoGT3Ob^05%%#mf+RpZ`u)TYEpw7&kH=YyIOb0tepd672VfFWMe#YLy-_-3TMo zm5g`+q5-FfD?ug35wm;#`Wao(P6?#QVm#eylCg9i%$tADKHe~Nsn5T@r!wM5s28{K zG1Zd_`ixVl!#U^zyG4W3Xc7}>EsXg8W95)>BA$pQM@*|R-g^DJAouf1ZI=Sw&M`22 z5dOBRz{o+`6eSYIFq9cqIK^w=6y;c1yy5|7NMx`!4UIto#IlLYzrlwEY8p zJWRG?h=!yahu4x+vwT{>xGKZv7UQC3`Dkx;gE4AO4{xW<{RpSowx0UP`0N~gNoKD~ zPGw6{fSkKv8R8ldzSn%YI|r*3IeJR>srSlj3*h{t{Z#*xt%; zVmuDK*`uqc>*yy&=v1B7&b6#`DU=6={Rg0y<;LbQ&8>iu3|rm9 z=$HkTs_r!uiM*#LAZ1Lv$k4&1XUu=|Cd zpwCJKh&FmCT3-*RGI`1hLDf_JeCbqk?CQF)^4q!ZhD5pHn7==Kee9TRcdKxnyc(XT zz(LE!a4+1uXd2%!PiUhjwiJ%k27>by@atKm;0wR2k3qvTE~aycTCm6|{i#0PB4V@WFo&P>Z3%w0I}jGlye_CsJgZ@o&>~7lB#qqAFw;s9@wuYGVGQY< z;H~TCaFe2w9dJn;mu>+A-n~9mmBb#_%=)A9^77)z-|(Ns{V`_DNImN(2L}hh&I3{H zwhSvm(+cbU!$V>s`3Poq7&yJS*d=4EoU-^O&yOAk2eIQYI#ae$^xJ%I5`8f$gD~Gh zp*0tGj}OhL)f1xc#;%jT9T{ua(!X_6^$l!;%HkiqEykqSqUU`1B6OrX>>bf{5@R%- z-!bhj4_TCql0@523%PEO{BifQf4~HkMK3f^1=4t=B-jg4Ko69%hnua8zK@+B^Pm=- z$tBZPlCPy^v=SDVmcST$gb>d(tw=Tw7;Kgo7Gl0}gSxTbAy#-BFB#HQW?}cfm7r{C zx=Tdi*;4zFZDTav(2c0Dqyp8Y`o&XZ1dMo_VotI#Tp z-5!5o6cN(|9rfSGm*%S5bKz9_&4Vg+I(SUpO+P*Wru#7Od8C`1t04Y87plTwQ9NmJ zRIe#T$VmtOz#1xN=Z_`GGU=ttQ&KWI!?_wj8L4`D1}*`cC??oMvo%(&!%3m13e|kuHdt1@s8VdrxYBND6!|?1x8DvMk)nf65GgDmT4nJ@Htzm(;Ptl9 zSZ;0Evna`rRIDi3QX4nz{u_eGFU3nFO{-h_OioYM->sE52=sM-DuC&&xyh`nM>)%P zj1(DA)j!3WQ+zj-jpn~aCbxIfQ^k$q2$YSos(sPizf)z5@|#A=TDi!=Jy#<};awzG zQSqwKGNm5ffx_Kw0)5ano>|ZwIc3JHPsKe)eLgH};&BSZ1Gg3R`U)4vYO7rc)U+vM ztB|kxujq#jmnWeHfcz6L{)bzw2k zI1{9Dnqw|b;?2%VVI65~Vj6R)N+Qf@4ynNXLy;?(DJ#hs;nG;l!*b-!_Y03fR_of> zfa537zNq4ue&n5;ST?x!sG-iAUF0D%G1{w`MX|M zii?Z8)WAVqz=q+4EpbF2tMW&k{)7? zn6Vt%)Gi;jdEK0jD~eQFV9$|o-GP%UqSm&j-O91MpRbFPsj|?!<+^|?6K?*yL3Ab% z2jeebn*@U4vH92`VY*nA);H6grU&g`VFjC(6zM~ZXcx&I3>NT-p~*ZWb?tqe$i_DB zL|!eQM;1OL?(3hQ#NAm%BD->fAa+ZI(P?WKyP36Nsag_@DwF;M5EVlLMSAoYQ5D$R z(2ZOCS!G|kje!}2@wuLcGOLRdf=?@4z3C$Mb1@YNq3d3`HHw3}8(d7wxiD&58s3O5 zDk>`Q88iOavZ3Sx2Q?UljcdvbvYJ*#Qm!Bl`r##Qx#LF)c4((JI)nOUIG^T({Wu70*n{Ghu<#+MdC5);v6H(TL0oy?JBlF;}vG1 znD=eDYDhYpXh*q;_xs_)*0*3pR^B(-a0r<6xuW_H@s{pTB-xIl;76F^)^Gl&90B`77U4pOHo_ zzw?~-GvCRbmCVe4OFZIwCSOA0D-tDBH6stxW$aWKNVmS&+CI-{DmqW4i`DzdfJvE@SEX$vgb*jtbS*N2@@{9fcD=1^Md=?6LHFkvMlQ>vx<~QgFA{=0!Hy0UG-4 za&;CjU5k{lF9odcXOf%{@PH`ddoy5A5iR0#&p4{{>?2s_$j@g`Lz`^kJ?*PNP@%#> zQ(+Z*oq}0LrqiO&X8Zo96b{+eRFOjVZ<-)5*y1E={%J1|2Jz%t^#|Kn9#4-#J|a$@ z#goR(^T2xzS*5_>TPKv>$l{kN+;wR_%y_y1lggJ-(z3pqO(qZbBZFhPlL;j+#uDNy zJbX5~f3$i_<$qhu;*NQr7PJOQ!xg*t_;HS!8w@>lezJ^B#Qx}n`}3AHFhzt6uWiaF zUGay%#IH`^bD8-QYRGPkzXP9sYsSGGkRl|`ZZ5j;ZSJ2Z7a4%6+zzSW`sE~p>Nw_R zb!}eX&fI7!Mc1cJLNklk^Y_fDx z+M&dL{QK1qg;B=7cwOO2?L<`)n2dV`41LhgVwsWb$E7jOYxT=9(tlg*(sa?}sFR}N z8pDlnh1|&!q9bjz?XFd}5Q>z0rj08_w`hNf{jggEVs;HiTG+?{vPl6P)7lk`)n&Wd zBMa=qp)M5N74}c3ub%>R5Ny_}h)5ytl$7_|8KBVWcV0MIi!C#Ug0rmXi?v5fOt`&; zrmP%VDHN#8fdSu42#=}m6w1$($VXD)3;D4dbc3aEk-uKT@6>X*sO^gyIS?v5CJX<< z#@=SWXpU@3;zYsME1&MVd^!#lVT>#L*haR9s<`I!X3k47x{7sHTl_5G+sNB^#j7o~ z?Jw>UgGH9%pt67kp8s6#E&7Tq7RO&`o>lC_Ep+wtVtEvp_Y59S6T!4_AHmV`qPNNw))iBrk!Ww=mTaTz;fibUz`^`_M@kUioXX3@f> zOANIVfM-DD;haF(?wV9}_3=?($7jSax=V4*_!xmhcmV-O1Cq$mD>PkyUJf-L+D(vR z6{}wbpG!&Rnme~lgmwzs>kpAohpDxUW-_XuY29`4o#W$%VDC~d=@rn>$Fi}wepj6R zy3(tW_4s)I<@Hz(7P?P=)fbOS)&X;rsFKMM)~Gz?y&B?rJav^bOB;OJ;#J4`hIWe&o&)q4?;YcqZ(M) zgN>nC>9E1=Q2V23V5|U^0^|N1)Yyv^I6D2SwXaUQQ=*IH#qGPC``mC}m>7Sc(F<5< zzL-gEFIvtCAMW+Z0izWi94|Y0FAZQZ3ds`DA#tlr}_rEaLMkv76iXoTw^=GY{ zmP+PV^~pMXKA}wHX}-HxKqaAr8qOsCDcL{k)p(*DuC=KoZxl+c?f~Drg~WN|Ks+Kt z7&Jg(L?K7moIEZYP6RXbu zMfAfv4&+}~Zl1ZUFdGIq-q_zyrTtF5m)v~|M; zP}|5ZVuVvrOk)m>2>4r|y@QKDg!gINIjom$ev>*Jxl^-csso2$U%WF3kLf%E*k$4E zHDca46CxtYlShk>hes7YdNm;L!+_(NMkFisvr{x9A(ZpDYUl#Mi{z+Yu zbiAdW0$iaacvXk+nGs3}9xU7J2t~DW^#euDEsB=&ozRw5zLi%=CMS|6SQy&M@EtO) zz6QIeOq~kbY;$LUJ?OL*IRRZblQbw$rHU(F+o4P!Sz=E(`-?f~*i=5at%hdG(*j0J zheNu5ydBvH9$kW@U;{JMw=$B1ZxppmitfM3wc<8o-LIcn$d~*RPph41Q|N!%5hE68 zVs(mx!?2VNq6Y1BDfSzgt)@$d4irVkvFQmzPgfnG_;Fl(`&{L!99YMTt{(6OjXYm( z9eNI;gxuK+y_CF(#G7h`rdDRK-*g`8`ZWIpznchG`jD4+Cgy4*o*Y+(+>kbC<|poe zRT;6OUnAoF*8NA|^BrAaE7dXfw=7dO`S4v99Sjk0D-g|)?Ufe5HaZDPp=_O{SN&kZ zAJ*?Fy|+T@MB=dnr5u^+Z)HLrxrJ>t+#|7u6E&BuncX(f9c4#IWn4_2$a@%( z_{SUn8qD*narrQEU2H{sALUIhaec3mJw8{@{_?!~`U3$f-o61G&2Tv0bkCmDSEu^< z?r5%L`OCYOyYY-iHNx)e%c1DcbafVtt~%jns#Z>>A69eO-e*|)A359#{%RB`bY3Xy zQgUq4Q@3C-vDv%Jug<=eRm2RIk~E1Q_E@*VlZ{B9w}IKtR?S*(UpxzH-x|E5PCa?~ ztx^CUCJ^Rl?AlBrC(1*OsGxN8zMp3;;y$6M2rRNnJ}}myRUjfFzZ$DX=qkiB$jy_S zMtlGDPU|hQh150nS;IR5pB7M&!CK({-Tb^xYhzPWKnaG%*&*-0?g>h%Sn>G9{$>rrMnsv&Uo75IKBj);L?wd7meX%?0kwj~{LX*+ZQiKNn-Bp9qIR zH*%w-X*u6A4oI_)a#t_fsw#ke@9;l<_!8((x8+ZAYYbLVsYzkXPap(;{5a!)5&3_v11oh@@Smce^2*HxjUtLa3t?CD#r-OMO{9j6mG$?Bb zcF%wTIF05f^)i5$*tNzgIdIuP8ah<8XSZMx6Kq0*-3aG%=BLvJ0z55Uw!^?b*Zw-! zrT@QwVymP7U?ZS^*{SHUFQHIPE0XPvtKwqjsQKSno8c)&XJRH1frzirt=~f2V z3y3WeL2E@HyyW*_OAM@GG^held_C~GZ3UH%XMcA$qJff0v2%6g;p*vex#ep8gm(u! z!X?9oX53iAj3NUTxz#gju4baQrfP{Zot>SlwkM&+qfDm38nBWG?0WR* z2)N-VoF$gKg}%2t4CX-m0#?lLCQdU5sb4aMTwr*r7m=Zw$Pn?&Nz!dn6>1AgTwF1> z{y{Hm8wBp+y%cqH%eXvyV%m!t8EbE>BA^EY@RUX%-oA%~r`q$8qI^oSgW7(GX6~&D zu&qvxZ2hRU5dI|eOZaeBGl8yH;w!;ashOswtK0^XZhs=%VQ>eAlixS;pWy(2_DS4>Ebm2uE5854_uL}b_j&Qkfj#S`bArB3WCGL;pp&qW*He3*|C`1 z?+r_f^F@>0HU`0o?FnOS6N-w2@5jq2iqKfgM^sZ*wExG^IXKk$_F=rO)~aPJ+dOI6 zSoSiPT_@YNTefYlWvyDaZM#$N^Lzh-@SJl$_x-)E&*yR&6(yCbP%F7T>MYgn11}#1 zWTP=tz$VZOJ5dEeapk#AyL1$_5u38y7Fzp}E062^;3J7B>S}9q2Q~>EJ<&r(JPkWE zLH20tCYt%a_PhzY(CMKl;S1#fS`1%<;>ugW>}q%eDScb0x8f+C3Ah$MvG#kiEKA?e zD^f;40qj!Oy*dY;*UqU0l4#G+1yfFy+zy&0=#r;ZQ0zF%R01gsTsm{aIfCp?Ip5^P#RUtLB)Fr!{i8Ii zcOm@d=O8?|ab``5C7y$b#Sg*BefvNjr4!naXPEks2rP5*!RyBI+ENQ2QHYx%FXX>T z=Z(_)h~-v0WvP^D_4B4`UF%FK8x|uB<)*T1G0%Cvgcz=HWDUG(%Aki#QnH#UFoZru zKr*tD6_qnCQh&)4?*IB+0avq4WlnLXW!?|J4)d!`v|{dgFI9iE^(XVU-;Y-YkHA9M zS!cK2skA5BfLQtQ^>gWJl8L~6#E(6t#g|+0{Llj4_uefrF<`)P(mpG53y>d<)lMVV zSK3rPsznq>md|{xZHcAdF{Lf$G%hWgb)_GIKh!%Ai zp(8`d$|#gp1N6dhC*3(2-gfcE1h>Qak=sNZsxux7DjI{cjF-_&L;51_SGZbKPL>FH zF@sKa7ZXRSdj_&yExJ}^mc0EkvCrW?GbA42!BYi`qp6e9qaGOFv`%gA1Qa{Ph_wj6 zQ2Qx+DU^Rq06J zL2_Td6-k6|j47Fiw#s#iWK^!&nI%q~;REP;O^y_*<#|te+O&=daOLM-I-{nQ>I~=8 zkaS2RRvaN|#tdu-3iMp{)vDxM_F`0`W&%IjC?cH`9^-GN0hfWqt_0+4QN}pUr()m^ zjfJ5`GPOhsVClg8WoT~8fq(z^W*sAeJjBmES`a^~e+||D7Y(AU?wc4J;FAD`(m==w z%}`^QhbEb||CCf{G?7E3@+5sX4ItQoT@U(qhO%VvI~KN#<8b(&GC$?(I)?B55&bx6 zk-RlW52LWpHT=h|H4P9|u>8*7!7uGN1*Nj78h@>RWiFEG8BP1Ud9z9!Rsq@%V4n;G z($??*crEv>p5gyDu9k5C`ve5LeBXzB8A3UGtPk_m?7a6YZ{zjEjoI8=@nRz@cf=C< zCW^``q_y5Uo>a`83pszJx6I}|?+_vsU`{)qXI6L=^oO5vOdqxNF+KJ#r~-K-sjBbN zsVWW}i4>&JVAeF|@Z|sv?I;7@d?_flvsNW#IRAB41jjkS;a=47JVmT!$nB9z3SbVx z;73J!ZLo#|U}Q;f&kO>af&TPYe`HEab5`_grmhE=BmCK#S)*wA=a#ZoAnvITpOyy& zI3Dyc6Z$AbBKv_^xL&UZc^WrYmZXZlhVnl=P!qU)zvI0LA#A^PzhJ-g z2cg3G#qEA_i)Tcc|R$al-Onu259nenL5Lq=kZYkTOXsSlQc`|UJ(_O(ktHpY1P+wSsDo?EivepX@f*)K_zURl(zZMuTSebMmOBqh$ho zCXY!zz*`T^UtAo9ve>Co>>>)go}D+U6{qQ+<1Mh2f<@y@@_UY)7+E7qsc>%IWAOiR z!We(G+v4v(l0q5&O$vRK8;4OYtYNW9ozNCI3Y`Er^Eo0lnZZ&%O}(Ld31PhXFCH-1 zpsJm(pGJcqHB7Q&5lgd#86t|rB9ncvv(iBq=BoVZDpYsw_^a@1nJ$``&McJT`~B64 zPj|P^gD{T;R)Hy=fHu>0JPNQyaQ1i^;KS9W{uzSBix+)3TI z;=`v{BxFv#Q}W3*@dCI0!8}BTS z&2I9vI%BPOA3~OJ%JF*<;0OxWXgzkYg)Zbz4Jvm@66VFTj4gDfH4$FQOR8X@f$#r+!DR)wE&FIMs=bY_{sIK#~rtZ1g z7%&)K>6(?&O|DpSit|WVq&tW_gH?=_d^^#92l6~iR(Q-p*2Lyw^xeRuqjBAbA&@=V z0}X9SmZ*k@ahLcByRaiS_ghvMp%vvKnts9Yd?ud?t3;LvJ!LzHm1d)2)ux3^K=?hb!VP z;5uu(#`>c7e0IqZs$stzg2SxU`k3M2a%4 ztmTq0s*E#0 zRnYzP*g+-&0`(*h0PjB>#A6pw)!4|wWIwOw_4~{FTennc|CP8uj<_~EwD-#vwXd^! zh>_bw43jkTNQ8HjUV<^22w)nSwMqrc45Er?rU2i+fi+sPM$29%P(;9)O#%e~7&d$J zN}dA#1?LX!G(=4GR14jwKKX88R3O!}zLv46AQGzoe&9QKLtup0f4aTicUg`#9dUar zlkw;83fg(oZuR#u?$H*`1&Lw$O^JM3yZ5FI{Z5ojA^w&~DcmSjGqfow;sX9*?Ctq} zGbb4u{g)(ug9x?IDCqfDidqwnuofXc3X3&j>@4B4ncP;dEMO~x7b}zV)nesABF+ac z;VIQa@_bUS;;h|m$(L3Vt58zQUPdc*LhAi_KH6?=?dADdj&XZT6cq@rGXbWc93S;d z1mR;66Z_D@Iqw@`tt>hznS&X2N{CgLUd2)p1^iEJk24HsvNF=2_)_t#l7wR0>oq2E zWRhrwq zs;C2U)^$xL@O%8qhhazgOEl{F`Kc6Nu<6537GspTZ7?5R`&_7Zay|f~R@y&+oM*v` zb&DBOR8|*wN}Hnk)JI^mRFcrj43nkpu^K5|IvC|G-C!eh-(w0enegbNN!Yupl-9#g z4|VXpcNh_VF7g12u~9I3W9>by4u!C}%rOPx`G%?~b_0oW z1VI2nK$1md(J}#oF;t$8Is4>|FJ!@2w@U4BW@Tjs@U8({#^QtyRbhmN18^9z^xKWa z6roy|QimYU+UkF|q`;w|kP{v!B%O5>p2qmfbjA_=>+m+r`OdlZ!)I}`f5YAN=p`@v zqH{;U>?3cch6fU|@RQA5$NNQ$xRCuTf5&2eQf>Mi)cWiN`2`F6ND*NgZ0x-~3t}a0 zT`f|g6m5Y`wpq@vp>ruw>Ze$!01^ZUDAp(=JBL#E@9xnJiWqJb;A&cN@`yM@pZVkik1U{y^E+t}vN zFd>8@r6FDJ(>==o5cX5+LQTRCUssk>`%Wt{HLTx19wo%YwB)I}-zcjhfMqMm~~KR-tx!BLRsT@B9f%s5Q@PS*MONJj8ap;!8*KcT*pRmf6a@tvEPUKpHd5wl^g zS{Q)@e$+AOHb4yU*6yhEct26b8DK#i}9 z>VI=4g5PdE{>#8!GXTaAzh4p4nhu12g;gh&YH?=JM3yj}sT!{&-7VOZ%pxEX@Vv12 z7ur)&#@ikXJK$se`h74zVzP0jYK7l0IAzXvr!I{zrQ#DB*#I?g)hV(4#x&t%_2a_^ zGpBA8KkaZctRJNznIikY6yIC0L?pF$TVXJKEiY0bDmnMfCfIpmgQc_Qrr+c(HqYu2Zn6ySPPluK7l;(oI7z z`6j>Feqzy#Xvx*=t~AU^aKtQJQ@q4WH1`|qV;oa8wj%%ZYLOfz_CxeHV;U{9RQCoo z2<})N8`qLLD0j(+Sr+|^G$n^g4?G}oJlc~Mz|kX-{U|M~gnd4l5%F+CR#kpkp~-_o zI6Xo7(e*yGktsK_B7gYpRee-n>_J>Efi8d~nE%-JngzZlp`i2ON)%N}Oc^xtlk@5N zXt64HX07R^Lo4E0GSb}d2gTjALqeDvbw(&iTtKcMP zyRuO`kE~=&x5fDs^!iDu>bC+K{Jm%_{~bNo4Hku-}ERy61@p% zWdh7Sh2A>1b?Ki~0Uw(uqp|dIPCBpls-m#cuCF7RZt;H!&z$|F9Tw-%MU;N50NTm5 zo_|feWS}bx_=H)L8^K@zjaYZ?ZpI+ig_zQUBrwJLFSON=z&EW`KMtL;TKZk<0t*sA1h?`_rS z)Qp`M^7ZAV{i<~dy79K6_vD>J(4MeP=cd0HiH&>fJZm`Sw|QKx089~3I+&_+6MAxf_#lTLwd{k2p<-*F)##7OI< zJ8w|Wat;Kgl3`EcUKlFptkmL+MZ#ja$EbxFVv}WwJd$JKuJx}g`hdejr-j(7-Uavk ziM3OaG1N7IdnA)W{`k^x&-3-y7T1qlWYF~Wwm7nLBI;e$-mPZ?qC|>Oki1w*gG0;N zod>Y9)XM75Dx6;?|0U&l_U0h{Fp-(3lg>cJj@{Grsn@g!>ko~2*^a5An}wKIv?P6O zAnp2G%2yD1H;hbrpTO8J^TpMzWWF}X!(Db-Pj6qj~!Grtk-c?Xgd^=>{i#Qn7^XLNTZF*4<$>L?!OIp>4Or9YncFEgJo zoGiko(8DJY;@|Zt9%vU6x%|gH_3FdtOjSzZhrQm$-cLKNs?za;+q0BWo`5L_t(evC)sT3H@-Zt!`a2PGX1VzNB+m8!B>ByZ% ztEx))LJo;lRSkD~1@9@xvsUYyAPT#tVB5dR&nw!V9+vd#k$nkvQ}7u`l@6CU)-$$W zAhyfkp$(uQt8zFfuRlaY7Dm29>mum=@_yLUd?ybeGP^^r?T43n4~+O{r3IXSXvE%XNXU@Fm&3)6JQjH-ie5jc{^^z-+jiFS}g=PRR@_op1V1 z^dEsKw|&Kj6K?or(K@o$i8S1oI>sw#SR)a8doVdKBCO7SVr}LOK3TtBoAEdD*9i!- zRy^WB(9Eh`4C=?WJAssIMK4!dEXAKjXCHp~!zepXR^Wtuwk}*+my>gOnQ7~?t=U6! zlWcUVxgHMacUOc(;viJ(GqAZ3^ur4)d22vI$wWKcdkMbut82aMoOnsm(JU=(J zw4J|Sws+{?^8s(sR1v>Sf|Y+3y;tTFWQqhVQGX9-MD2e{SkDoqqKfIkEwwgf1v4)V z`BYM(x91}{O4_M$DgHycdmzJDJcQR<%`nqqNTUCiV$Q9=Cr6fdniPmsQIWajhq-D^ zQ=klSOX!HKu#hM^He{v6z9@&ZHbiQDabCR(WOHX)lzRB&$g(VyDNK0ZbX2pvS;Tjq zeuHN@{Y8J3YXS`4)h=?XS-egp^G6;eQ?MOc&r%^46pOv zo0a7Yh#sbcf8UCn&FU4Cj`#MKUGCY@6JehsRe^;U*3tbKN@qlzy7`6^kgT$h=a}$V z&_!R;pWZK{clnDu)FBuqLzwB(j}qd@VIRN8C1M;BNq4mkj(PJ|ZRa8t6cB-vIvp9X zZMLpJGgQDFpB6qx$Gt1Ql$(fa|A@t3TFG4F#ZL4Ghie(!t2MK`f_RCX1ZtvjBiJlp@9(er{0|9o46G!DMKVH%8kKpv0{9J zUZ|v1=g-1~(08Xi``<5xi9_ef(tcbZK4Z@eiL1nVq0(B8j1{y@4G=)Q-O_HiYf&ud zNV(z*>AEDXzMWz6Vh_Bwwca_9fnsgr*igc=p{8a_bQVrr5+NbOS{6DfJ=WaT7Nw+l zT~D0-LfNr+BW;Nl*2RIpBYE*lG6fE=xAd~oyHJ>I+9Q-nNeu>)%OcNwwfNvgOCrQtRZj41q93#j4C6^n_e79mr_oNpb8@j zEXvv%6BXi~k^3j`!{q>>%aKF+*N%A)rYya=V-njeVcxQz;r%%26I}-LTK&97j|GXp z_w-OB!`$MVQ-#TNsk3_Qxihw7r49ci`#zX625!15qSJ!D(S>2K6T}-5W&WFh5Da8wtCi;j?1wMu_I$WK&9l%0Mg6r946Mhf}iy)H~~ zCxM#Kuj(pa-%jAP%q}!>lS!tY&$u#F(>1l299&c3vt53NZghBjT>h=y1+aKi^3MlK zdMpN0AW3O;7;$?kCzH-8f8V)bimX`a2vqozYRK658V@7d7~*~UBlY_>eQzYcJw=|R z?fk{H3-!UeF@dT3XUig7`jolN`RoaOus{tv8MaCRr?+Qk;oeXXCX3m=RVwPnW9# z*P-?Tfo~+~cNn~O$y0O#mKL0?D%-SW>(a_@2Qx@8VW-P9u|#Zkbh2|4rD!4h z5sO`6q=(diee-`E8lpQ<4x5awb27inafko`Cq&Dx(@hi%YQhM6MHQFIDqQIMAm7KD zGo4w$NY)WujBw#;)V6zRQM>V!Lk5zwy5=i^;A``8D7K0rsHl^NFVS)3Ax@B{ty4Wm zhCL9~1Cz4=40fe9(rZc&`GL)pItxjZ#cJ#Tq#r(-bVlD_0UD>AGt&=`JU%o)3;{SPORR!R46ohAo5}t0DEd6tBPq zdDa&|;|wHh7-IltzfCVO@mC`+C9G}Tmw}&cI(m2Q)5K@J@lhhO<}&$yz81yk3bmJI ziUt@+Q55J*Ug%Nc6|!VkYEn)Mo%?2dM`Fgdr5lTaU1;m!i7qrKOi04>H)Sh!bH8Wz zEv1Rgho6;|;t5}$(0XNjRp~4?KWGV4Nr{Bw5LeutEDjKt#D#4lBppf&&E&sAbqXJU zIpbqVbI=IZo`6#y@)_5JM0-TB2u#k%>! zZ50hd`s}?2f1A6CxCr@2MOv>|mL2xd3nO+;$+1X`r3Eti(P#7D+6+voK}Daa={E1^ zJb&Aolu>uO1pQ)=3}^e`Ea(YGTB%k6z$irwz=O5-0jPm+yPY!kY}XAh8t-RlUti|f z#Nmk1-SEUUFXahpcEg|g=FjE4Gp49}1-~TnK59tv=doiK;0Ar#;4!dbZk_q{Llh;` zx8SoL30{iiu24pbS;zPiK8J0ET}d^yQ^5MDKoXCUgAiqg;fIZkSBL5F2&_TdOkf2)pEjk+0>s|Vx+L0HCH9ug!21K|ny#2~1v^yL^L$77{)@jcTc)g7PF;N%0g&s$U7kONIX^&38Ka~}V>%;AR#!K`nDHEu|pAcEkxmfcq|i#hWeeP z0{uMu6bG3aXV{^o*lo)3ptb00_XU~-4RODV2iQV>=9ws|v!DFOWi9g<%a*8qQ((x z*K@g(BI*V*7xg8j>J(TJiK?h-KC!SWG}bVA_u*NC=x6Esp{umGJNUJkbRU%i!zFR3uyF3lB8Rj=znk zMe>VBL+`@v?QQ=$+-ZoM4Fz_(_1+q8hH0SS2%-D^jR$E%=%wtzaz|2eVxOGn_mq z)nWqqZg4}@M}Ir{%ql)j9cMx!pY-KzSi=4KfD>AH6lWjWA8E-O;F3uO_{ zc(U7j;^bD@#cP-J{d+pCY2B6K-N=& zutcg^PNr$mYzed7kVC*O+#b~Lgtz7*zFoYO-dA}&H)`-!c#HtELE@CLR70{}AL=My zfP{^-(e3$%8>Hgo;fPk(!4^IxKLibT>nd&T2QFJq9iKTTLFVAoD)Yz%j6VZe$eV({;V0 zZR3vqSG&Sfq5+E0$118?;3c8gH>lV2`VfV;ofw)<4CZr8elFm`m5bR#R(q8x6zPme zpPoVOI=IqeX*@R+b0O%+Um1gFVtg*0;Rz5~RWADpb(mx6qtRrPvTxjocDf?G)pmWB-9e9Q1y52iDcLnzFK=ZyK( zjR88dTZF4B@CoH|WP?8mSEn*KE)4SVt-77Tdl}A|_9pPoH}m#FjHwz!99h%YPZ%&8 zKyGdvTULrx1fDD}{qhUAPO&4fMOB|qto+2h#YQ=f%^JZKj;I@PJqKsFcV1tX@Lgw6 z5H(!GtPC0k^IFLM-Ea4GJ%%`hbHVuo%=-dyLva~-!+>GB5aP*eaTU#f{v= zZ0E#|98Sw{wSZ~)NSdFQdzTC}GKK%TQz|eWV@FmN#7Pv5N!Yf~jHKxu1O_>+HJ;#NXZLdfrEm*{jUP zX#-F|PV>LL$i3a;8DLG=sLN7C8DF$mLECz){Nm}v$InIQDGoiT+R2BrYQpFs3t5T5)*4EXv-W$httKdv)8xm zhP8?nvC*QUnv$=5%AVk6)@DA!k}g<(0X}41$pd!or`a!oCiQ=P&oiERPtN5J){5s9 zRq}1!kmTp$KOSpr6*<&>U0RTUqrk%rql6(%NJ{@z#E$YXdaxkh0Aq|KF`n{8NnC^E z4L|n#tSr4?*Hh&y?(V?#mRnh80og2$WRa~5kzV$+hOsVQx4vt`?bX#4V0+}q_|H_r zJ=9zd;8LTofkZN#xg??@4Gd3)m4`(`04DmeW8kl7`UQtHJ_CF zd(cxAUMM&V%%s8f6$8B7K}PSH&A#rVV|(C{svpfT5ZBn}sCfvbE&P?B*&F1{Lx>oS zA=(n&i=C_TE#6#s4h=+@FYdIo;_IPf{HCTm>M}loU8NRDwaLW-cuJY-jJUL+MOrOg z6Yzk|BR~RZ!&^ill87w{g=LwKkoesuX91!PLydTp&?<@WL`R2Kjp1JTeKJ-Y#7cft z!+hU@{=W%a?ikTxU4z`u)voz;Cs=D#bg^lz|J@}4#P4O;-c!9bMC4PO}TUc zod)x8)kb|ekVv4{;YSuq7{B#!u%)lACm-KB!)*E-q32 z0+*jY@=m{F9Urt3ApscX(pR9S0q9Ht!qfNpIt*XO_O1a10_s1YcgVJ7_c?MHI6-Ps z48HFeimQfkb0EZpTq%}|CmKwIpt6Uc=D(;uD2ZEymC3=^aR&>OR$C#Ai`@EK7S*6L zF?!=kcsD06f>R7FQ5ior!YpNPW;(tL?!Y=vNhY^Sq6f2B>h2X0ss1>mcEZrxtkFTA zL|5-Z9^%1a$wqy#6Qy0^o!!sIGplXiE@BuAyLraM4sPJIj9VUv&xt+D3|m|JtW@?s zx}yiVZj@{ExtEW)$U)AytLc7=Ae4&O+p=G@ZCNI*ojFqU5a|LfO>7c7WM_>W&VIxW zXQt}s2c3pBCJd&1g!;m~s`(QNrmF5E<`?|lccy=jw% z8rzvHyVIfe$5kz|O#2y*PgS`r;DqObJ|X!t(=JuFENF#8$SWFI`n2eZC!gTyT9*dH zV!5o$A9gf9O!y0kIeV{2?G#JCDV%dvev)L2*Zj0}hFBF$xpiNTmaf!a4HS-8iAj+fr?^`@9?9J-V=bJ|*Z}1HSqxLt@D~X0q)AhNb$))74L9!0HIi75{*zcO{#fdA~z~ZhcigT>=W zOdJx8g8RH;J`v30wa86i#M5u1n=(durv0NrV=bQ;ugB<1#Xkz98^3~|@QbW0bK(bA zFb{SasX>PHnE3RNjd~K|eEi5W->dG55y0Zv^NTZ}@(cKTe9zWgoHg5W| z^qi-@-KL*Dkd?*$t}mUR{jfY-Cg5wU^4iVHNC^5(w`TcUg8?YrTRw7H#C-s~{w*uq z2Y_T?ztWaRFiT#yQg$H(ZHPBV+;_73spo_0(_)~U4ZPVa8&uWzv6Vzuek81lc%(Kq z2jfnv=xY-Cqo%{Fp@OF0R5b~wWN~|s{^9$G+4d!k*MWz{?eA5acDeD1CBH6gyclt= zKvXqUVZ%qJ7%6&Z-8KHiyt9!K(6vkCEV%v%U3$E?0@Sm1#kXERfO3 zyw$y)F%>{4`oUJZW_<;L|CFQk$2V2?$mQ9gO(!7FFl-P(mtW5ai(6O5EK|^7lytqa z%cM61vvS;b;uW8XB}$MIBQp2&@^s4Y4hj6S)8>@=Rx}y4cWm@Q5%c}PZrT3#Q3)~s znv@{;=80ga(-A0`siQpxR1my2US73OVs}k*?6~ zWNF8s4$>yCjrtx=3$Vz-6Z^dCa|D3$NC4dY4-#jP@z}QI$N)Oq%3V5;X5s7RVO|T$ ztXKbun88VAJv+23eL{Agm<2VthvWTAueG>}Vh!=ZHS}uvVDZ) zrJ)pNHP2mlpPzY?NWvvnHNM%Pc+WPD&hM=(tOhwnoSifoy0iH6Bz5z@$C(?j-!{9L z9D*}_+;I^?Qc;*fB%Fen?m_jPKk+*_=LZ6EB8^(Zd>3P;30(jI6`IO&jfe)ln~f9pz;A|yC6b!AMwN=s9G1T?5vwD6KUT{(PT=E z9Qe3yj#{MW#`Dzk<~e~Q2qoyA?b+a5Nwjmd2r+R~M!AHxc4SSL(a%~57L-&*a0t(m zzaW+mY>mdyxfRxB_%hFKouLe7dRaSS?xiqotThke}hnaHZ(2x@5?Fy|~p& zwOxNKI-}%2O^MwoZ8X*eZkWYkR2DsJ)}9C8WSHBbDzGJBenZu7fnYBYozj@*t1RyoEUp)hOA*;sd57#w>*@MI2 z`x3AWa@Jqzm?pcoTP^B+sr~qa4NW5ZI>KM{D8^Mtp`!O6J-xhu#<(h-ON-?zE0K(V zuh^IbQL8R-gCCg9;oUZSVx?K!UlxzB&Q8J!tTyGc?zC7;!HwSuLN$is+*|$M# zkvn^D2-B}eHRn+^coz;=lOd0SfYyHxl&36^*8AN!z8eT!1j1FWO9OG;5Tc5@^pMf=! z0aFDW2|%{aww9XqePoE>2QC@WCrv@Qpi%^RZu0T(I`G%Z+c)4A%1BkNQ`=~@104o3 zD=)_h_~;o5hsrl6TU4v-RT;uej;RH`7rZX)c+{9BfhHHnI5shd_8-`PZ2Bpf0)iaN zJ#h2EU*^$7>Ag8+F}vo(d)uSTn6mD7WSG{UKi6z#N}^)BZr4)37H8xr2`+~$)S$fN z)7-xRH8ZIEXk_RiStAD{Hw2%*ytI~fTzwcD1CUf5aM`hNS%)c|3{ zZgTLef6z;vu(cq&!Qa@ZIQ5V#?cbeYE=)RD`u8S2e@}8IGrA52IN$s^xQG)XjSL2~ z=JNFZ-oNmVeCpDRg{&LEqD?j1P<#wt#8_>A-9qSZD8`|IA#0^_Xw0U)bC^ml<1qJW zpQDu~iM4T>)NW0C>V)#d+vs-r?I^!e&f(MKZ>AhY6FIXpS2)#e6RVet>g|r6!Aw8M zHP8M%7&XV+r-hgh`{vIn|1n#;(qQ7u#a<}iNCN`(2IsHid^#(V97hEuBHl8$i?tTQ zn26M1M5V=reaA3Fp`KLEF(d5L`oEsR2(!mcRAS!JFe%UY^8o^l;Mebe9Yri_8bnm+ zx)Yj?LAuj8<*y+ZAXcbvSY*@7HDhbQv#BR%cNYN40U7PKK%YWM_ZyFz<4WPQ^Q+y8 zr{+hs`UntwQpQYb78<;5RFAD#8v+Q3q9vMy4?%X8%egrfVUcv=7Z;f-MmyhFe0@qk zT{u@)O>TMNo)S-_#CGa6KD(kM$8RNl?titou%7H@ZkE}21u1tExx}42MRb0!KF~Pa z#<}(Md>gP3>~`tWbLAndPdf5D$sDVfbq&Ri=bQ9L=-Y}jCXKMAT<+0Y2%xOu?Q~om z$5Bl?tTFytOIYqxHph%vyvLHK1^s8;6Q6wNX#NMu59*Sz4dlSdh@~V?{Op-Q#mSN^ z45!QW)N#5DVwGS0MZJ7g1*TkrkJn9zk2eUFgEN!y3S7h+Cqv>o>$!9f5sX&h88a~} z*?Q8j7R(1Iu#WNkdicjG;M`R%#Q*}xM(u!eMB$dN_U8|>lw_>Lpt{bk&>jT+`_s218BIe!u zi{vJ1V#K2pPwvFr(a+p#J4z^aOkS$mZ(TjFYke7fk#l4lHR_eP;p6D)N}f37PN_5l zOHU_HPibPl^xjLg&=yD<^*y?gEFy4X{fd}$r@E&e`N%V{9#h*O=QWL%1pc&4xDz~& zvwz4s*x}k;ndo=lcWhJWV~{pjo=*A%DzA+jhFvNenX(9a&SmoRdGp}Up7QW}sOv(v zSX?}ns5`IKkZ^tDr6z0?JzxFG(_g))?sUC+27S*fQI(!{n_k{sP#?A3R1w*oor{8e zTwRCEfO=}64W?lYdXEZeDUAXib?GB18qnf&iXvn?$$hEF5P#6-!ibIbtqEXWnumaH z(5Wx9GW}Y|m9hyil2q~iSnpg1ho67iBt(XeQ_qQB>=>y?xIu+l?;uBK@IHUE*osXG zP73BdjoBq?;Y~dxacFoe(_pDm~4%#;FeX%c(+ijbMpEajkX6{`_^Nz9`}X| z$yu$+L-&q~>843xYkuu09@tR&6rFu)B1}tut+7*P=`nHO7%`u<^eLGyc-*v)yIp0G zhUoTNU90A&lGqgh)I6nzTJhVr=(g?CNN26;UpQPV3vm|hLv1pr`1>u!V3dK<1-E{k zE+PMx^vK0QRKr&Xkz?rJv_Mciwt_%l`42kKZzeibdQb5==gnKU2`^Jdds_gx>B}CB`?fFHWrk=&a{KwPi zkhS80e05#a!NmQc&l%kPYk7ZrQHx{Ef{Gy!X9I9l>rc6Id{{E5E%nHXMd-|~eM@#A z%o(iwpH=1LCSF<_II_#D?wFbvxq&F8K>VGwG$uDcNRs@ z^DD}?Xz?=mwVDOkHl?M$boGLtAKv|UhSWK|vb>brTi$uh-gwR63uu@ODOe81mYT3(ixb`| zLlfA#9d;AVf;2T#fF}2Hx^#jF$%;9Thx0W9=G;5z4#oPxuTPgXE}~c99r6ko2A6;k zYhQ(`4~jfXXD&fqB2G6a+KY=efv7hcANMEjMdsGRKyn&v(tcAlz8tbP5Iw;eh5~S} z8=ni1G>;xy4Egiw?)Ai;$WE`@Tf8gCWr+sw0GHn(mV(B<0-O6@*Eij$@o-3q^bd17 zez}von{;zB_Ir9Da`#qkt9b*vyxZN_V+I@f?G)6o3;n9o=^HE1nQm)at6~v|C9-(t z0`&DA!`cL8TFwZ|jW7k(E?F;1N!?)Uth&bpDeADJD~*rUPKv0H$}mxZ7EeQ7@AUH1 z$1QRH;Po5?RUDWLY4Of|YmDy7SeF<-U$Z>`Dq$H54hco!IPMfkvmSmQBEB)91T7lY zaZS>)4kR+o%R)Bo&-ZpyK_GAP1N+y;nu1@BtF)f%AXn^j(~a{zeYVHv(*%PotyJ6T zN9!h}QOB1@=h+hgfYGaw*f!Hl0ZbekpqILO__hb1h9yaMCPtBe4A7CzPegPNCF4qf zfm<}Er2i*{qUY1c404-c+@_=8*Ri*)=jfsrN5iIfy18ZiWt3&E(!3=+BXa`kJ$u7m zVsof7PoTf{TN@qVB}zNX{%z8ss&&edICQq4uYKZf)eXzQy+%X3?@$E(T=-XJOlFMu zA#h|{4cb9#9FCiPw+r%`5chDA=L5nHTOW(W9+LpgyZ;?S-h3^GeQn?r{J>GdS(l?w z9qqfh{F&b9rQW|RENW<{$Kc@%=`K6+o3Zo6wr=?xQi~L&S0kPQ^nWYh-U|Jv6^|U#(ux z!eWrQ#d5mQ@L<nY3hmuPefZyYpkD~v1?k!Cx3Y2TDMZ)zy)`EI&xeP(mu#;-~~zt`e^XB#BeRki)f zUHJDIFnt2m*yA=pBW}l)X-BIS6RlJmV}7$eneBSwUE;{YV7fQD6xF(m{Gc#kW#ecjZ8x9pzHneb-C z;~g`~5oH1U-N^5eQ9CS~vT7>;#S2b&<8n-EU|7R_0i-2P$W@b7Uug!P1GRe_1o4XdMc0lb0f zvQ$f?VQ=qUcGAzi*>*XTHg_W5C1h#!)(zf7{MTW1sj*SC1+C&-xGlI+B^G2IoKFLM zKmm?2z<&m#x2UhRU$w**&4T? zLBlfL2d24{h;{Z^1kbkEA`1zvD{;cIXYd^B8yw%RtBy zzILqMVgsd4r#3$=*vIPSOB!^T82!q|Ih|QyHp1|x*w9{ti zINCOA9WQKU^<{r|>q+}6>b{yRY8MtVYj@k{E|9tIKdEOp1madTj&>zVBjjaTmy|Eq zAcd*?=__5CF=C{4ZWAONmA4wvv!6=PA<4q==pix+?$xIf$hWr|UQWz;J(7~mnfi;@A~%W8!O!_=`zpj&Ej0T2r> zTAr7N|8G48v&~8{JDClVAQ(>%uiBa_7Wi$g_`8oAja$GkHqF{w-=t5bRf`USx#J=6 z*DOo&Qg+)~`5CuK*kH5d7qTdTU-7rMrw3+@AazhbzUyKLF1|Y2P>M5XXpg z{RJkw%Sb~~3|+-8p0~5r*(>>-o!{$LuG_XSJNb4GO$MXu% zfj)px_e#_ue%t*0m9s`xBaO#zQRxrmr&~e!2q`nFr0s5J?fYl19c8C>jlI^nLMgC# z456~C?LI&_fiTl9viyz^NN$MyQ2wfVzNCA!r8bo<>qEtJ5nrG;%PT~)zUBiBnv+tn zOe?CSV?3Nv+-zP;D6dqu){mZdWI#!h8tT#i>D_wPx}B_;GU(24-o<8391gE*^iXrB zSQV5yKCFg!s9sq~haL7-Kl^p|;l^OwD%UP;-L54^KCyG^yz!9Ai5UZ$M4(c7NH%lW zgP0Kg;Uwzr7ID1Q)w*wYg{-|%ihFX!_X=&3e^xmW=+#)w83=gGa|5ameYZ^)_*%Lq zY9!1{IjD{%TY3ny#Mth8^VGX^f3CY7&*n4za-MYfCygK-MkB)Mn1Uo zyN}?6O#!?b6o`Mz3WZ#DL=l#<<>C%)(rI;ws=~Zp`p_OOoI@Y=?vEE9{bz32DD- zIcXnjROjKu+*({r8rl*bPCGG_Z9#T#n8y(m|6bPvm~!{?^XVr&E+AX!m)?KaLl9xq znd(Lk*`#&4o(dNG($^cbe;?kx!g-L48?{BRa79%bxvQ7O3YY=PVbMkY*>z0!7H!sg zyS58?fFf1Tz5VtSbR|ur?5Ymt%#dSo()w{#C4ogIi!77&>;K;}cH+Je=(F_{5*$_$?CB}Z7(;PJacpx;*Q~76ppMJS= z!hF#qG;&{vP^G z4N!EBIGE_kVoK3JRdHDLg05#Cat>xswBAi4)?{Cysf;b9oid13-d)v>& zU_DGB2uixfp!o#7GNXD;1>h`=wu^Aj`w`Upxf!^+aGLl$E1>yvRS5woIy@QubJu#) z?oWGis<3UYygzHd0iTYbdNW70oZlL!T56{V#1Q$yn!@*k`v<0T1gXiMD*E-GX^Ln$ z8~})3zDgy#y>lwkr&Y&nnAlurmKr#-J9lkJ`mXc(ZhHUPzCW#2DNJ$^T4c~VdhwoC(>p6HIMdJ39s( zP>7mXD&!`7$E&+)ndY<^qflnr0Iw!3=NaY|U+hQ2P45^H(tmw)aQzQtvdB4y7Guem ztB!jme?1u_<7?k4bZIk1cs3l+_B|yIR;=1OMC%%GDyv7K88dvLQ@5Nc`oyNhbcQcr zm;`oQB>}t!$GSR6!B?>_Hcs+d6x|lmrW_z(|MSUHzDmii%13CKym>M&>+{^%(}l|& z=@(QoHhVWLKWuND$yN7|cgx6pzl6+Zw>fmRbuae;!m=BNR*d504#CoFfm&s;F>^wD znF#!TuxD~ZvUoBI&@Uwc>6H!7UAXh_+`OQaMY7{2X7)K|au?JUABb2QduNKSX|l7X zY`Eh5$-Z2=n9XsZ06be&uV1uPPo>e6TZUdg@ah4v*B_?hqcq@J)nfSJIpAveAV|^E1kH zEybc}SFm@46ay{!J#`C zy>zjn7e-ga8jDeQ+N9Q7Z#8VtsB2q$UhEyPeU)|AF88ReJ{dRc5gGBPa3bnC?<}+XbqAu8DzvA`5((It&s4~#U1wGy)~mI z5q-?{`Cg$w@fT42+ki@+IR9$B+iZlU9K!u@(6)Rvql4Y_+B>%#y;pEM4j}%kT8_-% z<8IQ&`hplRgJEcb9Ih{?bIIN-l4?EE!>)ivU=v6_*;st|b=N}cxCg*ps}0u21JVr! zS*1wHYixngH8F{q1NoKfW|a3?3ubNw)KjuqCzPqPC@hlXroU5Gb~4c!T)8g9P)RhV zN6se!PT#Q(+k6+ICfyWRP`XZGtm0yX;8K>-G1A8+qn1^cETWA^k?3!pvSCT0EEj8= z_M#Dp?NA68v?w>yv(Pe7xd)!ZN7`mdQ@k6XizhMal2f`&<9 za_?2w=CE<9rfvw~%4-69K%q&~+hU-Xk8{xCkg3P*&_nZIbc)a!O81L8lq8MlKTIRb zwB*;!00f)oO^FI{cEl9~JLV21Q&s=k)#V>~A?|n)B5!P{f zXG=qKL^EV7M(#Le&fdbkB=0I%CWptwOk8s|L`S>S4BWcwVQwUi0A0d98AobPKuf@% ziqRZSrPt#jGz+ktUb)J8!EfH3>zdz{HC|Y?sHoZV5Y}5=Ql3tVUej%N;HY9N2o$4) z|9~vk^X%#StRRac@XMX;CMq5ZRP3@xd0K9-NvX`fod33RaUj1PkD_kCzqRi0w`ylg z^xud8qtc|}JIs$jyHqo+RG{%cZJtDl@Fy;6IM7#d$E*I{vxnL?y`&{R$nB203IiJ3 zQ-Z%Lph`9G^GSdK{!-3dj zmyl&J)n%ZHIpv|}chGOu-8svthCm1r#>(@N+s}%H+hatPmB!0F9lJMGf{z`Ry7N2? z76$wxXcti5xN)BxFKFt0h=*@!R28kRfhlDtI}T8Z<^S|~F~{}6hiln@9DBM+X>*kq zE{rDSR)EP5e86;_Ni!eTUu`r~xg(}>uXIn5Z|IZF#|<7|c*dzR;md)_N$I^ieQ&p= zQ4vb#_qzGCY6&c{RYyY>=1yKs4bo7eo-*kPi{ep@-enLko8x_6gbB2JTvp5EIk{xv zD8OefX7{0aBrJ6%H2D)5Gi(|?L1kLNpI@&fZE#aZE?xIa^`yr_N_UbcF74k%-(Ty& z=jAkwu5(?{-y?WS!fcw6Cl-+v#dd_iCew619k@_h!(ijj6dRg&DY^n&DdaIfb;Cxq z=wm7BlvkaDdv-LkWD?d>1d2hH6($i&OrBaBdfl-7QYdPtd1WksF^c;1;~-G;;RFO- zQ)m%+K(?+n%AM((JIVG4G_*EvUS7i)h;%;Z@^9`e{iREM!xbneSx`J(8a5;qLQKMa zjwz2-wZE<9Ko&&pt>+r%FlnpJwP)oZzQ>}E%Sxoncv^tIRAl_{b1^sQb+E}hEo83V z|7xk!{p&ke(fBqlM<(TEg=pQKs-+VAtQm)oS&|T1(_5d!^<6n5(YZn(^*=_X^ajf% zgs?{*t|fo(nvpiAMok9LdP1d>zks-~Ll&D*KcxU%@kMsl>`BzHao2XIhRPg+)A>dM zU7468Ylk*-!|y`l!hH|5!j4n>!(%~e&(peAEL>3TFX*S(-*-R8)(r{MVpf-gv|t1; zFm;DW9chW|`H8(s$Sx)bNB8`FEm=Ezvm6r1Y z*ExQdnH58Zt;^Z$P1F`Lh!6xPnKvfpBH;Yo5#Li0F|!a$n3ywdPNQ|AtCgzOe>TZR z_FCGUV@r$$$?+DnjxKj8I-toS$i(0~BSN;;zryQGhFW95wJ^E#t=KWg0ajILn8^9~ zq5W$`q(p?UsAcF(SWJ!zc(OvVZa={6Zw=`RSnvdqwLqYR)y0Gl3wKVVo`a!Gcm!ZS z>0JMmI{HvarsMuY*&Q*=g@R9k@#lBi2IQI-Cc%OfMGtX8d6C)`l`pg za>yz5=xz(ocJYc}Y(AJ`Z73vLH7l4)mmAo-Bu`Y#HR9zusWhn!Obi(zcecd&zQ5-- zF9=v_bT{qVxU;4`dulH{*)mME0KWvF5?wxMdujU#dDl=^&yHF(Y%JnSjMp1lRDC%s zf8X;26KDLE*@MtszH&{PO(ZFq;fd_Su`NQgg+|w zkH3uT2_qnD(2@riqUIxMG-n9>bEQn0BD)9;>d-Sur;u);H*NxYv60MXmuxYpuEh%7uods1eSACoyt1ZE_ zPkyom95YvKwR6LvY)2dsQZsxCn;G*qTr1iVWmL|?`xH&qbj*&ic%YxfFL#;hWJ3IE z0u|F`FDmAHPJO#SX{dSS-Bi~rXl{WGxl9erM=*ze_Txz2zRe*=#5fenQ%N*g!R`~Tl%`Uc%HW1ko0wA9&~v6>>yBcgcoXb}I$R;(2Lv3&ld49w{o_IwL_eJ8{83Ci zT!}Q?HgS@@t_efr{|#mJRO~-A3vE*)S{(y%4c+-zl^ls*Csk(B)1=n)j&gz2blVVQ3Dy2#R*v_DyJ&D3;p9r<(%~B%uEX%zY4lH9jz# zsKpec(%knZ5zU`topn+HDZ`x0N?fg_nA>QUfV@V$SFbxR*;z8=(j}`+sgnPc5b7Lh z4s#VPUTu#$_(^R+Zy0VTCQ&^)b$+?`XmWF7aVhBZcFbI<{1^|W(bDGY9)l%60F&zd z#ykp(8iCmH^zt=1p)FyINjd#=!Wt5U%Q+EwLbsO5zvPFVCBrz1Z&=@x523Nbpsg}u zaOpYB()d#*yfPXCUNR^?O+s>57PCh$2~Yle`e+Z1h;PydkY<_zf#rP9mJ7&St!`x~ zovm)qJ3;~iEt^;4rlPQE>?uqj6#nafmp&AU(@21Bb=I6c+T}nUn0EvENS2xATBZE6 zVvHHTygJbj7lQ$4XXF?Ke7<(E{nPll1QQ8wCC)mo&|N>zpTM2>Dh z#}Vkc+_q1cMIwCH*AZlSz3`Iqz)c5jbB||NrT;Vf?oEC@Cu@waFWG znVJ|V%FAYyULk8rI3dojlv7F=l1nZ7cKiDJm?MpO2wl7MCicnPU<_Lq`{IVz<%n<+ z`3Cu7JbPz4;<~OX71hDPn5hd9PO<+f(FMmY4L_LDJfKGeq`QxgOJ~l2 zf5*51Fz_|4Ge{>|J*yal64jP5Xw3MD#Ttv+H|_LRS>iAe55uOV!mgh@pZ2T_mRTR3l?FEXK?Ay{C#+If+k}HDeO+fH=*b=Zj>n8`sZlLQy~q&z1d)s)bg}Z#t*O zFL(F3L|A9kS-}bAws@a1qKit_IdRQxv0zP1A)yL&Qjk1OOW@hg)o|)*ELm5Yf%oXk z6Ew|Cfadpx^l$^lrwd*i)}ej5uU=6yEm~5CjQKalUD6jS;-ZN%nGx`%EGu*?1=#1t<>^T?hMKP&6*7c>fb5$lQopXu#ebj}yg|VY6P&)ivWs@|LrDcA0VYYC zxZ;H{ONI#_VK(J$I`9tZTawDa(GbmlDdeUh(kL*sXlA$WYJetgi=!-lQ4rROoEt)g zC**HSMAciU*fHV7i31vyPd$5w*+6onV9mO;@>VsoK2*!j9r_V|&wwrPXwU?WXUfd}MRY1WG4!WFZY= zG@N_rtF|)sYd*FiK$Qau-ZPX5(i)}6G2gx$XTHbHrd%(J#zp7QfJ}E;OCg#D^XV)O z6DYTG7he#Rh>!Lyb0kvJAG@3zB;E>li=c(>sos zss2S55vZ*qR3DhMX7}`^2jea)N6K)1ldzOHDrBcgEtxRMn?`SPkZh1=c#ejJ1SBB< zag=74x2$Jr^19yfdxUKLlvmt8j6sN(AGs%6LPO@ETN#kx_icNgsYV;N3`4kYiwq6D z$!k~&IbL*ecUmXZG#GLgvVWT)LTQG^inmzQ!ZCM+s`6n~Sm2tkO#m`!j8=W+VO|!d zk-gB@wZK_^yV&1yDm9qJx#mBsf(x2^beRmKrXx)y7Sx%JS-(kz)m9E6D!fBs4$!p7 zeiWB9Z@3B5s)X&pe>tDNLpLxmpnn3+YX00#{(TN3gp9Pj>x&iiRO78=K7?SC?C~UM zN$WXiZW@P+eS02!j^8j^e5&Mg(m?PcG@6B4v(i7z(;A?8H9Hz4I(5+e zSnTTU3#8Jq|H%)ws|H}o5f@#`n5d)KKQuDbO!wE!1jHo%U~#u3)}b5EP?btsljH(y ztH968xYF@2rH?)6>a0~(0;g8->iKC<1Lv^#Gy{^Qc9hEVYjai_zGP$Ci3Kqo4t~e@ z+g!;qSm@SC&zF=i-RU8x!bVO(TC|5}?*EHcxm4;11HeL_|tyr!@+iJ1k5G zSn?C1KK#jUg^9%E&Wdrm28`WTcTY@LHpfZZhi&Rr{}a^EMt28&`rh=6{;Y~vfp6r8 z?Z&C7f>vfU4A*HPVK$2>h^19Tya4O{2*PCzR8g3gr-Bt2MU(-n#xN7OHGvP+4W&6c z%fsvpH8zbDn6%c^^$4f^-r~9Qw9SNd-+pek-ptFO)@z0c`N7!qb=cLrq=a$>A?)g?p&vI69-?TqeZip+^3ewLa3!Ny^$f8!!}Ri;jxG!9z1+p$XCel90>6 z5`7A!?8gUh!LKG3eY=X(Nr%0Y^Qf(Gk%6VHWB22xzaaA2Ge5%N|HBHQ6!~p6ccMfO zl`j#*=T^ZYtO44$h%!%hew34+-rIiq;!@wge}3GmUVU!SQ}6`C8QW0t6*ov_DIwp2 zPEJ(5V%kc&!^q~eMW*&SNMIoM1{iR`IpAh$3Jdx2Muo?eCJPRKW_|Wy+A&aU-849V zkumD9JRzSye<{r{_tpO3o(?pbj!+X zFF54qAt2(EZT6@vDtB^91x_potn*9?q8>w48tCR z)C63wd`7H7c%g+rc&O<0r!UE&ON({@GA(7@h4#d+>ncDshhId~Nhu8+8Ne#+>Z&^A z3hV3?uSUd|1n+xqFRv;DP^8cU|5v}2&V8{f{7_Qi@V`ItRNV;3 zJ$y6cY78n3L?SGSlUSV&H~-55z$jx0GW--t5?>png#RIzhSdMGw-;L?JDVjrGp63MzTGeu|5VX&(*6x9Bv42~60J7; z^+q^+4`Ce8*}3n0B0q&ttw~&#nuKp|vhPthY;4p+_+}O3rcCywhmNXZSTw;@E44@y zN5ZD6_Ckp;87@$%ksP}9kE#e`z+$iNNPX*#eA84~rZidPTBMWAiGOQm&*TKxubG=e^LF7xSHRb=c!$$oc#1^@Dm=r%moa`YgP<`P&8IW|BTy{qt`(T+(`d1- zL&1m*TMJiY{Nf1Mw$SZ<)eUH#qa;zM>rOKB2q2i&Vd7{?-vCatW?K<7|GIG zbPDqi3XM?f%Ngp#DW;y!+v>i3dMPdAPXNE~%%{hcW~K!!LIpK#UOjgi{H>qvbcru! zN*W#-j$RtvS6bx+CwS!U?(Pl_e){_L=oe{HCc4e1o%_$(|K?__l+HHpzW#3Ekx(<;9UTB)87RfGuG{E+uJ=y-_F$SAE?m2SXf3#H z&x%8=Zk1j~4+DYx{Uq$nS*DWKQ&^9$T|DC%-3@Y36ikyiYRk~P-pAg9A`x6wEng>Q z+aGEOny!Sf`$0?}0e*fkM80ZdJ?z~5T2N@3)Xwda(S85_P2h-OUi`_ETE3ZvY*xn}Dm ze{bP#1ahZ=!*AVYf#VA0J9zKPbj@}bu-@(LK)D=0`~ifEl10jGn?M34Xs4D*IZo56 zF`%D0djbT|pBpy#Bvo2&U^in3zBdDI4`K zN6(wZJjZUyf{bC@WYScQ7_A~;u)EiS9dr1X1V=%PglY4jHQB`9*Vos;@-LuCXfO&p zr5>mhl`!QYEZnT&GHl-9OS~(;Y{yS&b6i@WQIqPou=8Avk%v2irE3k;P1{td-x05< zZ9s_=6cw@#6|u|i%e*eA@{8^Nb6mn4-xB0!h<#j|+~Dw~sU@0N`^u`Ue}`nhcqBgR zOtM2-V8Y~B{V)P$Q%s%&r410(vMQQx+X^|l!SIf*;U|N|hc-szc?qz4u0F+T9NS@sa z;&huCpnK4)O3uPwMbB{r_Ul5*XM!TVb4W_Qx(L3t3-nYv`v{GsW-&H?cc2JqkgjUo zo3O$+Bcq?W!gnswpS=JbK%V6X8nDhw{7=kk;AONMT$2MnD`EHT{UC85g_poSMp9Z1GY!1 zKZh;9!#YECtYG$J13P^q9UST5NHYhH^fzEqu3xw0joLM8sd-7kQP)LLedmA|_{{&8>HSqf= zTgJ*I5LlP80hCRzUi`^89gKklS-Hs!l%2(-ghtT)kebqJWbV)P-dF+GA%CWrC?YYF zE}SDaOb-Z^-mb19yzrMDuo?M6Uq?-oYeFm9)!$xjCOsAN93OBHz^z>yN*^9D6;W-H zOoZ9-(W$A-=*bfuaMG(00hjD<~?TY<20<>3FSLdFrf6wtH z6^z4!ci@sxO;0jeIr4LS{P1KuMoY1k6k5K#Q^hL!;JpRfvtiCRX5T~4tMeCd?T&*> zHW^e~;2IScu1&R8ZeD_ngH0Q-=OJ_Qb~{OXk4Z_cHU%kV%P z*F`S4jd>1I>hRb}A0y5TEBkGVAcv>N+z0RWB>QqA^iOLB5yEQGMoW$${K3hwDQD={ zqy7vtr|1;mA6G^IDQr@5&a4X62)2+=E|mB%VJiF?$f&V8;`plG7BJW4(zX5R8>c}T zXC&Jr?j#&Y+XI|ppT2p=XZ#8XGh9S>4T3%Rx))VuNBXV=m63}IZV68klrD*q2%1sB zq!Cyc4&aO{^>$_8ghsXSiNm(Q72B7-%sE9~T28sOmuidiw9Kf^+ z3!RDErL`BMqu=F>D(@S*C4$j{M{Ylmr%PA%W@{JDn=zuJ3?GJh9=299TZG|p)Q!DNtI%&uU=){jW?!-cvu1#gBP@d;3JI4@C zm`Ov!uVl*gt%s(9)z`P^~*X}_9k*pGex zCb7o>+5)jO_`iT@)tVFo@;A2DW(pO*5)1-)MOrZ(cOuu9z)$)Ia*;r(s;${RKdNs< zm6bqx#UWpjnI}gLl=?tp+)h1dd}#Dpe}|9>7mKcd{)`)*p4-y4vrd%d9rZ0;uk3J1 zs%A!q!kold$40iOlWB$QLVo?7SMEo7)GA0!s`ziI=e&^3K|(fB>f0Z4o7VjOr`Lz< zx1ffjt8Io7uAKovs0;a3f2^gRnpbe4zDk%Sk@bT&gPF=k%euy!xLKmZ7P_$0&1F|P zgL0;!c}>{R#C&Hh;C}_x7od{yUW&(-Q#$|@*cKbzq_?Ym@k(vXuQSpKt$;Tx38b)b=V2OIbYuc zTA|zHG_U(HJxc8XF~eu)T1U}L^4o@;N|uX>I&|401se0Hu_xO_=nOGolp_6uFiMQ$ zw$?yfah~^V^G}P3idDJ1ZLmIPY_7WOBi|2x0(WSaV44?7>1Lxo@Rnz}tXfaT)MZD< z!kP0u|IN$$oAal};m4cG3hPO>>IM3xNJ148S&`fc4&Vm500{D4WPQDt#u&}5?C0f<2o)ORvmmV;jUc$0G z*OS696~u=F?1ycV$=cZ98%HGK2R8f(A1EH+MAnVy{%QdyYjTI?_ATOToWdr}SO%AW znYF4|4JqV|s*nnC(~rj-#(^`VBwEny#4I_)ezQp=CX=O5u}!)DN>@dFLX$7L=V@N$ zqUe-V%Tix>jm|Ko@;NWiFBfH8z#UQ=0TfeEng$>CJn@9emHslou#x07Aw z@rVvRscqglD;_s0yCpb`HSQ-aDBysI#l@Xz3cvVoK%g`WSEAIh7qfc{d=5#a#Yfwm zhcgQXV`95y;7O5qgmRJ&drKgnm`F}B0v1dKdi8L@&3M?k(KW(R(`|BRt@2cSEzUzA z6m>KikGn`-ZQh@`J+3@E;^#}lblUc&jJhdh5hUX^k?H8Ju5a4qq`jTBgGP8NaK=Ik z$GFi@7o#%qR814r+!swb)J^s6qWq^oMFju_%Tzo6H0@=Op`?45m`m-6vljlwk#GDa z>>$b8BLjV_43+oVJ79~P;(i>UdF)#C93G~wzvkSbiyN&nfD!Wdp@@@+Bn0`Zn|_V@ z$uGmo^#_X@e~
    %rFwWE=~PCpp*3p06}df+mICPtT=*o7SIa#JPzJKjjjiLd}%} zmAp2IIZwi8YC%-Mi;>YODB$OSCR$}~zpq4rld+ux3DL;L!^JcbmJz>u0wBWid4H~X zaiyg#p?;!#&SuOCF{Cx16n_5q%9&2JOUA3ioneAz!$m+jUEfN`Z?05_5i9(+x5IP< z7#3ksRuf9zJ!3NqAaF3NxJ~CkGC6?sg}GK3gvcP!)KU_PXv~N6rE>d+Pz@r216pNP?h8vLY@OxwOBy$bYY|6{eNgk;1l}|AAD} za!!M^;BPdcJcIYcRN1Rao6txE_h`3f%L7(9rhWUqYm0)x@T#O@ot1Rd*OV1*leEU! zuOB>jigW55H&FpBTwhUYSwN1qo4*{PeZ)=xyu#Su@#y5q&O;H9*oRYU-tf@D>YXBo z+~fT{CV&}{F`AbBZ!L4$yjnTL5mspjrlWEWQtE(D?=SX%5g2W9bhQU1rCxdJV) z{z9tP=Kv}4%~Wd1Ud^YmfP>IFM%TQ5Bk=oNW17jhW4e^k#tLZ*gJCf+HwJiSfcIv? z-VtN)=*4E~jcY-Guix?8dcTLrQ8X;}1_rrV*Ux{e`i%P!O4E=aR1lP0nd%gsD-1`; z;T`;Vze;gRk~n1-oGZ<6G9) zROSKJ9MIbH+DYT$SPA%}9v&VdUMUANA!AI`RI*#PM`rPxM%B=zT;$d8>vR@$?P@ds zH3vo&rOg1aF=4N(i;D|D)GT11Y4GA=Vy@@99XiO>HFWzYeq?lgwruT~}8ZZ`SPK5x^(8d@BJX zsRWTzLvRD75{?JH$M$AZ08Fr_J;c&+!=T$b zD{s}J@^1GZ(HLu-=3JtJOLmBHXyuY8eqlRg-KK087spFe_cjBGf#}%< z!25I!9t$V)I#NSEB%2SXoL~o;o5T^gdOwTl7E;6$Jy+o?_1u2gz(o`e`?wN>I3jxt zB4ej;0reT3O1!GW?OzM#xe;px9E}SN{d!%dJ43pOSKj&k!;^hQ`*M2TMAD@{CU(Kq zVJ^e3tN12IjEs$@M@IWitAaGHY(Fw>GZAcS#>AaNqzFMJHsUepCHi;NY^l{pS}$%IS53d(U6(3QWqd3iO$he zeynYWQOGPB<8SQ)HyXiVk}Z5RD}q|WwHSQ0ZzZ0^fOzzGC7ULW%jt%M1NAr1z$Y@x zvAh0NWXg$tKDK*myzL#6GSp4w?^DktzXu%QiO04=8l?*XNA0kX#CpSWapocfbF^$~ z7Q7TUb)fWJc5cnm&&Z)L-WNED`;%uw136`L;Af|w%j0MYlOFs}MF!Y`yShSwKw%dv zcn0*veIG~6G&<8zjK^bDqFx+d848hY$K2#wlcbF$Q;kVH|GWD6H@H|=dQ9ReL&hHW z(fUj#1AgBs*q8HeH@kq;g!@M_vi2`W?KmI~4)RXXJfzXW>eH@K=T@=T+(un53gwc+ zJo>h$&cGZykC|9vLLH4usGUNCini(mR!_WxH)u!5Q)+$aPYetOh)4;+#|h=lp7_}|!$k{CxmuYUIhZv{XbBp8dCao@)pMh!_q~v^S8N`SKiI>LZMh-HPO$`o zlHV4;JUI#edAeAoF%-^bBut}pa8%k-V2s;KsUF94ewfP>qq5-2D(Lqle*U2XqFODe ziy10@$2H2|Bla{F4batqRboK^_p`w2)?wv@;XBiGC2!N%)hxFLHpK z|3(%(ZtS70tJ9intE8rlQ(%J&n>z<}uBL(e#-uTA+NbB|Cu}Sj7F@RK@_BpPzvmE* zai_sRm9V`2^zNGOILtc=x6z)mq#do&l*<+B8OF1J3w75zd>$W&^w;G9!xM1{_+-xLecvTPOdpGX^_3{#8P0n0xr zE#)IU8xMyIc-#=1Tj=&QT$ud>*2dJ~=3fd;WGAGMkZuTSaUD&&{CHvZ25c-dF89X^ zLzG!*WV}LE-{ckGoMw;xm4s1Tj~}cESbq4=`mi}uo>Ds@vO3bQc>^G#JtbkX{5&9R z-aFZw5YHiRM&x#oB`p>vYJX~7Lq~Nsx36UxJ8~Sim$|(kgxRO_!Sf6GHWu26`y<*v zXi|54)+02pBCbNlppoyKjAz}`(i;bZz9MIqwT9{>FA`7@+Hbz39N=bWhK#AfDbeyNcE5c$wOWg;Y|ib!p>>%JM!VE?!@ zM~lVOH=pTW15I_R7HrapH{X*w7|l39Ly(%9)9Pq&av3b>2$KU)Ie@?jgw_|Ub>hc{ z2)I!&zl_MfeECldzJ4{RYgH|aZPq6sAgn^{ogLqu<|rfTk^W^kfpW^@%a!nQU_LFx zK_yplN98&qRNYe|IiHa7xrq3vOlzPsszI;!{MyHAAv?zrq;3d_{FX@W^%EZ;TEN+9irW{R6qUQqqzv;c z_e~A6RZzkm&_cnR0C5oYKq3O}5c+>`d!hLTKaXhCdtfVC3IpG_+Loe+i7RIw^2Um2r&v6n2xk zD`8!txKbu>?=jBiuPc@<#^6x_V`0q9%=UwAf&H?m_g+A=()%5XQkUvc{=7_H?tFGo zFG25zbA4zDK8ragkNX3lsE&Q8y-1G`)l_(l^jti{CJ0gu$lYnivcXTA=7nYvO>&dV z&mIwF6Fx+wfPsA)4irb0D%2HUwz1Vb6}|!!1!dH%UaZ@=LaOD%>ZL|=CNivk^B`ig zgQ&y;u4J$1OZME_s-5v^Ar9dYZckQ6Z*)XI0+di`S`r(gm;X4zaU>x;s15}l!b|mg zSnPeidM1$&vX90x9WrOQ`D`<$Q>x+#!ozM5UXjF?FrvnTbv2lL^`)oaB5?NZvaSQXP;lhi~Jl{8jdFf;we&V>To*z9J-R1(~L&go| zgg69S0+B>8@f9N+DQ~=#mnc|8wT^qu3nd+5f#T8Lp;qpu1@dqa&}*&+OJUbQbB zD7+`7mX&8VLf^cIlIslQjOJaLuuXy}NP}&-1iD8?Z|DQop_1TiV>T&*ff3dOby#Rp zX+Cn*as0`5z%C3110|qNj@+$1Pc=VWOFV>bJ83wF+S$R=@X)!o>CLSnxun|bDO?F* zuClt>HNUr~XC-No@`YwZq9>{|nY|m^@SY^ujA3NZp>|Q2`2)>~^-&2yQ@&v1st#-s zCmziDAZiQtJ=cq+I9&N13fR;1W)H}#`Ro0Qp_0t=j~!L>qNEMyDBN=ePtr~t1PE!5 zYaNOW$k0cAfm7TntG?I-?Hdu_pYhzFiIf|h>9mmA!9F2nO*&l|O8GOAzLK=nZlfR#MSufx`o+BD!llB{r5=!(*)Rp5vbV0!> zM0BqEddRZWFCUVt{#Kd2xF-ya^{nKfsJjzfw17mNG!CS9Ux`*q(?aRH#hlj|uWVpF z$2F#qQB#3f3Anvzr8p=&o`NqYD3kf7gu&o`kox3>7`uiQ^|0!`N0{QobHCZp_oXrc z%v+OaVPo}N#Griq5wzG|U8ZT|XW$QjAq>sOQp>qg%z}e;o0S&9?}A(VQy%HYV@U2F@hj5lNS-g!6C#BvUzXF0m4rQ&NY_|mpeJUH8^T%1 zdC?;w-LP{Tx9y6O6@M>s4<0)K*HusGk0F`?tCun{fWZ-Sd!V=4Fud2%xoO0G6K3Xo z*ZbFi;_CyA4@1Xh^62v)+V&?!3wU>-MVTOq93@Ahgn-IQz<~gK#NdVfi!@L=iB@;K z5ef+&&`rF3eM`lV;i$eY3)$1Bl+*Fq6r08(=+)>u=3VOB?0i4!n4qF7!O?Y`3q&Ld zmoC6SMwMOjyo`vLg~>+6oBO_k_=_BoaJD2C_G*vx+q_*76Taz6-DH96Sdp5A)l zBnmy0Q_11g239XPmy~$uo4U%T=Kn)y08L1tvKpd4o}HZmOJv=`kHG%E3|;+Dd~DJI z_CLEeomWkNmELM4x6bO?6gV3C$-LRf_*tbDe9_&6Cqw)pC@^vFFtH{BZh33h?#;Mv zexJxmM_=D4Y=0_*1x#@5lRJi7HAk?hJp@5TOSyB!?@9J-RvbM3!L1^<3O|9$Tv0c| z-oVOfhL1y%e|r)W3yn*{B@*R=dSBdhn4>BZqp-)*!UTx6kT<$l)xNgVPFeAXSQWD- z%lw*6#tFxN^g*kkMmkq;VfRrr)i!fNZ6xZFof7mWu#e8jWnJ>5q}G{4H8dAmr0KWU~1EM#LlOaz&FNcz3xXE^b#R zPLCYZ&Bq5APKq6+pyRuK@ls+Z6$8qZAU|*KV#L^S@ohY9fA^dn*~Kf@Z}6tmk0{r# zg;pCJC>ADN+m*huIZdE~uaES)6zP)PvYV}^>?%BlG%i{qPmLp5CW#mmT4 z|4G34RUq7!zfWGUYde&+Ee>ZD z6!z4$iCc7V+*pKH&svitUa&D=rxS^${t_B=)Ol9+aQI>M^oO}v!e2#%Do@Zyn*mqt z$Ovzs2&UBD&u`ZKS+bMdbe>M6(WFEOsTjK!8xwl)TT0tMva=+`#V!GJKCZauqN(7oQpiN{)HX~|EciT&lffoxkKY%&4z;P2&WysK zc@`B0=wo#wX%l+3lz%s?=nA&1=`PWDoKSChVKCmt9>KellrpHUti0gEx97ERmndA4 z0GIx9GZjb6x>_t#954!8mwn1p__~$)v$(R};}UJjU*80nTY0kS&C=V9e@uC~I0+^^ zFrvOAchlQ8d5Y6SzcCr&Nhe~bq{qq8{=8VbLZJ!K@zBj*>EcZ0DI}iB9)39#KOe3@h8W^D15>3x@GRRnT zaNS7yj6-{h418m=D4X;?R|KJXR zj9HYore$2of`P@oq0o--cvzmKP|i?|Dx5$67i%Px+ZRV0rCAnhE79`AXh~*k!M9V} zDHfWf9{%{_4_=|-hLj3Z2|THANEL#D#sK41YE`Q#Hr}I25E@|QxC1K@Y(_;-Is0T4 zyWI|v<}qA0I;!V*JOW+F0|9>;L1-G?3mBcpkP)7tIQ_BLh5QWJM3~j35T~30&v>E`F#KW z{r2`24aGoAOjzSPW{+0@iW0r~aAk!kg%B_x=*@7#P%l?Z zwNgK&L68!})M~QVRv`H*#VG#N%#drl@eY&PH%_-GqB)E!BO8M=zVPV6o`bi3Wcx`;8tWaNR6V(=IfgP@fZoP%F7p|zf3_n!CLa+nkU-@hX zblFbXPT5k`@Y&N^TzV5mXZ;^9G#{)QO$pYXi<$^*x zf=e`|ba!`$oM{bj(Rw6b)5#=tD)sAEE)6II+)5St5WCey4&1tWDK#wr`TAS73jDx7mKx;knY$kIvvutTPSF(6}dqce{L_)H3=z*%U z*=(Mkp2&lfrwa$~n-oN?3a(s@**PO2I)P&lg#OjsWKx#{Kd7xtQ?1~V9588y+XFX0 zo5_q`k?Z58ER?JJLDklD-MhVlL$V_sexY)qG=^X zouF&NO@>#v@m45BiFQ5k?(pQ$%W*gycriCB6f@S!Bup3Ng0hMrZq5>2C7KL{8#1m0 zE26Gz2!TA**$QdE7s&7ZsWL;mjeP#!PLo-lEf~YbhJ^V68y8?W$b3|m#>U8A#Pja% zZoA#$;({I+J>VXI+n|;%W_ta*Ukh@l(;V$CX(FyxD^}^tuC^4+S`Veei$F^bXTW!G zP((>dzSM|#_w3!6_0}M@ySqCEBZ@7Nhrwn7OZk#`CH1B1a82|y{G76nl_0pq`9`Rd zrE?0##&DR41f!qMyy`Mkpq%r3J|`EiJOh=#C9sSuoxQv%{@r-Fp<>}N5}G%(3qh9% z%uR9^g6W!;`j4Ech^7t}g0a~Q3#*uerE%ajkt)e#NObvzMuboxas!Y;fQr)$bgb8F ztT>YFp!(CurhUbok5T-H(^0l_X=o55D$n`J?m`9Qc-Q%(wsIjE&P%C6pU1{q#;USm z?jfbh!IR+JdrbVKYkf2hww%qzR`4rO39?B}kMCwfv(@SgH`T=amH>S>tbjhEBl zR>Z=Sm*|V*Bu3S?ttzb13xMKfIV6R6B*#v2!w0u0!YHzXVZbw(rJ@le2K-28uhC&Q zIql8kTzM1-GiUz;3qdaGrKmctz`OU-(30-RoHHQP<_T-ZFdW;8g>*=y`ErORUun}dN83NV#jH-KdtNe)fYh;=y;V-$wQ~7%eYnZd# z3?>K`@>ftNG$qDI0G%+`BeOWI*6TIwV>5$y48Hm33F+K&S+InyV>7Aealfes$$7-2 zVDBL~Umzf&#Nc!>vO9Abs$)?%SU&0$qJ4~KDM{(gF_{JADW!yM%nIz4r_?}6BQO*H z)~T!o?hb9o%wRt4_j|k@ zP@Ht~FxLXO5);Hg5DPfSQG|Gl`OmIG;{%>f+F&0I&_EJICKZ-~4Q0W!b5_s6A3Rta z#HyNsgJ;GH7AWJzyM!~>gaHe@s>YJ2Ai=>(d{Gd%wQknzZX%_>b_j0*rl=L*U6$ko zdPQltbBaw&G?);|a$1Mek$j=?NAI`8y-h@?&ZNo#a=z@Ib-oVj_R#rl&W~Vvp4akp zWRcJ?By=VHv6__S`p(m6%xv@Y7-K|`1gi#0MGoZgcx;*mk}$^j>C-1D#&8gUwb$Mm z06Q7GC5u-uIE0oo!`J5?;0n?m!lhYz(E!bTxxT)JmYzNKub;CwT_uTTcs!j>7~b98 z-DEQP^yw2aD3A|{%y+W<>{>2h(!^zvtx!4PP9Qm%C&Xeh3%?>%P_-nvSnKr~3j`C3 zVfB@FInY%IQY2g{_UwILdGqJ&7-rr=E9>%JgpsVhJQ!mT-~<~lnU77@wk=|)BvU*K za-i~l7DaHN9riO@J9Iv~Thrb0S|`5} z74uBK0B;^MNV7zIv->21 z(=Ta`za@bpERw}j#G!Azyis&GS{QbZ)KO(IE~C42DXGorwJ@rb4+Sq>Xr9x!TCJ#S z2JwIAKdr!uzrtTc#sdisULFM;m4^if$eGOTex?hC5Y7ts;;O6aTs2xI5r?8g^H4srW#H2gq-7D1w95F`3M&N;^^7Hicgko}EzkdDj;RA~Mf`y@iIo^(|pFEA-hRLN= z0`=YVP-{|3Gm(nsMTi+lA-4@7XkluqEg(W_|J?IQGHfrEp&0laQ-v;IaAopt;31OV^g>}_*Qnm6Gsw9DFh@|<}PBU zQ1q&R7LHd4pJ6J5p?OEi7Q{79-B+yzYKcJ(^IVYHyH|w64yCWEP||o9{aYN#JMb|Y zPgIYZHhR5?;WzVykT(WK_a8}0?Snsq6^wJNfpq^;(b~#XHD&BymiA6MabI%6AwI6 zbj**vSA&2q3qb1g2+Pjd3t|ex%+L$gQjo^XkSxF1Y=|%D7B{(WF4Qb?pm2|>ZNLu` z`CcFP^=VIs37uWYd~e(K;o;%upMS2Z>TozKW9)y8E}cw+NT= zhD^(1)&M8A=lE|+(s(Hn7Fh1Lot~3FjXHHuh;8~7pP$T zI?5>JPU*?XlC#2^3-B94M1nUcNttWXm_!`oh#(j(r;>xNye<;+dN_9!K_+F~X-OBT zAw6i+qC&Fr7;`U;dG<5!ufJV+=CzLh{{G&aHAJtDijCcS*)PV7s$s!X-fiV+mMDe? zj8oTjlkIazU69N#QuTNz;EjVD2`C|&lBH~xE!RyLwUg22vz6C)MB21=!pkn2IAaQu zGpRd93X+oPJHH#6K$%Du z-Y5-=_N=@&s7W9!gm80nb9HqEGGY;~?2?z(I*GpZ#NwuD-rn9Al;u&d@Iry1DX)72 zYM3U|kkAnp;Vidpi&REfgmmv{R74*KS=W2l+u)Jo;D|9Bh8;{?$>yL##vCcc%; z5eUtQYrobz=53c9A!Ul_g@;6pGI;~4)Y?1XHmX4cQI4$HQm&S|1R98rqmHmr3A zQh_h%#`_Uwx6ml5Aj6~A?&h-6jSiWh_GB38+qbv3^ZA^SQ)OqxuV_U;{rBJ1)32^v6Mi41_6eoQ&CLyv9`qW4$)%JY9v-j_piKh6jkM7q&85dOXCvvt zl^2cCE3@c)=8e$E1`dbAY&MG{*hO|TAP6L#3lJsUj1#(CF4ezQRTUCMsPk|zTo`N> zGyC}QqaxI^D7zdFG+ll*VSDr(0Eh+_^WXpe_m^LOL4~&Mc6hO{RIDH24Z6{}zB=x0C@mR{?z-;j=?P_Yk&Xzy2Hw&4J+gsouMEli1UJUk z(p086*kqE=RU%Z!BTYUIl5>ml!{mKff+S^ru*xLz&1Q380TVZ+T>J~mUO*5a*kpl9| zFTZfc+P2NR*ZJW!W&1%;#763#4BPhrF2jYVW_GA&085i3k|&;nhCY7$`1bbJG|h6k z#1g}OjLKQ?8Zl5n9S`rqrl>ab0%HQPXH+#Zuj7KZW+t7AZrlnJ&i#`;Hpgpdf0{&s zaX$R|>#q<}443VAIoqFxFV~t4aX94`>udUctQI{TyS2LR>lU-F$~=xPC2VLezhi z#WF5j5tvL=XE+=VNVPfhsk%8eb97lXkpRK9pkQXfa`qs=k6opRF(GT2l*DegLjev@$acF$qzr4Vkr{kY^`Vt`Wg6D*R*8{4$b~;4x0oA1MFGJw zO36`@d&z2b-Ctf_Di*imZwT##zXi1(OiKEg%Ln{qH3V;(*B%T~yaloXFM~p`Z*Om4 zs|Ei9{siO0#!wR17Z|PJ#T|s0tCH9FR}^&oy74MnD~D=>b1U_8Y?NAb1K?TosBof? zPQ{f_21b}FB@$6zbOdLQ(x5s6JhM4xYbM95TQi$@Pnok~lh2`FuW`&8E{SO9yDh5nWc=as<49oks^j*w7JzA3~A` zj~#*V)WSA5qJkH}rZw%kLb7M`&=bN};9C=jiZe{SG_%wE`q<2x>g?p{J=|;D29ih+ zTt4KH1i+u_aH*)(P3CE~;6gx9BaQC*`WmI)q2gw*eFnQ3kz;59AaQs|b#*r0T~`bK z;bmW6U$FtW;H6C$Mq^_RF`umNgjl~<4@85I(S^D;g;CUpKuZxY^Xhe1BHlJ+}m z$)RSZKUFmy+l@3m;7`oG!#I;X8PYs_mOSf)58|-t#@4$P@wFt{P8N3%sXXU%orr;P z?8;JcoOe;SK3g{HC-bhE(Kl7sHIQ)dM{x;Y1`vG++a1^noYX)NC!Y)`4|4<1O$?Jj zrHGNA_^_sqhBi;8Gqka^fHW#!!{vK&boa%8Wr5r#+@ zGkbb^0=DU9*b+H=bsB!vZ8_%pWnm$Nu7pb(;h3&MgarCdvu=v?-e#DQHwZ71767aA)bTF9(kLD=rM;JkEQ;WA-b}Q4m6<3+ zfD1O84Zu_d87Lclr!J7{8_@y1e58poz42am5EN@qYhWl1`=_d^<#GwLrr`a-m0c4h zfs{c4RmNN#O8x4mvQf>`JVZ@%%6LJL2!oBX4me=0Xwx*nKN3!(yM@~iG=W?N44`!! z1U@6JB)CU`B9|#_jM2jTd5#bEgHjRZU131GL`hV^8bXFfO(tz51k_GA?~}SoDP!=h zL=rK!&!0cD8Vcjboa##Q0igR)HwJmo>!lps+^J9I!eb)|x&`rYK}jk_n7I-(F1Kj- z_rL!g#Rp%g=~xv6af_X&bHVfG8oa7{98ll)-@bi=*-&5#z~0o~&!WnB4Gq)})=zl3 z>TktY1au1H3CCu$K|m*n`?(l7A7sJ>m^{SW65`5>UtV6WuCCZDno-`3G_>UU(K`V( z161|&^rZDhK^%SG&t|hcTb!Di(k`uGrDKz5m)ZM)VVx0vPDI`H-SqH^X+#}GmV`j@ ztk>69bj4I{Rt*?sVY}T9nS%wfsePZkD7UhIJ;-xcs}%|h6s$cD0lCy-bjj+vJ|kd3 zwQ(4*Bz+{$4CLMMJZQ1&^%|O%4J-5LYvaSq%gg7_pKauz)wl`wE4ZD$*x1z-+}5!X zL|HR{Ik{u%HYV_YvzDC67$bX8263V4qhVXqca+`#t8c1azXF)0x5{245%U1N z4QtI}V_aT| z@q|GrPRXm4QQOf20@L2pcyZvbK>OZ!R|kEljrbP^fOtRcneeZF{p%bNa3WB&1|FZi z8j3muI1W}^D}tKfaEKt1;`_e;{{1_@QkQ_jou$EG@eYu)*m8do-YU+Jw(yh9Fz208 zB8-+cb z;O>;zMTQBs2a%x=7hzaS%gj)w8$U_I&c2*pXeAV?te6HZC^*)uSagjshY-#iz=SKS z(4po)WSKk=X9yup=cfsBQ_lYWFtTg;Au}>ycFHyv!+Fg3hx2KbS+jfDuAj4` zU9j}Mx9fzT-U(f4i|us{yUyNcvL6dp*rsAXUyKi3P#l9 zZtgF_dau0TikZO!Xd}5qGp^WXTseFI%ors&b)S&%Qvwp{gow06mhKW$VrA_aeUhdPd8Ias%L~#4tGTlfJ8uyE-R2UI6gSIe+=` z1xnG^uU{97#qoHAq+;+-Ht_B3Eh2isD|OP}r8}zigS~PY5$7;4S2V6nq&aQ~?@-e;$R!BFbaHcs zhfRXdp!-CG>cX2HxD{HL@Yg@NO7ZCHU140wSjH0HT4Ju5J(uxKM1~)pLq7?`{}9qQlWl zylkut9vwu*i>a7_`njKj=j@sraQq?HTMd=uEhNAK=={N*@hs4NRaM}Z1@!}TnH_Up zIV}EgI3Sj!`~tZw=gCPOY~ns&!ov&;TrTskon6Ys*~leoZZ>4II@qadAOmBJ(OY^# zT7LZa5w#&Sd0MwY5S(B1{9webxM8B;3}~2{<3Q;%#nVk(l=umw2L&0dJna!ln_05b z&x#T5%Tl##>G`#hc5al>%;4sj7!kvM(4WKL0K28RbDr*;N&S@g|C6l$=LI$jWAQ7C zNQ3>v7*YR6Cyw)#9iGtlJ%~6tLfBlkw! zrgr}ID0D;eU`b%kJgdWV^K++>NI-ZSDTV*8G8~47dGTW~8aNfgKmYm9ELLXWIpK#W z1t6lAsLoPuBzgslwrw9DA3uEffI(o-y*tY6b~<&$V!r-F6ug_k!@(DNB(-qrh;-!J zFqt?@poB|;U9EbgJ~T-WKYcY5Ql_rRapHzhZ1mCmm2Pr~6yPg!&gkOEb*-fri<#xR zIZb96z46Q}*eP9@dSMm^6bv=1(PQR4Q+VJY3T9@rw-_DD3X>NgfIt5DW53^HZ_q7M zn0Co~5oj#PL~;fxziBR(3)j37D$A>x8MI1fSrj+dbzxfU#p$jNN6KPl$!c@tq23(4 z&%*8P?aRvx8jLc(#N6D;p1x2GLt4c^;}FF6?lBf~?>hB)TN@g_<|y;l696#p7%;Xm z*h~oF=H>=$NuOoTfI-$U%cA7zKR-Wjyjm8$7SuB_$nAEk*a0${Y8XSsiomQyd46si zPAremcaxJen$P^l%mzCFjK2zJ{M;$Ot?lD z9S(=qyBMsbNCfE;Pt(g^|`#e9ptDMR9QC*&`sTAn6ZvfAUQ95O^_+AIZE&A*RKrT6^$xZs}(y26|+i4!@suD?LjZI zkvc>U2;>-12!$2^A!Dw)Vj(WQi}6UxDf+n!Ld>23iWh5|hU<;es(2XE-j#Y(df-jE zu3Jmh{~#sGel+=dL~$nyc%4ex?nM))?4f>Y8ew>V;95zWPL6@&tC4pg zVvqY2K!BCxzjL-BH_VGFBk0q5${(Vqr_%|bS_p@=w~Ng=qf`l~uXJWTdm%T@<# zgreyfx9g`Y<;dXzs=Nk0Di?*JS2qpwV@#+S2N70e5QQI4*_rxm zxF~un{v>+>AlHSSEBssOlSu1h6O&8G%L-Oq*Y$Kd#ee|ipU>y5=hK*z5t9kCn>% zm6iY>4ZDcz!L;$TZB8bB8HqaBv-%3>sNxaP!K z*EK4E@ft$^l-KGHi~TU(9>tkBY;+KY2?Gjbf|jBebCKS)L!O_XDP&A|IVO#FNZu(G z1~lR!1st0DT-70toGZuPGv?y^>FH@Un=O~ix~^YeUy+ppQGiDJ7Ik!6Pa`mvNbNw!^TA zOY7x*z-~z8V@lw>ML|Zay?Z&!rrXe@gmlgDy#4~zXj za6GW6Q?@Rj!)LXqL)ZqjZ-BS&->T!D+a7fG^z?+. +# +######################################################################### +import os +import logging +from unittest.mock import patch + +from pixelmatch.contrib.PIL import pixelmatch +from PIL import Image + +from django.test import override_settings + +from geonode.tests.base import GeoNodeBaseTestSupport +from geonode.thumbs.background import GenericWMTSBackground + +logger = logging.getLogger(__name__) + +WMTS_TILEMATRIX_LEVELS = [ + { + "zoom": 0, + "bounds": [-20037508.342787, -60112525.02833891, 20037508.342775952, 20037508.342787], + "scaledenominator": 559082264.028501, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 156543.0339279803, + "tilespanx": 40075016.68556295, + "tilespany": 40075016.68556295, + }, + { + "zoom": 1, + "bounds": [-20037508.342787, -40075016.68555735, 20037508.3427759, 20037508.342787], + "scaledenominator": 279541132.01425016, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 78271.51696399004, + "tilespanx": 20037508.34278145, + "tilespany": 20037508.34278145, + }, + { + "zoom": 2, + "bounds": [-20037508.342787, -40075016.685557604, 20037508.342776064, 20037508.342787], + "scaledenominator": 139770566.00712565, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 39135.75848199518, + "tilespanx": 10018754.171390766, + "tilespany": 10018754.171390766, + }, + { + "zoom": 3, + "bounds": [-20037508.342787, -35065639.599861786, 20037508.34277575, 20037508.342787], + "scaledenominator": 69885283.00356229, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 19567.879240997438, + "tilespanx": 5009377.085695344, + "tilespany": 5009377.085695344, + }, + { + "zoom": 4, + "bounds": [-20037508.342787, -32560951.057014164, 20037508.34277579, 20037508.342787], + "scaledenominator": 34942641.50178117, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 9783.939620498728, + "tilespanx": 2504688.5428476743, + "tilespany": 2504688.5428476743, + }, + { + "zoom": 5, + "bounds": [-20037508.342787, -31308606.785590325, 20037508.34277579, 20037508.342787], + "scaledenominator": 17471320.750890587, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 4891.969810249364, + "tilespanx": 1252344.2714238372, + "tilespany": 1252344.2714238372, + }, + { + "zoom": 6, + "bounds": [-20037508.342787, -30682434.6498784, 20037508.34277579, 20037508.342787], + "scaledenominator": 8735660.375445293, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 2445.984905124682, + "tilespanx": 626172.1357119186, + "tilespany": 626172.1357119186, + }, + { + "zoom": 7, + "bounds": [-20037508.342787, -30369348.58202224, 20037508.342775624, 20037508.342787], + "scaledenominator": 4367830.187722629, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 1222.992452562336, + "tilespanx": 313086.067855958, + "tilespany": 313086.067855958, + }, + { + "zoom": 8, + "bounds": [-20037508.342787, -30369348.58203337, 20037508.342784476, 20037508.342787], + "scaledenominator": 2183915.093861797, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 611.496226281303, + "tilespanx": 156543.03392801358, + "tilespany": 156543.03392801358, + }, + { + "zoom": 9, + "bounds": [-20037508.342787, -30291077.06504764, 20037508.342767175, 20037508.342787], + "scaledenominator": 1091957.546930427, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 305.74811314051954, + "tilespanx": 78271.516963973, + "tilespany": 78271.516963973, + }, + { + "zoom": 10, + "bounds": [-20037508.342787, -30251941.306609083, 20037508.342801783, 20037508.342787], + "scaledenominator": 545978.773465685, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 152.8740565703918, + "tilespanx": 39135.7584820203, + "tilespany": 39135.7584820203, + }, + { + "zoom": 11, + "bounds": [-20037508.342787, -30251941.30652203, 20037508.34273241, 20037508.342787], + "scaledenominator": 272989.38673236995, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 76.43702828506358, + "tilespanx": 19567.879240976275, + "tilespany": 19567.879240976275, + }, + { + "zoom": 12, + "bounds": [-20037508.342787, -30242157.366901536, 20037508.34273241, 20037508.342787], + "scaledenominator": 136494.69336618498, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 38.21851414253179, + "tilespanx": 9783.939620488138, + "tilespany": 9783.939620488138, + }, + { + "zoom": 13, + "bounds": [-20037508.342787, -30242157.366901536, 20037508.34273241, 20037508.342787], + "scaledenominator": 68247.34668309249, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 19.109257071265894, + "tilespanx": 4891.969810244069, + "tilespany": 4891.969810244069, + }, + { + "zoom": 14, + "bounds": [-20037508.342787, -30242157.366901536, 20037508.34273241, 20037508.342787], + "scaledenominator": 34123.673341546244, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 9.554628535632947, + "tilespanx": 2445.9849051220344, + "tilespany": 2445.9849051220344, + }, + { + "zoom": 15, + "bounds": [-20037508.342787, -30242157.3682939, 20037508.343842182, 20037508.342787], + "scaledenominator": 17061.836671245605, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 4.777314267948769, + "tilespanx": 1222.9924525948848, + "tilespany": 1222.9924525948848, + }, + { + "zoom": 16, + "bounds": [-20037508.342787, -30241545.8720675, 20037508.3438421, 20037508.342787], + "scaledenominator": 8530.918335622784, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 2.3886571339743794, + "tilespanx": 611.4962262974411, + "tilespany": 611.4962262974411, + }, + { + "zoom": 17, + "bounds": [-20037508.342787, -30241240.11838522, 20037508.339403186, 20037508.342787], + "scaledenominator": 4265.459167338928, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 1.1943285668548997, + "tilespanx": 305.74811311485433, + "tilespany": 305.74811311485433, + }, + { + "zoom": 18, + "bounds": [-20037508.342787, -30241087.25546706, 20037508.34828115, 20037508.342787], + "scaledenominator": 2132.7295841419354, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 0.5971642835597418, + "tilespanx": 152.8740565912939, + "tilespany": 152.8740565912939, + }, + { + "zoom": 19, + "bounds": [-20037508.342787, -30241010.79616208, 20037508.330525283, 20037508.342787], + "scaledenominator": 1066.364791598498, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 0.2985821416475794, + "tilespanx": 76.43702826178033, + "tilespany": 76.43702826178033, + }, + { + "zoom": 20, + "bounds": [-20037508.342787, -30240972.57764789, 20037508.330525238, 20037508.342787], + "scaledenominator": 533.1823957992484, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 0.14929107082378953, + "tilespanx": 38.21851413089012, + "tilespany": 38.21851413089012, + }, + { + "zoom": 21, + "bounds": [-20037508.342787, -30240972.57764789, 20037508.330525238, 20037508.342787], + "scaledenominator": 266.5911978996242, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 0.07464553541189477, + "tilespanx": 19.10925706544506, + "tilespany": 19.10925706544506, + }, + { + "zoom": 22, + "bounds": [-20037508.342787, -30240972.57764789, 20037508.330525238, 20037508.342787], + "scaledenominator": 133.2955989498121, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 0.037322767705947384, + "tilespanx": 9.55462853272253, + "tilespany": 9.55462853272253, + }, + { + "zoom": 23, + "bounds": [-20037508.342787, -30240972.57764789, 20037508.330525238, 20037508.342787], + "scaledenominator": 66.64779947490605, + "tilewidth": 256, + "tileheight": 256, + "pixelspan": 0.018661383852973692, + "tilespanx": 4.777314266361265, + "tilespany": 4.777314266361265, + }, +] + +THUMBNAIL_BACKGROUND = { + "class": "geonode.thumbs.background.GenericWMTSBackground", + "options": { + "url": "myserver.com/WMTS", + "layer": "Hosted_basemap_inforac_3857", + "style": "default", + "tilematrixset": "default028mm", + "minscaledenominator": 272989.38673236995, + }, +} + +EXPECTED_RESULTS_DIR = "geonode/thumbs/tests/expected_results/" + +base_request_url = "https://myserver.com/WMTS?&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer=Hosted_basemap_inforac_3857&style=default&tilematrixset=default028mm&" +mocked_requests = { + base_request_url + "TileMatrix=4&TileRow=5&TileCol=7": f"{EXPECTED_RESULTS_DIR}/tiles/wmts_7_5_4.png", + base_request_url + "TileMatrix=4&TileRow=6&TileCol=7": f"{EXPECTED_RESULTS_DIR}/tiles/wmts_7_6_4.png", + base_request_url + "TileMatrix=4&TileRow=5&TileCol=8": f"{EXPECTED_RESULTS_DIR}/tiles/wmts_8_5_4.png", + base_request_url + "TileMatrix=4&TileRow=6&TileCol=8": f"{EXPECTED_RESULTS_DIR}/tiles/wmts_8_6_4.png", + base_request_url + "TileMatrix=4&TileRow=5&TileCol=9": f"{EXPECTED_RESULTS_DIR}/tiles/wmts_9_5_4.png", + base_request_url + "TileMatrix=4&TileRow=6&TileCol=9": f"{EXPECTED_RESULTS_DIR}/tiles/wmts_9_6_4.png", +} + + +class Response: + def __init__(self, status_code=200, content=None) -> None: + self.status_code = status_code + self.content = content + + +def get_mock(*args): + file_path = mocked_requests.get(args[0]) + if file_path and os.path.exists(file_path): + with open(file_path, "rb") as fin: + return Response(200, fin.read()) + + +class GeoNodeThumbnailWMTSBackground(GeoNodeBaseTestSupport): + @classmethod + def setUpClass(cls): + super().setUpClass() + + @override_settings(THUMBNAIL_BACKGROUND=THUMBNAIL_BACKGROUND) + @patch("geonode.thumbs.background.WMTS_TILEMATRIXSET_LEVELS", WMTS_TILEMATRIX_LEVELS) + def test_get_target_pixelspan(self, *args): + bbox = [-757689.8225283397, 4231175.960993547, 3557041.3914652625, 5957068.446590988] + expected_target_pixelspan = 8629.462427987204 + background = GenericWMTSBackground(thumbnail_width=500, thumbnail_height=200) + target_pixelspan = background.get_target_pixelspan(bbox) + self.assertAlmostEqual(expected_target_pixelspan, target_pixelspan) + + @override_settings(THUMBNAIL_BACKGROUND=THUMBNAIL_BACKGROUND) + @patch("geonode.thumbs.background.WMTS_TILEMATRIXSET_LEVELS", WMTS_TILEMATRIX_LEVELS) + def test_get_level_for_targetpixelspan(self, *args): + target_pixelspan = 8629.462427987204 + background = GenericWMTSBackground(thumbnail_width=500, thumbnail_height=200) + level = background.get_level_for_targetpixelspan(target_pixelspan) + self.assertDictEqual(level, WMTS_TILEMATRIX_LEVELS[4]) + + @override_settings(THUMBNAIL_BACKGROUND=THUMBNAIL_BACKGROUND) + @patch("geonode.thumbs.background.WMTS_TILEMATRIXSET_LEVELS", WMTS_TILEMATRIX_LEVELS) + def test_get_tiles_coords(self, *args): + bbox = [-757689.8225283397, 4231175.960993547, 3557041.3914652625, 5957068.446590988] + level = WMTS_TILEMATRIX_LEVELS[4] + expected_tile_rowcols = [[7, 5], [7, 6], [8, 5], [8, 6], [9, 5], [9, 6]] + background = GenericWMTSBackground(thumbnail_width=500, thumbnail_height=200) + tile_rowcols = background.get_tiles_coords(level, bbox) + self.assertListEqual(expected_tile_rowcols, tile_rowcols) + + @override_settings(THUMBNAIL_BACKGROUND=THUMBNAIL_BACKGROUND) + @patch("geonode.thumbs.background.WMTS_TILEMATRIXSET_LEVELS", WMTS_TILEMATRIX_LEVELS) + def test_build_request(self, *args): + expected_imgurl = "https://myserver.com/WMTS?&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png& \ + layer=Hosted_basemap_inforac_3857&style=default&tilematrixset=default028mm&TileMatrix=4&TileRow=5&TileCol=7" + background = GenericWMTSBackground(thumbnail_width=500, thumbnail_height=200) + imgurl = background.build_request((7, 5, 4)) + self.assertEqual(expected_imgurl, imgurl) + + @override_settings(THUMBNAIL_BACKGROUND=THUMBNAIL_BACKGROUND) + @patch("geonode.thumbs.background.WMTS_TILEMATRIXSET_LEVELS", WMTS_TILEMATRIX_LEVELS) + @patch("geonode.thumbs.background.requests.get", get_mock) + def test_tile_request(self, *args): + bbox = [-757689.8225283397, 3557041.3914652625, 4231175.960993547, 5957068.446590988, "EPSG:3857"] + background = GenericWMTSBackground(thumbnail_width=500, thumbnail_height=200) + image = background.fetch(bbox) + expected_image = Image.open(f"{EXPECTED_RESULTS_DIR}/tiles/background.png") + diff = Image.new("RGB", image.size) + + mismatch = pixelmatch(image, expected_image, diff) + if mismatch >= expected_image.size[0] * expected_image.size[1] * 0.01: + logger.warn("Mismatch, it was not possible to bump the bg!") + else: + self.assertTrue( + mismatch < expected_image.size[0] * expected_image.size[1] * 0.01, + "Expected test and pre-generated backgrounds to differ up to 1%", + ) From 8fbad2c0a4f164c61bed89eb52f12a393cdd6757 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Oct 2023 14:14:33 +0200 Subject: [PATCH 256/330] Bump ipython from 8.15.0 to 8.16.1 (#11561) Bumps [ipython](https://github.com/ipython/ipython) from 8.15.0 to 8.16.1. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/commits) --- updated-dependencies: - dependency-name: ipython dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 10f3976e521..399f9b596af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -145,7 +145,7 @@ pycountry # production uWSGI==2.0.22 gunicorn==21.2.0 -ipython==8.15.0 +ipython==8.16.1 docker==6.1.3 invoke==2.2.0 From dd66c76518ca4d096f3b4fe8dd7125f02080b928 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Oct 2023 14:15:02 +0200 Subject: [PATCH 257/330] Bump psycopg2 from 2.9.7 to 2.9.9 (#11562) Bumps [psycopg2](https://github.com/psycopg/psycopg2) from 2.9.7 to 2.9.9. - [Changelog](https://github.com/psycopg/psycopg2/blob/master/NEWS) - [Commits](https://github.com/psycopg/psycopg2/compare/2.9.7...2.9.9) --- updated-dependencies: - dependency-name: psycopg2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Giovanni Allegri --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 399f9b596af..96d9277889b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # native dependencies Pillow==10.0.1 lxml==4.9.3 -psycopg2==2.9.8 +psycopg2==2.9.9 Django==3.2.21 # Other From 38c2c3942f77d344e75867455ad594325a582b09 Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Thu, 5 Oct 2023 14:16:49 +0200 Subject: [PATCH 258/330] fix: requirements.txt to reduce vulnerabilities (#11558) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-URLLIB3-5926907 Co-authored-by: snyk-bot Co-authored-by: Giovanni Allegri --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 96d9277889b..55007ddc19b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ beautifulsoup4==4.12.2 httplib2<0.22.1 hyperlink==21.0.0 idna>=2.5,<3.5 -urllib3==1.26.15 +urllib3==1.26.17 Paver==1.3.4 python-slugify==8.0.1 decorator==5.1.1 From 24d34cde04d31211c9109d673787792b34bcdd79 Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Thu, 5 Oct 2023 14:21:30 +0200 Subject: [PATCH 259/330] [Snyk] Security upgrade django from 3.2.21 to 3.2.22 (#11563) * fix: requirements.txt to reduce vulnerabilities The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-DJANGO-5932095 * - Align setup.cfg to requirements.txt * upgrade spsycopg2 etup.cfg * - Align setup.cfg to requirements.txt --------- Co-authored-by: snyk-bot Co-authored-by: Giovanni Allegri --- requirements.txt | 2 +- setup.cfg | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index 55007ddc19b..39864fb3294 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ Pillow==10.0.1 lxml==4.9.3 psycopg2==2.9.9 -Django==3.2.21 +Django==3.2.22 # Other amqp==5.1.1 diff --git a/setup.cfg b/setup.cfg index 2b22c939e0f..3c2147edf46 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,8 +27,8 @@ install_requires = # native dependencies Pillow==10.0.1 lxml==4.9.3 - psycopg2==2.9.7 - Django==3.2.21 + psycopg2==2.9.9 + Django==3.2.22 # Other amqp==5.1.1 @@ -36,7 +36,7 @@ install_requires = httplib2<0.22.1 hyperlink==21.0.0 idna>=2.5,<3.5 - urllib3==1.26.15 + urllib3==1.26.17 Paver==1.3.4 python-slugify==8.0.1 decorator==5.1.1 @@ -134,7 +134,7 @@ install_requires = django-bootstrap3-datetimepicker-2==2.8.3 # storage manager dependencies - django-storages==1.14 + django-storages==1.14.1 dropbox==11.36.2 google-cloud-storage==2.11.0 google-cloud-core==2.3.3 @@ -170,7 +170,7 @@ install_requires = # production uWSGI==2.0.22 gunicorn==21.2.0 - ipython==8.15.0 + ipython==8.16.1 docker==6.1.3 invoke==2.2.0 @@ -195,7 +195,7 @@ install_requires = webdriver_manager==4.0.1 # Security and audit - mistune==3.0.1 + mistune==3.0.2 protobuf==3.20.3 mako==1.2.4 paramiko==3.3.1 # not directly required, fixes Blowfish deprecation warning From 0f26dd32cfbd550c0df967652910dda5f1e3123a Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Thu, 5 Oct 2023 14:26:36 +0200 Subject: [PATCH 260/330] adding hostname to celery state db (#11557) Co-authored-by: Giovanni Allegri --- celery-cmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/celery-cmd b/celery-cmd index a1c7fbd98fc..a600163fc77 100644 --- a/celery-cmd +++ b/celery-cmd @@ -3,7 +3,7 @@ # Luca Pasquali CELERY_BIN=${CELERY_BIN:-"$(which celery||echo celery)"} CELERY_APP=${CELERY_APP:-"geonode.celery_app:app"} -CELERY__STATE_DB=${CELERY__STATE_DB:-"/mnt/volumes/statics/worker.state"} +CELERY__STATE_DB=${CELERY__STATE_DB:-"/mnt/volumes/statics/worker@%h.state"} # expressed in KB CELERY__MAX_MEMORY_PER_CHILD=${CELERY__MAX_MEMORY_PER_CHILD:-"200000"} CELERY__AUTOSCALE_VALUES=${CELERY__AUTOSCALE_VALUES:-"15,10"} From 10c39c577065abb371258d146a1386bdbe9e9c41 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 5 Oct 2023 16:25:37 +0200 Subject: [PATCH 261/330] Fix requires bucket name for AWS tests (#11564) * Fix requires bucket name for AWS tests * Fix requires bucket name for AWS tests --------- Co-authored-by: afabiani --- geonode/storage/tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/geonode/storage/tests.py b/geonode/storage/tests.py index 89eea61d4ad..48d1f3ebfe2 100644 --- a/geonode/storage/tests.py +++ b/geonode/storage/tests.py @@ -197,6 +197,7 @@ def test_google_size(self, gcs): gcs.assert_called_once_with("name") +@override_settings(AWS_STORAGE_BUCKET_NAME="my-bucket-name") class TestAwsStorageManager(SimpleTestCase): def setUp(self): self.sut = AwsStorageManager From 8913d8e653819704984f5bf3dd9076e151322a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Wallschl=C3=A4ger?= Date: Fri, 6 Oct 2023 12:35:21 +0200 Subject: [PATCH 262/330] issue#11566_pre-commit_hook_black_isnt_working_on_Ubuntu_22_04_3 (#11567) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4527a569c31..f6a72993d13 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,6 +9,6 @@ repos: - id: check-yaml - id: check-added-large-files - repo: https://github.com/psf/black - rev: 19.10b0 + rev: 23.9.1 hooks: - id: black From e0c62a853279129f37ee8549427e40f3df2d6413 Mon Sep 17 00:00:00 2001 From: Snyk bot Date: Thu, 11 May 2023 15:21:56 +0200 Subject: [PATCH 263/330] fix: requirements.txt to reduce vulnerabilities (#11029) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-DJANGO-5496950 Co-authored-by: Alessio Fabiani From 80580b0ced4033db73293a7557da79cde5727156 Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Tue, 19 Sep 2023 17:23:17 +0200 Subject: [PATCH 264/330] Fix target image for docker README --- .github/workflows/52n-dockerhub-description.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/52n-dockerhub-description.yaml b/.github/workflows/52n-dockerhub-description.yaml index 1bd1b5f84f6..6d9a0352a54 100644 --- a/.github/workflows/52n-dockerhub-description.yaml +++ b/.github/workflows/52n-dockerhub-description.yaml @@ -17,7 +17,7 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: 52north/geonode + repository: 52north/geonode_thuenen short-description: "Geospatial content management system" readme-filepath: ./README_thuenen.md enable-url-completion: true \ No newline at end of file From 104270d020ca9f72dce65a1c51e4dc4111845ec8 Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Wed, 20 Sep 2023 14:46:18 +0200 Subject: [PATCH 265/330] Remove example test Test example is available still from the PR https://github.com/GeoNode/geonode/pull/10842 --- geonode/storage/tests.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/geonode/storage/tests.py b/geonode/storage/tests.py index 3d496dba6f7..89eea61d4ad 100644 --- a/geonode/storage/tests.py +++ b/geonode/storage/tests.py @@ -608,30 +608,3 @@ def test_zip_file_should_correctly_recognize_main_extension_with_shp(self): self.assertIsNotNone(storage_manager.data_retriever.temporary_folder) _files = storage_manager.get_retrieved_paths() self.assertTrue("single_point.shp" in _files.get("base_file")) - - def test_zip_file_should_correctly_relate_mixed_content(self): - zip_file = os.path.join(f"{self.project_root}", "tests/data/data_collection.zip") - storage_manager = self.sut( - remote_files={"base_file": zip_file} - ) - storage_manager.clone_remote_files() - self.assertIsNotNone(storage_manager.data_retriever.temporary_folder) - _files = storage_manager.get_retrieved_paths() - base_file = _files.get("base_file") - - def strip_extension(filename): - return filename.split(".")[0] if filename else filename - - base_name = strip_extension(base_file) - available_files = [ - _files.get("shp_file"), - _files.get("prj_file"), - _files.get("shx_file"), - _files.get("dbf_file") - ] - - def assert_same_base_name(base_name, file_name): - self.assertEqual(base_name, strip_extension(file_name)) - - for file in available_files: - assert_same_base_name(base_name, file) From f661f59f54d63eeb66bc049a48e1cc80819f8ded Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Wed, 20 Sep 2023 14:51:31 +0200 Subject: [PATCH 266/330] Reconfigure pipeline regarding ignored paths --- .github/workflows/52n-build.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/52n-build.yaml b/.github/workflows/52n-build.yaml index b30a7643827..18a9b21175f 100644 --- a/.github/workflows/52n-build.yaml +++ b/.github/workflows/52n-build.yaml @@ -15,9 +15,9 @@ on: push: branches: - "thuenen_4.x" - paths: - - "!./.github/workflows/52n-dockerhub-description.yaml" - - "!./README_thuenen.md" + paths-ignore: + - "./.github/workflows/52n-dockerhub-description.yaml" + - "./README_thuenen.md" jobs: build_and_push_geonode: From fbee36cb4515c39b4d117333980011e56f7ffffb Mon Sep 17 00:00:00 2001 From: Emanuele Tajariol Date: Tue, 10 Oct 2023 17:52:46 +0200 Subject: [PATCH 267/330] [Fixes #11494] Implement relations between resources (#11565) * [Fixes #11494] Implement relations between resources * [Fixes #11494] Implement relations between resources - tests and improvs * [Fixes #11494] Implement relations between resources - fix formatting * [Fixes #11494] Implement relations between resources - fix flake * [Fixes #11494] Implement relations between resources * [Fixes #11494] Implement relations between resources - fix formatting * [Fixes #11494] Implement relations between resources - fix flake * [Fixes #11494] Implement relations between resources - more tests and fixes * [Fixes #11494] Implement relations between resources - test fixings * [Fixes #11494] Implement relations between resources - test fixings * [Fixes #11494] Implement relations between resources - test fixings * [Fixes #11494] Implement relations between resources - minor fix --- geonode/base/api/serializers.py | 38 ++ geonode/base/api/tests.py | 386 +++++++----------- geonode/base/api/views.py | 98 ++--- geonode/base/forms.py | 35 +- .../base/migrations/0086_linkedresource.py | 23 ++ geonode/base/models.py | 53 +++ geonode/documents/api/views.py | 20 +- geonode/documents/forms.py | 56 +-- .../0037_delete_documentresourcelink.py | 20 + geonode/documents/models.py | 37 -- .../documents/document_metadata.html | 4 +- .../templates/layouts/doc_panels.html | 8 +- geonode/documents/tests.py | 50 +-- geonode/documents/views.py | 2 +- .../geoapps/templates/apps/app_metadata.html | 16 + .../geoapps/templates/layouts/app_panels.html | 7 + geonode/geoapps/views.py | 3 + geonode/layers/models.py | 17 +- .../templates/datasets/dataset_metadata.html | 16 + geonode/layers/templates/layouts/panels.html | 6 + geonode/layers/views.py | 2 + geonode/maps/models.py | 20 +- .../maps/templates/layouts/map_panels.html | 6 + geonode/maps/templates/maps/map_metadata.html | 16 + geonode/maps/views.py | 2 + geonode/resource/manager.py | 19 +- geonode/resource/utils.py | 20 - 27 files changed, 513 insertions(+), 467 deletions(-) create mode 100644 geonode/base/migrations/0086_linkedresource.py create mode 100644 geonode/documents/migrations/0037_delete_documentresourcelink.py diff --git a/geonode/base/api/serializers.py b/geonode/base/api/serializers.py index 74d74e9a236..077c605361b 100644 --- a/geonode/base/api/serializers.py +++ b/geonode/base/api/serializers.py @@ -20,6 +20,7 @@ from slugify import slugify from urllib.parse import urljoin import json +import warnings from django.db.models import Q from django.conf import settings @@ -54,6 +55,7 @@ ThesaurusKeyword, ThesaurusKeywordLabel, ExtraMetadata, + LinkedResource, ) from geonode.documents.models import Document from geonode.geoapps.models import GeoApp @@ -787,7 +789,43 @@ class Meta: class SimpleResourceSerializer(DynamicModelSerializer): + warnings.warn("SimpleResourceSerializer is deprecated", DeprecationWarning, stacklevel=2) + class Meta: name = "linked_resources" model = ResourceBase fields = ("pk", "title", "resource_type", "detail_url", "thumbnail_url") + + def to_representation(self, instance: LinkedResource): + return { + "pk": instance.pk, + "title": f"{'>>>' if instance.is_target else '<<<'} {instance.title}", + "resource_type": instance.resource_type, + "detail_url": instance.detail_url, + "thumbnail_url": instance.thumbnail_url, + } + + +class LinkedResourceSerializer(DynamicModelSerializer): + def __init__(self, *kargs, serialize_source: bool = False, **kwargs): + super().__init__(*kargs, **kwargs) + self.serialize_target = not serialize_source + + class Meta: + name = "linked_resources" + model = LinkedResource + fields = ("internal",) + + def to_representation(self, instance: LinkedResource): + data = super().to_representation(instance) + item: ResourceBase = instance.target if self.serialize_target else instance.source + data.update( + { + "pk": item.pk, + "title": item.title, + "resource_type": item.resource_type, + "detail_url": item.detail_url, + "thumbnail_url": item.thumbnail_url, + } + ) + return data diff --git a/geonode/base/api/tests.py b/geonode/base/api/tests.py index 677c7e06d8a..4833a955484 100644 --- a/geonode/base/api/tests.py +++ b/geonode/base/api/tests.py @@ -22,7 +22,8 @@ import sys import json import logging -from django.contrib.contenttypes.models import ContentType +from typing import Iterable + from django.test import RequestFactory, override_settings import gisdata @@ -62,11 +63,12 @@ RestrictionCodeType, License, Group, + LinkedResource, ) from geonode.layers.models import Dataset from geonode.favorite.models import Favorite -from geonode.documents.models import Document, DocumentResourceLink +from geonode.documents.models import Document from geonode.geoapps.models import GeoApp from geonode.utils import build_absolute_uri from geonode.resource.api.tasks import ExecutionRequest @@ -2607,53 +2609,12 @@ def test_only_get_method_is_available(self): response = self.client.put(url) self.assertEqual(response.status_code, 403) - def test_linked_resource_raise_error_for_geoapps(self): - geo_app = GeoApp.objects.create( - title="Test GeoApp", - owner=get_user_model().objects.first(), - resource_type="geostory", - blob='{"test_data": {"test": ["test_1","test_2","test_3"]}}', - ) - geo_app.set_default_permissions() - url = reverse("base-resources-linked_resources", args=[geo_app.id]) - - response = self.client.get(url) - - self.assertEqual(response.status_code, 501) - - def test_linked_resource_for_document_should_return_the_expected_ouput(self): + def test_linked_resource_for_document(self): + _d = [] try: # data preparation - ctype = ContentType.objects.get_for_model(self.map) - ctype_dataset = ContentType.objects.get_for_model(self.dataset) - _d = DocumentResourceLink.objects.create(document_id=self.doc.id, content_type=ctype, object_id=self.map.id) - _d = DocumentResourceLink.objects.create( - document_id=self.doc.id, content_type=ctype_dataset, object_id=self.dataset.id - ) - - # expected output from api - expected_payload = { - "links": {"next": None, "previous": None}, - "total": 2, - "page": 1, - "page_size": 10, - "resources": [ - { - "detail_url": self.map.detail_url, - "pk": self.map.id, - "resource_type": self.map.resource_type, - "thumbnail_url": self.map.thumbnail_url, - "title": self.map.title, - }, - { - "detail_url": self.dataset.detail_url, - "pk": self.dataset.id, - "resource_type": self.dataset.resource_type, - "thumbnail_url": self.dataset.thumbnail_url, - "title": self.dataset.title, - }, - ], - } + _d.append(LinkedResource.objects.create(source_id=self.doc.id, target_id=self.map.id)) + _d.append(LinkedResource.objects.create(source_id=self.doc.id, target_id=self.dataset.id)) # call the API url = reverse("base-resources-linked_resources", args=[self.doc.id]) @@ -2661,50 +2622,59 @@ def test_linked_resource_for_document_should_return_the_expected_ouput(self): # validation self.assertEqual(response.status_code, 200) - self.assertDictEqual(expected_payload, response.json()) + payload = response.json() + self.assert_linkedres_size(payload, "resources", 2) + self.assert_linkedres_contains( + payload, + "resources", + ( + {"pk": self.map.id, "title": ">>> " + self.map.title}, + {"pk": self.dataset.id, "title": ">>> " + self.dataset.title}, + ), + ) + self.assert_linkedres_size(payload, "linked_to", 2) + self.assert_linkedres_contains( + payload, + "linked_to", + ({"pk": self.map.id, "title": self.map.title}, {"pk": self.dataset.id, "title": self.dataset.title}), + ) + self.assert_linkedres_size(payload, "linked_by", 0) finally: - if _d: - _d.delete() - - def test_linked_resource_for_maps_with_mixed_resources(self): + for d in _d: + d.delete() + + def assert_linkedres_size(self, payload, element: str, expected_size: int): + self.assertEqual(expected_size, len(payload[element]), f"Mismatching payload size of {element}") + + def assert_linkedres_contains(self, payload, element: str, expected_elements: Iterable): + res_list = payload[element] + for dikt in expected_elements: + found = False + for res in res_list: + try: + if dikt.items() <= res.items(): + found = True + break + except AttributeError: + self.fail(f"\nError while comparing \n EXPECTED: {dikt}\n FOUND: {res}") + + if not found: + self.fail(f"Elements {dikt} could not be found in output: {payload}") + + def test_linked_resource_for_maps_mixed(self): + _d = [] try: # data preparation - ctype_dataset = ContentType.objects.get_for_model(self.dataset) - _d = DocumentResourceLink.objects.create( - document_id=self.doc.id, content_type=ctype_dataset, object_id=self.map.id + _d.append(LinkedResource.objects.create(source_id=self.doc.id, target_id=self.map.id)) + _d.append( + MapLayer.objects.create( + map=self.map, + dataset=self.dataset, + name=self.dataset.name, + current_style="test_style", + ows_url="https://maps.geosolutionsgroup.com/geoserver/wms", + ) ) - # data preparation - MapLayer( - map=self.map, - dataset=self.dataset, - name=self.dataset.name, - current_style="test_style", - ows_url="https://maps.geosolutionsgroup.com/geoserver/wms", - ).save() - - # expected output from api - expected_payload = { - "links": {"next": None, "previous": None}, - "total": 2, - "page": 1, - "page_size": 10, - "resources": [ - { - "detail_url": self.doc.detail_url, - "pk": self.doc.id, - "resource_type": self.doc.resource_type, - "thumbnail_url": self.doc.thumbnail_url, - "title": self.doc.title, - }, - { - "detail_url": self.dataset.detail_url, - "pk": self.dataset.id, - "resource_type": self.dataset.resource_type, - "thumbnail_url": self.dataset.thumbnail_url, - "title": self.dataset.title, - }, - ], - } # call the API url = reverse("base-resources-linked_resources", args=[self.map.id]) @@ -2712,71 +2682,38 @@ def test_linked_resource_for_maps_with_mixed_resources(self): # validation self.assertEqual(response.status_code, 200) - self.assertDictEqual(expected_payload, response.json()) - finally: - if _d: - _d.delete() - def test_linked_resource_should_return_the_paginated_result(self): - try: - # data preparation - ctype = ContentType.objects.get_for_model(self.map) - ctype_dataset = ContentType.objects.get_for_model(self.dataset) - _d = DocumentResourceLink.objects.create(document_id=self.doc.id, content_type=ctype, object_id=self.map.id) - _d = DocumentResourceLink.objects.create( - document_id=self.doc.id, content_type=ctype_dataset, object_id=self.dataset.id + payload = response.json() + self.assert_linkedres_size(payload, "resources", 2) + self.assert_linkedres_contains( + payload, + "resources", + ( + {"pk": self.doc.id, "title": "<<< " + self.doc.title}, + {"pk": self.dataset.id, "title": ">>> " + self.dataset.title}, + ), ) - # call the API - url = reverse("base-resources-linked_resources", args=[self.doc.id]) - response = self.client.get(f"{url}?page_size=1") - - # validation - self.assertEqual(response.status_code, 200) - next_url = response.json().get("links", {}).get("next", None) - self.assertIsNotNone(next_url) - self.assertTrue("page=2" in next_url) - # calling next_page to be sure that the url works - response = self.client.get(next_url) - # verify that now it has a prev_page - prev_url = response.json().get("links", {}).get("previous", None) - self.assertIsNotNone(prev_url) - - # verify that it works - # calling next_page to be sure that the url works - response = self.client.get(prev_url) - self.assertEqual(response.status_code, 200) + self.assert_linkedres_size(payload, "linked_to", 1) + self.assert_linkedres_contains( + payload, "linked_to", ({"pk": self.dataset.id, "title": self.dataset.title},) + ) + self.assert_linkedres_size(payload, "linked_by", 1) + self.assert_linkedres_contains(payload, "linked_by", ({"pk": self.doc.id, "title": self.doc.title},)) finally: - if _d: - _d.delete() + for d in _d: + d.delete() - def test_linked_resources_maps_should_return_the_expected_ouput(self): + def test_linked_resources_for_maps(self): try: # data preparation - _m = MapLayer( + _m = MapLayer.objects.create( map=self.map, dataset=self.dataset, name=self.dataset.name, current_style="test_style", ows_url="https://maps.geosolutionsgroup.com/geoserver/wms", - ).save() - - # expected output from api - expected_payload = { - "links": {"next": None, "previous": None}, - "total": 1, - "page": 1, - "page_size": 10, - "resources": [ - { - "detail_url": self.dataset.detail_url, - "pk": self.dataset.id, - "resource_type": self.dataset.resource_type, - "thumbnail_url": self.dataset.thumbnail_url, - "title": self.dataset.title, - } - ], - } + ) # call the API url = reverse("base-resources-linked_resources", args=[self.map.id]) @@ -2784,38 +2721,33 @@ def test_linked_resources_maps_should_return_the_expected_ouput(self): # validation self.assertEqual(response.status_code, 200) - self.assertDictEqual(expected_payload, response.json()) + + payload = response.json() + self.assert_linkedres_size(payload, "resources", 1) + self.assert_linkedres_contains( + payload, "resources", ({"pk": self.dataset.id, "title": ">>> " + self.dataset.title},) + ) + self.assert_linkedres_size(payload, "linked_to", 1) + self.assert_linkedres_contains( + payload, "linked_to", ({"pk": self.dataset.id, "title": self.dataset.title},) + ) + self.assert_linkedres_size(payload, "linked_by", 0) + finally: if _m: _m.delete() - def test_linked_resource_dataset_should_return_the_expected_ouput(self): + def test_linked_resource_for_dataset(self): + _m = None try: # data preparation - _m = MapLayer( + _m = MapLayer.objects.create( map=self.map, dataset=self.dataset, name=self.dataset.name, current_style="test_style", ows_url="https://maps.geosolutionsgroup.com/geoserver/wms", - ).save() - - # expected output from api - expected_payload = { - "links": {"next": None, "previous": None}, - "total": 1, - "page": 1, - "page_size": 10, - "resources": [ - { - "detail_url": self.map.detail_url, - "pk": self.map.id, - "resource_type": self.map.resource_type, - "thumbnail_url": self.map.thumbnail_url, - "title": self.map.title, - } - ], - } + ) # call the API url = reverse("base-resources-linked_resources", args=[self.dataset.id]) @@ -2823,50 +2755,34 @@ def test_linked_resource_dataset_should_return_the_expected_ouput(self): # validation self.assertEqual(response.status_code, 200) - self.assertDictEqual(expected_payload, response.json()) + + payload = response.json() + self.assert_linkedres_size(payload, "resources", 1) + self.assert_linkedres_contains( + payload, "resources", ({"pk": self.map.id, "title": "<<< " + self.map.title},) + ) + self.assert_linkedres_size(payload, "linked_to", 0) + self.assert_linkedres_size(payload, "linked_by", 1) + self.assert_linkedres_contains(payload, "linked_by", ({"pk": self.map.id, "title": self.map.title},)) + finally: if _m: _m.delete() - def test_linked_resource_for_datasets_with_mixed_resources(self): + def test_linked_resource_for_datasets_mixed(self): + _d = [] try: # data preparation - ctype = ContentType.objects.get_for_model(self.dataset) - _d = DocumentResourceLink.objects.create( - document_id=self.doc.id, content_type=ctype, object_id=self.dataset.id + _d.append(LinkedResource.objects.create(source_id=self.doc.id, target_id=self.dataset.id)) + _d.append( + MapLayer.objects.create( + map=self.map, + dataset=self.dataset, + name=self.dataset.name, + current_style="test_style", + ows_url="https://maps.geosolutionsgroup.com/geoserver/wms", + ) ) - # data preparation - MapLayer( - map=self.map, - dataset=self.dataset, - name=self.dataset.name, - current_style="test_style", - ows_url="https://maps.geosolutionsgroup.com/geoserver/wms", - ).save() - - # expected output from api - expected_payload = { - "links": {"next": None, "previous": None}, - "total": 2, - "page": 1, - "page_size": 10, - "resources": [ - { - "detail_url": self.doc.detail_url, - "pk": self.doc.id, - "resource_type": self.doc.resource_type, - "thumbnail_url": self.doc.thumbnail_url, - "title": self.doc.title, - }, - { - "detail_url": self.map.detail_url, - "pk": self.map.id, - "resource_type": self.map.resource_type, - "thumbnail_url": self.map.thumbnail_url, - "title": self.map.title, - }, - ], - } # call the API url = reverse("base-resources-linked_resources", args=[self.dataset.id]) @@ -2874,51 +2790,63 @@ def test_linked_resource_for_datasets_with_mixed_resources(self): # validation self.assertEqual(response.status_code, 200) - self.assertDictEqual(expected_payload, response.json()) + payload = response.json() + self.assert_linkedres_size(payload, "resources", 2) + self.assert_linkedres_contains( + payload, + "resources", + ( + {"pk": self.doc.id, "title": "<<< " + self.doc.title}, + {"pk": self.map.id, "title": "<<< " + self.map.title}, + ), + ) + self.assert_linkedres_size(payload, "linked_to", 0) + self.assert_linkedres_size(payload, "linked_by", 2) + self.assert_linkedres_contains( + payload, + "linked_by", + ( + {"pk": self.map.id, "title": self.map.title}, + {"pk": self.doc.id, "title": self.doc.title}, + ), + ) + finally: - if _d: - _d.delete() + for d in _d: + d.delete() - def test_linked_resource_filter_should_work(self): + def test_linked_resource_deprecated_pagination(self): + _d = [] try: # data preparation - ctype = ContentType.objects.get_for_model(self.map) - ctype_dataset = ContentType.objects.get_for_model(self.dataset) - _d = DocumentResourceLink.objects.create(document_id=self.doc.id, content_type=ctype, object_id=self.map.id) - _d = DocumentResourceLink.objects.create( - document_id=self.doc.id, content_type=ctype_dataset, object_id=self.dataset.id - ) + _d.append(LinkedResource.objects.create(source_id=self.doc.id, target_id=self.dataset.id)) + _d.append(LinkedResource.objects.create(source_id=self.doc.id, target_id=self.map.id)) - # call the API + # call the API w/ pagination url = reverse("base-resources-linked_resources", args=[self.doc.id]) - response = self.client.get(url) - # validation - self.assertEqual(response.status_code, 200, msg="no_filter_validation") - self.assertEqual(2, response.json().get("total", 0), msg="no_filter_validation") + response = self.client.get(f"{url}?page_size=1") - response = self.client.get(url + "?resource_type=map") # validation - self.assertEqual(response.status_code, 200, msg="map_filter_validation") - self.assertEqual(1, response.json().get("total", 0), msg="map_filter_validation") + self.assertEqual(response.status_code, 200) + payload = response.json() - response = self.client.get(url + "?title=single_layer") - # validation - self.assertEqual(response.status_code, 200, msg="dataset_filter_validation") - self.assertEqual(1, response.json().get("total", 0), msg="dataset_filter_validation") + self.assertIn("WARNINGS", payload, "Missing WARNINGS element") + self.assertIn("PAGINATION", payload["WARNINGS"], "Missing PAGINATION element") - response = self.client.get(url + "?resource_type=geoapp") - # validation - self.assertEqual(response.status_code, 200, msg="geoapp_filter_validation") - self.assertEqual(0, response.json().get("total", 0), msg="geoapp_filter_validation") + # call the API w/o pagination + url = reverse("base-resources-linked_resources", args=[self.doc.id]) + response = self.client.get(url) - response = self.client.get(url + "?invalid_filter=invalid") # validation - self.assertEqual(response.status_code, 500, msg="invalid_filter_validation") - self.assertEqual(0, response.json().get("total", 0), msg="invalid_filter_validation") + self.assertEqual(response.status_code, 200) + payload = response.json() + + self.assertIn("WARNINGS", payload, "Missing WARNINGS element") + self.assertNotIn("PAGINATION", payload["WARNINGS"], "Unexpected PAGINATION element") finally: - if _d: - _d.delete() + for d in _d: + d.delete() class TestApiAdditionalBBoxCalculation(GeoNodeBaseTestSupport): diff --git a/geonode/base/api/views.py b/geonode/base/api/views.py index fbbd2faec29..2721fd7c195 100644 --- a/geonode/base/api/views.py +++ b/geonode/base/api/views.py @@ -17,7 +17,6 @@ # ######################################################################### import ast -from geonode.geoapps.models import GeoApp import json import re @@ -110,6 +109,7 @@ RegionSerializer, ThesaurusKeywordSerializer, ExtraMetadataSerializer, + LinkedResourceSerializer, ) from .pagination import GeoNodeApiPagination from geonode.base.utils import validate_extra_metadata @@ -1489,48 +1489,54 @@ def _get_request_params(self, request, encode=False): url_name="linked_resources", ) def linked_resources(self, request, pk, *args, **kwargs): - try: - """ - To let the API be able to filter the linked result, we cannot rely on the DynamicFilterBackend - works on the resource and not on the linked one. - So if we want to filter the linked resource by "resource_type" - we have to search in the query params like in the following code: - _filters = { - x: y - for x, y - in request.query_params.items() - if x not in ["page_size", "page"] - } - We have to exclude the paging code or will raise the: - "Cannot resolve keyword into the field..." - """ - _obj = self.get_object().get_real_instance() - if issubclass(_obj.get_real_concrete_instance_class(), GeoApp): - raise NotImplementedError("Not implemented: this endpoint is not available for GeoApps") - # getting the resource dynamically list based on the above mapping - resources = _obj.linked_resources - - if request.query_params: - _filters = {x: y for x, y in request.query_params.items() if x not in ["page_size", "page"]} - if _filters: - resources = resources.filter(**_filters) - - resources = get_visible_resources( - resources, - user=request.user, - admin_approval_required=settings.ADMIN_MODERATE_UPLOADS, - unpublished_not_visible=settings.RESOURCE_PUBLISHING, - private_groups_not_visibile=settings.GROUP_PRIVATE_RESOURCES, - ).order_by("-pk") - - paginator = GeoNodeApiPagination() - paginator.page_size = request.GET.get("page_size", 10) - result_page = paginator.paginate_queryset(resources, request) - serializer = SimpleResourceSerializer(result_page, embed=True, many=True) - return paginator.get_paginated_response({"resources": serializer.data}) - except NotImplementedError as e: - logger.error(e) - return Response(data={"message": e.args[0], "success": False}, status=501, exception=True) - except Exception as e: - logger.error(e) - return Response(data={"message": e.args[0], "success": False}, status=500, exception=True) + return base_linked_resources(self.get_object().get_real_instance(), request.user, request.GET) + + +def base_linked_resources(instance, user, params): + try: + visibile_resources = get_visible_resources( + ResourceBase.objects, + user=user, + admin_approval_required=settings.ADMIN_MODERATE_UPLOADS, + unpublished_not_visible=settings.RESOURCE_PUBLISHING, + private_groups_not_visibile=settings.GROUP_PRIVATE_RESOURCES, + ).order_by("-pk") + visible_ids = [res.id for res in visibile_resources] + + linked_resources = [lres for lres in instance.get_linked_resources() if lres.target.id in visible_ids] + linked_by = [lres for lres in instance.get_linked_resources(as_target=True) if lres.source.id in visible_ids] + + warnings = { + "DEPRECATION": "'resources' field is deprecated, please use 'linked_to'", + } + + if "page_size" in params or "page" in params: + warnings["PAGINATION"] = "Pagination is not supported on this call" + + # "resources" will be deprecated, so next block is temporary + # "resources" at the moment it's the only element rendered, so we want to add there both the linked_resources and the linked_by + # we want to tell them apart, so we're adding an attr to store this info, that will be used in the SimpleResourceSerializer + resources = [] + for lres in linked_resources: + res = lres.target + setattr(res, "is_target", True) + resources.append(res) + for lres in linked_by: + res = lres.source + setattr(res, "is_target", False) + resources.append(res) + + ret = { + "WARNINGS": warnings, + "resources": SimpleResourceSerializer(resources, embed=True, many=True).data, # deprecated + "linked_to": LinkedResourceSerializer(linked_resources, embed=True, many=True).data, + "linked_by": LinkedResourceSerializer( + instance=linked_by, serialize_source=True, embed=True, many=True + ).data, + } + + return Response(ret) + + except Exception as e: + logger.exception(e) + return Response(data={"message": e.args[0], "success": False}, status=500, exception=True) diff --git a/geonode/base/forms.py b/geonode/base/forms.py index c888d816670..69da9e6bf50 100644 --- a/geonode/base/forms.py +++ b/geonode/base/forms.py @@ -46,6 +46,7 @@ from geonode.base.models import ( HierarchicalKeyword, License, + LinkedResource, Region, ResourceBase, Thesaurus, @@ -345,6 +346,38 @@ def _get_thesauro_title_label(item, lang): return tname.first() +class LinkedResourceForm(forms.ModelForm): + linked_resources = forms.MultipleChoiceField(label=_("Link to"), required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["linked_resources"].choices = self.generate_link_choices() + self.fields["linked_resources"].initial = LinkedResource.get_target_ids(self.instance) + + class Meta: + model = ResourceBase + fields = ["linked_resources"] + + def generate_link_choices(self, resources=None): + if resources is None: + resources = ResourceBase.objects.exclude(pk=self.instance.id).order_by("title") + + return [[obj.id, f"{obj.title} ({obj.polymorphic_ctype.model})"] for obj in resources] + + def save_linked_resources(self, links_field="linked_resources"): + # create and fetch desired links + target_ids = [] + for res_id in self.cleaned_data[links_field]: + linked, _ = LinkedResource.objects.get_or_create(source=self.instance, target_id=res_id, internal=False) + target_ids.append(res_id) + + # delete remaining links + # DocumentResourceLink.objects.filter(document_id=self.instance.id).exclude( + # pk__in=[i.pk for i in instances] + # ).delete() + (LinkedResource.objects.filter(source_id=self.instance.id).exclude(target_id__in=target_ids).delete()) + + class ResourceBaseDateTimePicker(DateTimePicker): def build_attrs(self, base_attrs=None, extra_attrs=None, **kwargs): "Helper function for building an attribute dictionary." @@ -355,7 +388,7 @@ def build_attrs(self, base_attrs=None, extra_attrs=None, **kwargs): # return base_attrs -class ResourceBaseForm(TranslationModelForm): +class ResourceBaseForm(TranslationModelForm, LinkedResourceForm): """Base form for metadata, should be inherited by childres classes of ResourceBase""" diff --git a/geonode/base/migrations/0086_linkedresource.py b/geonode/base/migrations/0086_linkedresource.py new file mode 100644 index 00000000000..cc5929e788d --- /dev/null +++ b/geonode/base/migrations/0086_linkedresource.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.21 on 2023-10-05 14:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0085_alter_resourcebase_uuid'), + ] + + operations = [ + migrations.CreateModel( + name='LinkedResource', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='linked_to', to='base.resourcebase')), + ('target', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='linked_by', to='base.resourcebase')), + ('internal', models.BooleanField(default=False)), + ], + ), + ] diff --git a/geonode/base/models.py b/geonode/base/models.py index 1a2c8a38abd..b5907a902ba 100644 --- a/geonode/base/models.py +++ b/geonode/base/models.py @@ -1726,6 +1726,18 @@ def add_missing_metadata_author_or_poc(self): metadata_author = property(_get_metadata_author, _set_metadata_author) + def get_linked_resources(self, as_target: bool = False): + """ + Get all the linked resources to this ResourceBase instance. + This is implemented as a method so that derived classes can override it (for instance, Maps may add + related datasets) + """ + return ( + LinkedResource.get_linked_resources(target=self) + if as_target + else LinkedResource.get_linked_resources(source=self) + ) + class LinkManager(models.Manager): """Helper class to access links grouped by type""" @@ -1749,6 +1761,47 @@ def ows(self): return self.get_queryset().filter(link_type__in=["OGC:WMS", "OGC:WFS", "OGC:WCS"]) +class LinkedResource(models.Model): + source = models.ForeignKey( + ResourceBase, related_name="linked_to", blank=False, null=False, on_delete=models.CASCADE + ) + target = models.ForeignKey(ResourceBase, related_name="linked_by", blank=True, null=False, on_delete=models.CASCADE) + internal = models.BooleanField(null=False, default=False) + + @classmethod + def get_linked_resources(cls, source: ResourceBase = None, target: ResourceBase = None, is_internal: bool = None): + if source is None and target is None: + raise ValueError("Both source and target filters missing") + + qs = LinkedResource.objects + if source: + qs = qs.filter(source=source).select_related("target") + if target: + qs = qs.filter(target=target).select_related("source") + if is_internal is not None: + qs = qs.filter(internal=is_internal) + return qs + + @classmethod + def get_target_ids(cls, source: ResourceBase, is_internal: bool = None): + sub = LinkedResource.objects.filter(source=source).values_list("target_id", flat=True) + if is_internal is not None: + sub = sub.filter(internal=is_internal) + return sub + + @classmethod + def get_targets(cls, source: ResourceBase, is_internal: bool = None): + return ResourceBase.objects.filter(id__in=cls.get_target_ids(source, is_internal)) + + @classmethod + def resolve_targets(cls, linked_resources): + return (lr.target for lr in linked_resources) + + @classmethod + def resolve_sources(cls, linked_resources): + return (lr.source for lr in linked_resources) + + class Link(models.Model): """Auxiliary model for storing links for resources. diff --git a/geonode/documents/api/views.py b/geonode/documents/api/views.py index dd15a23e363..1e00ca5c12a 100644 --- a/geonode/documents/api/views.py +++ b/geonode/documents/api/views.py @@ -31,11 +31,11 @@ from geonode.base.api.filters import DynamicSearchFilter, ExtentFilter from geonode.base.api.pagination import GeoNodeApiPagination from geonode.base.api.permissions import UserHasPerms +from geonode.base.api.views import base_linked_resources from geonode.base import enumerations from geonode.documents.api.exceptions import DocumentException from geonode.documents.models import Document -from geonode.base.models import ResourceBase from geonode.base.api.serializers import ResourceBaseSerializer from geonode.resource.utils import resourcebase_post_save from geonode.storage.manager import StorageManager @@ -142,22 +142,8 @@ def perform_create(self, serializer): @extend_schema( methods=["get"], responses={200: ResourceBaseSerializer(many=True)}, - description="API endpoint allowing to retrieve the DocumentResourceLink(s).", + description="API endpoint allowing to retrieve linked resources", ) @action(detail=True, methods=["get"]) def linked_resources(self, request, pk=None, *args, **kwargs): - document = self.get_object() - resources_id = document.links.all().values("object_id") - resources = ResourceBase.objects.filter(id__in=resources_id) - exclude = [] - for resource in resources: - if not request.user.is_superuser and not request.user.has_perm( - "view_resourcebase", resource.get_self_resource() - ): - exclude.append(resource.id) - resources = resources.exclude(id__in=exclude) - paginator = GeoNodeApiPagination() - paginator.page_size = request.GET.get("page_size", 10) - result_page = paginator.paginate_queryset(resources, request) - serializer = ResourceBaseSerializer(result_page, embed=True, many=True) - return paginator.get_paginated_response({"resources": serializer.data}) + return base_linked_resources(self.get_object().get_real_instance(), request.user, request.GET) diff --git a/geonode/documents/forms.py b/geonode/documents/forms.py index 8da41217463..286f98e0a6b 100644 --- a/geonode/documents/forms.py +++ b/geonode/documents/forms.py @@ -18,7 +18,6 @@ ######################################################################### import os -import re import json import logging @@ -28,14 +27,10 @@ from django.conf import settings from django.forms import HiddenInput from django.utils.translation import ugettext as _ -from django.contrib.contenttypes.models import ContentType from django.template.defaultfilters import filesizeformat -from geonode.maps.models import Map -from geonode.layers.models import Dataset from geonode.base.forms import ResourceBaseForm, get_tree_data -from geonode.resource.utils import get_related_resources -from geonode.documents.models import Document, DocumentResourceLink +from geonode.documents.models import Document from geonode.upload.models import UploadSizeLimit from geonode.upload.api.exceptions import FileUploadLimitException @@ -82,54 +77,12 @@ def _get_max_size(self): return max_size_db_obj.max_size -class DocumentFormMixin: - def generate_link_choices(self, resources=None): - if resources is None: - resources = list(Dataset.objects.all()) - resources += list(Map.objects.all()) - resources.sort(key=lambda x: x.title) - - choices = [] - for obj in resources: - type_id = ContentType.objects.get_for_model(obj.__class__).id - choices.append([f"type:{type_id}-id:{obj.id}", f"{obj.title} ({obj.polymorphic_ctype.model})"]) - - return choices - - def generate_link_values(self, resources=None): - choices = self.generate_link_choices(resources=resources) - return [choice[0] for choice in choices] - - def save_many2many(self, links_field="links"): - # create and fetch desired links - instances = [] - for link in self.cleaned_data[links_field]: - matches = re.match(r"type:(\d+)-id:(\d+)", link) - if matches: - content_type = ContentType.objects.get(id=matches.group(1)) - instance, _ = DocumentResourceLink.objects.get_or_create( - document=self.instance, - content_type=content_type, - object_id=matches.group(2), - ) - instances.append(instance) - - # delete remaining links - DocumentResourceLink.objects.filter(document_id=self.instance.id).exclude( - pk__in=[i.pk for i in instances] - ).delete() - - -class DocumentForm(ResourceBaseForm, DocumentFormMixin): +class DocumentForm(ResourceBaseForm): title = forms.CharField(required=False) - links = forms.MultipleChoiceField(label=_("Link to"), required=False) - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["regions"].choices = get_tree_data() - self.fields["links"].choices = self.generate_link_choices() - self.fields["links"].initial = self.generate_link_values(resources=get_related_resources(self.instance)) for field in self.fields: help_text = self.fields[field].help_text self.fields[field].help_text = None @@ -163,7 +116,7 @@ class DocumentDescriptionForm(forms.Form): keywords = forms.CharField(max_length=500, required=False) -class DocumentCreateForm(TranslationModelForm, DocumentFormMixin): +class DocumentCreateForm(TranslationModelForm): """ The document upload form. @@ -173,8 +126,6 @@ class DocumentCreateForm(TranslationModelForm, DocumentFormMixin): widget=HiddenInput(attrs={"name": "permissions", "id": "permissions"}), required=False ) - links = forms.MultipleChoiceField(label=_("Link to"), required=False) - doc_file = SizeRestrictedFileField(label=_("File"), required=False, field_slug="document_upload_size") class Meta: @@ -186,7 +137,6 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["links"].choices = self.generate_link_choices() def clean_permissions(self): """ diff --git a/geonode/documents/migrations/0037_delete_documentresourcelink.py b/geonode/documents/migrations/0037_delete_documentresourcelink.py new file mode 100644 index 00000000000..a03389fd6b6 --- /dev/null +++ b/geonode/documents/migrations/0037_delete_documentresourcelink.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.21 on 2023-10-05 15:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('documents', '0036_clean_document_thumbnails'), + ('base', '0086_linkedresource'), + ] + + operations = [ + migrations.RunSQL("INSERT INTO base_linkedresource(source_id, target_id, internal)" + "SELECT document_id, object_id, false as internal " + "FROM documents_documentresourcelink;"), + migrations.DeleteModel( + name='DocumentResourceLink', + ), + ] diff --git a/geonode/documents/models.py b/geonode/documents/models.py index 5f5f41ab218..78292dfc8c9 100644 --- a/geonode/documents/models.py +++ b/geonode/documents/models.py @@ -25,14 +25,9 @@ from django.urls import reverse from django.utils.functional import classproperty from django.utils.translation import ugettext_lazy as _ -from django.contrib.contenttypes.models import ContentType -from django.contrib.contenttypes.fields import GenericForeignKey from geonode.client.hooks import hookset -from geonode.maps.models import Map -from geonode.layers.models import Dataset from geonode.base.models import ResourceBase -from geonode.maps.signals import map_changed_signal from geonode.groups.conf import settings as groups_settings from geonode.documents.enumerations import DOCUMENT_TYPE_MAP, DOCUMENT_MIMETYPE_MAP from geonode.security.permissions import VIEW_PERMISSIONS, OWNER_PERMISSIONS, DOWNLOAD_PERMISSIONS @@ -150,37 +145,5 @@ def download_url(self): return self.link_set.filter(resource=self.get_self_resource(), link_type="original").first().url return build_absolute_uri(reverse("document_download", args=(self.id,))) - @property - def linked_resources(self): - return ResourceBase.objects.filter(id__in=self.links.values_list("object_id", flat=True)) - class Meta(ResourceBase.Meta): pass - - -class DocumentResourceLink(models.Model): - # relation to the document model - document = models.ForeignKey(Document, null=True, blank=True, related_name="links", on_delete=models.CASCADE) - - # relation to the resource model - content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField() - resource = GenericForeignKey("content_type", "object_id") - - -def get_related_documents(resource): - if isinstance(resource, Dataset) or isinstance(resource, Map): - content_type = ContentType.objects.get_for_model(resource) - return Document.objects.filter(links__content_type=content_type, links__object_id=resource.pk) - else: - return None - - -def update_documents_extent(sender, **kwargs): - documents = get_related_documents(sender) - if documents: - for document in documents: - document.save() - - -map_changed_signal.connect(update_documents_extent) diff --git a/geonode/documents/templates/documents/document_metadata.html b/geonode/documents/templates/documents/document_metadata.html index 2f4361c9be8..a04a7323b50 100644 --- a/geonode/documents/templates/documents/document_metadata.html +++ b/geonode/documents/templates/documents/document_metadata.html @@ -88,13 +88,13 @@

    {% trans "Metadata Provider" %}

    {% block extra_script %} {{ block.super }} +{% endblock extra_script %} \ No newline at end of file diff --git a/geonode/geoapps/templates/layouts/app_panels.html b/geonode/geoapps/templates/layouts/app_panels.html index e5ea2378882..4763d7a1257 100644 --- a/geonode/geoapps/templates/layouts/app_panels.html +++ b/geonode/geoapps/templates/layouts/app_panels.html @@ -297,6 +297,13 @@ {{ geoapp_form.title }} {% endblock %} + {% block geoapp_linked_resources %} +
    + + {{ geoapp_form.linked_resources }} +
    + {% endblock geoapp_linked_resources %} +
    diff --git a/geonode/geoapps/views.py b/geonode/geoapps/views.py index eb23dee862a..788dcaccbe1 100644 --- a/geonode/geoapps/views.py +++ b/geonode/geoapps/views.py @@ -323,6 +323,9 @@ def geoapp_metadata( geoapp_form.cleaned_data.pop("metadata") extra_metadata = geoapp_form.cleaned_data.pop("extra_metadata") + geoapp_form.save_linked_resources() + geoapp_form.cleaned_data.pop("linked_resources") + geoapp_obj = geoapp_form.instance _vals = dict(**geoapp_form.cleaned_data, **additional_vals) diff --git a/geonode/layers/models.py b/geonode/layers/models.py index 05df8956868..22eb2eff0cd 100644 --- a/geonode/layers/models.py +++ b/geonode/layers/models.py @@ -16,6 +16,7 @@ # along with this program. If not, see . # ######################################################################### +import itertools import re import logging @@ -38,7 +39,7 @@ DOWNLOAD_PERMISSIONS, DATASET_ADMIN_PERMISSIONS, ) -from geonode.base.models import ResourceBase, ResourceBaseManager +from geonode.base.models import ResourceBase, ResourceBaseManager, LinkedResource logger = logging.getLogger("geonode.layers.models") @@ -317,13 +318,15 @@ def maps(self): map_ids = list(self.maplayers.values_list("map__id", flat=True)) return Map.objects.filter(id__in=map_ids) - @property - def linked_resources(self): - from geonode.documents.models import DocumentResourceLink + def get_linked_resources(self, as_target: bool = False): + ret = super().get_linked_resources(as_target) + + if as_target: + # create LinkedResources on the fly to report MapLayer relationship + res = (LinkedResource(source=map, target=self, internal=True) for map in self.maps) + ret = itertools.chain(ret, res) - _map_ids = list(self.maplayers.values_list("map__id", flat=True)) - _doc_ids = list(DocumentResourceLink.objects.filter(object_id=self.pk).values_list("document__pk", flat=True)) - return ResourceBase.objects.filter(id__in=list(set(_map_ids + _doc_ids))) + return ret @property def download_url(self): diff --git a/geonode/layers/templates/datasets/dataset_metadata.html b/geonode/layers/templates/datasets/dataset_metadata.html index cfb423709ac..c01955351ff 100644 --- a/geonode/layers/templates/datasets/dataset_metadata.html +++ b/geonode/layers/templates/datasets/dataset_metadata.html @@ -109,3 +109,19 @@

    {% trans "Metadata Provider" %}

    {{ block.super }} {% endblock body_outer %} + +{% block extra_script %} +{{ block.super }} + + +{% endblock extra_script %} \ No newline at end of file diff --git a/geonode/layers/templates/layouts/panels.html b/geonode/layers/templates/layouts/panels.html index 579b5f46ea6..d49f35726b2 100644 --- a/geonode/layers/templates/layouts/panels.html +++ b/geonode/layers/templates/layouts/panels.html @@ -331,6 +331,12 @@ {{ dataset_form.title }}
    {% endblock dataset_title %} + {% block dataset_linked_resources %} +
    + + {{ dataset_form.linked_resources }} +
    + {% endblock dataset_linked_resources %} {% block dataset_abstract %}
    diff --git a/geonode/layers/views.py b/geonode/layers/views.py index 3aa3bb91ff8..e4a7f21fb7b 100644 --- a/geonode/layers/views.py +++ b/geonode/layers/views.py @@ -612,6 +612,8 @@ def dataset_metadata( if up_sessions.exists() and up_sessions[0].user != layer.owner: up_sessions.update(user=layer.owner) + dataset_form.save_linked_resources() + register_event(request, EventType.EVENT_CHANGE_METADATA, layer) if not ajax: return HttpResponseRedirect(layer.get_absolute_url()) diff --git a/geonode/maps/models.py b/geonode/maps/models.py index 90b18bc98ab..6cee9d21f64 100644 --- a/geonode/maps/models.py +++ b/geonode/maps/models.py @@ -19,6 +19,8 @@ import json import logging +import itertools + from deprecated import deprecated from django.db import models from django.template.defaultfilters import slugify @@ -26,7 +28,7 @@ from django.utils.translation import ugettext_lazy as _ from geonode import geoserver # noqa -from geonode.base.models import ResourceBase +from geonode.base.models import ResourceBase, LinkedResource from geonode.client.hooks import hookset from geonode.layers.models import Dataset, Style from geonode.utils import check_ogc_backend @@ -59,13 +61,17 @@ def datasets(self): dataset_names = MapLayer.objects.filter(map__id=self.id).values("name") return Dataset.objects.filter(alternate__in=dataset_names) | Dataset.objects.filter(name__in=dataset_names) - @property - def linked_resources(self): - from geonode.documents.models import DocumentResourceLink + def get_linked_resources(self, as_target: bool = False): + ret = super().get_linked_resources(as_target) + + if not as_target: + dataset_ids = MapLayer.objects.filter(map__id=self.id).values("dataset_id") + datasets = ResourceBase.objects.filter(id__in=dataset_ids) + # create LinkedResources on the fly to report MapLayer relationship + res = (LinkedResource(source=self, target=d, internal=True) for d in datasets) + ret = itertools.chain(ret, res) - _dataset_id = list(self.datasets.values_list("pk", flat=True)) - _doc_ids = list(DocumentResourceLink.objects.filter(object_id=self.pk).values_list("document__pk", flat=True)) - return ResourceBase.objects.filter(id__in=list(set(_dataset_id + _doc_ids))) + return ret def json(self, dataset_filter): """ diff --git a/geonode/maps/templates/layouts/map_panels.html b/geonode/maps/templates/layouts/map_panels.html index dce48088967..96b3892117c 100644 --- a/geonode/maps/templates/layouts/map_panels.html +++ b/geonode/maps/templates/layouts/map_panels.html @@ -317,6 +317,12 @@ {{ map_form.title }}
    {% endblock map_title %} + {% block map_linked_resources %} +
    + + {{ map_form.linked_resources }} +
    + {% endblock map_linked_resources %} {% block map_abstract %}
    diff --git a/geonode/maps/templates/maps/map_metadata.html b/geonode/maps/templates/maps/map_metadata.html index dcc99f734e7..0fece0b382f 100644 --- a/geonode/maps/templates/maps/map_metadata.html +++ b/geonode/maps/templates/maps/map_metadata.html @@ -87,3 +87,19 @@

    {% trans "Metadata Provider" %}

    {{ block.super }} {% endblock body_outer %} + +{% block extra_script %} +{{ block.super }} + + +{% endblock extra_script %} diff --git a/geonode/maps/views.py b/geonode/maps/views.py index 00c25225a62..c79c1986f40 100644 --- a/geonode/maps/views.py +++ b/geonode/maps/views.py @@ -211,6 +211,8 @@ def map_metadata( new_m = ExtraMetadata.objects.create(resource=map_obj, metadata=_m) map_obj.metadata.add(new_m) + map_form.save_linked_resources() + register_event(request, EventType.EVENT_CHANGE_METADATA, map_obj) if not ajax: return HttpResponseRedirect(hookset.map_detail_url(map_obj)) diff --git a/geonode/resource/manager.py b/geonode/resource/manager.py index 245350d6cbf..6b2cead54ca 100644 --- a/geonode/resource/manager.py +++ b/geonode/resource/manager.py @@ -47,10 +47,10 @@ from .utils import update_resource, resourcebase_post_save from ..base import enumerations -from ..base.models import ResourceBase +from ..base.models import ResourceBase, LinkedResource from ..security.utils import AdvancedSecurityWorkflowManager from ..layers.metadata import parse_metadata -from ..documents.models import Document, DocumentResourceLink +from ..documents.models import Document from ..layers.models import Dataset, Attribute from ..maps.models import Map from ..storage.manager import storage_manager @@ -512,12 +512,15 @@ def copy( if "name" in defaults: defaults.pop("name") _resource.save() - if isinstance(instance.get_real_instance(), Document): - for resource_link in DocumentResourceLink.objects.filter(document=instance.get_real_instance()): - _resource_link = copy.copy(resource_link) - _resource_link.pk = _resource_link.id = None - _resource_link.document = _resource.get_real_instance() - _resource_link.save() + for lr in LinkedResource.get_linked_resources(source=instance.pk, is_internal=False): + LinkedResource.object.get_or_create( + source_id=_resource.pk, target_id=lr.target.pk, internal=False + ) + for lr in LinkedResource.get_linked_resources(target=instance.pk, is_internal=False): + LinkedResource.object.get_or_create( + source_id=lr.source.pk, target_id=_resource.pk, internal=False + ) + if isinstance(instance.get_real_instance(), Dataset): for attribute in Attribute.objects.filter(dataset=instance.get_real_instance()): _attribute = copy.copy(attribute) diff --git a/geonode/resource/utils.py b/geonode/resource/utils.py index 5a557f8ceb9..aa0b7f36f3c 100644 --- a/geonode/resource/utils.py +++ b/geonode/resource/utils.py @@ -29,7 +29,6 @@ from django.utils import timezone from django.core.exceptions import FieldDoesNotExist from django.utils.translation import ugettext_lazy as _ -from django.contrib.gis.geos import MultiPolygon from geonode.utils import OGC_Servers_Handler from django.utils.module_loading import import_string @@ -316,16 +315,6 @@ def get_alternate_name(instance): return instance.alternate -def get_related_resources(document): - if document.links: - try: - return [link.content_type.get_object_for_this_type(id=link.object_id) for link in document.links.all()] - except Exception: - return [] - else: - return [] - - def document_post_save(instance, *args, **kwargs): instance.csw_type = "document" @@ -375,15 +364,6 @@ def document_post_save(instance, *args, **kwargs): ), ) - resources = get_related_resources(instance) - - # if there are (new) linked resources update the bbox computed by their bboxes - if resources: - bbox = MultiPolygon([r.bbox_polygon for r in resources]) - instance.set_bbox_polygon(bbox.extent, instance.srid) - elif not instance.bbox_polygon: - instance.set_bbox_polygon((-180, -90, 180, 90), "EPSG:4326") - def dataset_post_save(instance, *args, **kwargs): base_file, info = instance.get_base_file() From ac26caa81f5ffc7cbfdf5edd675b0f4013592f14 Mon Sep 17 00:00:00 2001 From: Emanuele Tajariol Date: Thu, 12 Oct 2023 16:32:11 +0200 Subject: [PATCH 268/330] [Fixes #11579] Use autocomplete API for editing linked resources (#11584) --- geonode/base/forms.py | 44 ++++++---- geonode/base/tests.py | 79 +++++++++++++----- geonode/base/urls.py | 6 ++ geonode/base/views.py | 22 +++++ .../documents/document_metadata.html | 16 ---- .../templates/layouts/doc_panels.html | 2 +- geonode/documents/tests.py | 12 +-- .../geoapps/templates/apps/app_metadata.html | 16 ---- .../geoapps/templates/layouts/app_panels.html | 2 +- .../templates/datasets/dataset_metadata.html | 16 ---- geonode/layers/templates/layouts/panels.html | 2 +- geonode/locale/it/LC_MESSAGES/django.mo | Bin 161121 -> 161218 bytes geonode/locale/it/LC_MESSAGES/django.po | 10 ++- .../maps/templates/layouts/map_panels.html | 2 +- geonode/maps/templates/maps/map_metadata.html | 16 ---- 15 files changed, 135 insertions(+), 110 deletions(-) diff --git a/geonode/base/forms.py b/geonode/base/forms.py index 69da9e6bf50..1c720aac2d0 100644 --- a/geonode/base/forms.py +++ b/geonode/base/forms.py @@ -20,9 +20,11 @@ import html import json import logging + from django.db.models.query import QuerySet from bootstrap3_datetime.widgets import DateTimePicker from dal import autocomplete +import dal.forward from django import forms from django.conf import settings from django.contrib.auth import get_user_model @@ -347,35 +349,45 @@ def _get_thesauro_title_label(item, lang): class LinkedResourceForm(forms.ModelForm): - linked_resources = forms.MultipleChoiceField(label=_("Link to"), required=False) + linked_resources = forms.ModelMultipleChoiceField( + label=_("Related resources"), + required=False, + queryset=None, + widget=autocomplete.ModelSelect2Multiple(url="autocomplete_linked_resource"), + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["linked_resources"].choices = self.generate_link_choices() - self.fields["linked_resources"].initial = LinkedResource.get_target_ids(self.instance) + + # this is used to automatically validate the POSTed back values + self.fields["linked_resources"].queryset = ResourceBase.objects.exclude(pk=self.instance.id) + # these are the LinkedResource already linked to this resource + self.fields["linked_resources"].initial = LinkedResource.get_target_ids(self.instance).all() + # this is used by the autocomplete view to exclude current resource + self.fields["linked_resources"].widget.forward.append( + dal.forward.Const( + self.instance.id, + "exclude", + ) + ) class Meta: model = ResourceBase fields = ["linked_resources"] - def generate_link_choices(self, resources=None): - if resources is None: - resources = ResourceBase.objects.exclude(pk=self.instance.id).order_by("title") - - return [[obj.id, f"{obj.title} ({obj.polymorphic_ctype.model})"] for obj in resources] - def save_linked_resources(self, links_field="linked_resources"): # create and fetch desired links target_ids = [] - for res_id in self.cleaned_data[links_field]: - linked, _ = LinkedResource.objects.get_or_create(source=self.instance, target_id=res_id, internal=False) - target_ids.append(res_id) + for res in self.cleaned_data[links_field]: + LinkedResource.objects.get_or_create(source=self.instance, target=res, internal=False) + target_ids.append(res.pk) # delete remaining links - # DocumentResourceLink.objects.filter(document_id=self.instance.id).exclude( - # pk__in=[i.pk for i in instances] - # ).delete() - (LinkedResource.objects.filter(source_id=self.instance.id).exclude(target_id__in=target_ids).delete()) + ( + LinkedResource.objects.filter(source_id=self.instance.id, internal=False) + .exclude(target_id__in=target_ids) + .delete() + ) class ResourceBaseDateTimePicker(DateTimePicker): diff --git a/geonode/base/tests.py b/geonode/base/tests.py index 4c11e59ee58..a217d78a0f4 100644 --- a/geonode/base/tests.py +++ b/geonode/base/tests.py @@ -17,20 +17,31 @@ # ######################################################################### +import json import logging import os -from django.db.utils import IntegrityError, OperationalError import requests - +from PIL import Image +from io import BytesIO from uuid import uuid4 from unittest.mock import patch, Mock +from guardian.shortcuts import assign_perm + +from django.db.utils import IntegrityError, OperationalError from django.core.exceptions import ObjectDoesNotExist +from django.conf import settings +from django.contrib.gis.geos import Polygon, GEOSGeometry +from django.template import Template, Context +from django.contrib.auth import get_user_model +from geonode.storage.manager import storage_manager +from django.test import Client, TestCase, override_settings, SimpleTestCase +from django.shortcuts import reverse +from django.utils import translation +from django.core.files import File +from django.core.management import call_command +from django.core.management.base import CommandError -from PIL import Image -from io import BytesIO -from guardian.shortcuts import assign_perm from geonode.base.populate_test_data import create_single_dataset - from geonode.maps.models import Map from geonode.resource.utils import KeywordHandler from geonode.thumbs import utils as thumb_utils @@ -54,15 +65,6 @@ ThesaurusKeyword, generate_thesaurus_reference, ) -from django.conf import settings -from django.contrib.gis.geos import Polygon, GEOSGeometry -from django.template import Template, Context -from django.contrib.auth import get_user_model -from geonode.storage.manager import storage_manager -from django.test import Client, TestCase, override_settings, SimpleTestCase -from django.shortcuts import reverse -from django.utils import translation - from geonode.base.middleware import ReadOnlyMiddleware, MaintenanceMiddleware from geonode.base.templatetags.base_tags import get_visibile_resources, facets from geonode.base.templatetags.thesaurus import ( @@ -76,10 +78,6 @@ from geonode.base.templatetags.user_messages import show_notification from geonode import geoserver from geonode.decorators import on_ogc_backend - -from django.core.files import File -from django.core.management import call_command -from django.core.management.base import CommandError from geonode.base.forms import ThesaurusAvailableForm, THESAURUS_RESULT_LIST_SEPERATOR from geonode.resource.manager import resource_manager @@ -1174,3 +1172,46 @@ def test_regions_are_assigned_if_handler_is_used(self): self.assertTrue(dataset.regions.exists()) self.assertEqual(1, dataset.regions.count()) self.assertEqual("Global", dataset.regions.first().name) + + +class LinkedResourcesTest(GeoNodeBaseTestSupport): + def test_autocomplete_linked_resource(self): + d = [] + try: + user, _ = get_user_model().objects.get_or_create(username="admin") + + for t in ("dataset1", "dataset2", "other"): + d.append(ResourceBase.objects.create(title=t, owner=user, is_approved=True, is_published=True)) + + web_client = Client() + web_client.force_login(user) + url_name = "autocomplete_linked_resource" + + # get all resources + response = web_client.get(reverse(url_name)) + rjson = response.json() + + self.assertEqual(response.status_code, 200, "Can not get autocomplete API") + self.assertIn("results", rjson, "Can not find results") + self.assertEqual(len(rjson["results"]), 3, "Unexpected results count") + + # filter by title + response = web_client.get( + reverse(url_name), + data={ + "q": "dataset", + }, + ) + rjson = response.json() + self.assertEqual(len(rjson["results"]), 2, "Unexpected results count") + + # filter by title, exclude + response = web_client.get( + reverse(url_name), data={"q": "dataset", "forward": json.dumps({"exclude": d[0].id})} + ) + rjson = response.json() + self.assertEqual(len(rjson["results"]), 1, "Unexpected results count") + + finally: + for _ in d: + _.delete() diff --git a/geonode/base/urls.py b/geonode/base/urls.py index 76a22d6138a..77e3d226b4e 100644 --- a/geonode/base/urls.py +++ b/geonode/base/urls.py @@ -27,6 +27,7 @@ ResourceBaseAutocomplete, HierarchicalKeywordAutocomplete, ThesaurusKeywordLabelAutocomplete, + LinkedResourcesAutocomplete, ) @@ -36,6 +37,11 @@ ResourceBaseAutocomplete.as_view(), name="autocomplete_base", ), + url( + r"^autocomplete_linked_resource/$", + LinkedResourcesAutocomplete.as_view(), + name="autocomplete_linked_resource", + ), url( r"^autocomplete_region/$", RegionAutocomplete.as_view(), diff --git a/geonode/base/views.py b/geonode/base/views.py index 7d5d08aa692..ab7359c8b3b 100644 --- a/geonode/base/views.py +++ b/geonode/base/views.py @@ -282,6 +282,28 @@ def get_queryset(self): )[:100] +class LinkedResourcesAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + qs = ResourceBase.objects.order_by("title") + + if self.q: + qs = qs.filter(title__icontains=self.q) + + if self.forwarded and "exclude" in self.forwarded: + qs = qs.exclude(pk=self.forwarded["exclude"]) + + return get_visible_resources( + qs, + self.request.user if self.request else None, + admin_approval_required=settings.ADMIN_MODERATE_UPLOADS, + unpublished_not_visible=settings.RESOURCE_PUBLISHING, + private_groups_not_visibile=settings.GROUP_PRIVATE_RESOURCES, + ) + + def get_result_label(self, result): + return f"{result.title} [{_(result.polymorphic_ctype.model)}]" + + class RegionAutocomplete(SimpleSelect2View): model = Region filter_arg = "name__icontains" diff --git a/geonode/documents/templates/documents/document_metadata.html b/geonode/documents/templates/documents/document_metadata.html index a04a7323b50..80b553f9c35 100644 --- a/geonode/documents/templates/documents/document_metadata.html +++ b/geonode/documents/templates/documents/document_metadata.html @@ -84,19 +84,3 @@

    {% trans "Metadata Provider" %}

    {% endblock body_outer %} - -{% block extra_script %} -{{ block.super }} - - -{% endblock extra_script %} diff --git a/geonode/documents/templates/layouts/doc_panels.html b/geonode/documents/templates/layouts/doc_panels.html index 0cbef86d490..0c7c10667ba 100644 --- a/geonode/documents/templates/layouts/doc_panels.html +++ b/geonode/documents/templates/layouts/doc_panels.html @@ -315,7 +315,7 @@ {% endblock doc_title %} {% block doc_linked_resources %}
    - + {{ document_form.linked_resources }}
    {% endblock doc_linked_resources %} diff --git a/geonode/documents/tests.py b/geonode/documents/tests.py index 5f09441c3f9..164abe7d519 100644 --- a/geonode/documents/tests.py +++ b/geonode/documents/tests.py @@ -655,9 +655,9 @@ def test_create_document_with_links(self): mixin1 = LinkedResourceForm() mixin1.instance = d - mixin1.cleaned_data = dict( - linked_resources=[r.id for r in resources], - ) + mixin1.cleaned_data = { + "linked_resources": resources, + } mixin1.save_linked_resources() for resource in resources: @@ -668,9 +668,9 @@ def test_create_document_with_links(self): mixin2 = LinkedResourceForm() mixin2.instance = d - mixin2.cleaned_data = dict( - linked_resources=[r.id for r in layers], - ) + mixin2.cleaned_data = { + "linked_resources": layers, + } mixin2.save_linked_resources() for resource in layers: diff --git a/geonode/geoapps/templates/apps/app_metadata.html b/geonode/geoapps/templates/apps/app_metadata.html index 18769664be2..17b2de35a97 100644 --- a/geonode/geoapps/templates/apps/app_metadata.html +++ b/geonode/geoapps/templates/apps/app_metadata.html @@ -85,19 +85,3 @@

    {% trans "Metadata Provider" %}

    {{ block.super }} {% endblock body_outer %} - -{% block extra_script %} -{{ block.super }} - - -{% endblock extra_script %} \ No newline at end of file diff --git a/geonode/geoapps/templates/layouts/app_panels.html b/geonode/geoapps/templates/layouts/app_panels.html index 4763d7a1257..855c10b1429 100644 --- a/geonode/geoapps/templates/layouts/app_panels.html +++ b/geonode/geoapps/templates/layouts/app_panels.html @@ -299,7 +299,7 @@ {% endblock %} {% block geoapp_linked_resources %}
    - + {{ geoapp_form.linked_resources }}
    {% endblock geoapp_linked_resources %} diff --git a/geonode/layers/templates/datasets/dataset_metadata.html b/geonode/layers/templates/datasets/dataset_metadata.html index c01955351ff..cfb423709ac 100644 --- a/geonode/layers/templates/datasets/dataset_metadata.html +++ b/geonode/layers/templates/datasets/dataset_metadata.html @@ -109,19 +109,3 @@

    {% trans "Metadata Provider" %}

    {{ block.super }} {% endblock body_outer %} - -{% block extra_script %} -{{ block.super }} - - -{% endblock extra_script %} \ No newline at end of file diff --git a/geonode/layers/templates/layouts/panels.html b/geonode/layers/templates/layouts/panels.html index d49f35726b2..dfd9d14e40a 100644 --- a/geonode/layers/templates/layouts/panels.html +++ b/geonode/layers/templates/layouts/panels.html @@ -333,7 +333,7 @@ {% endblock dataset_title %} {% block dataset_linked_resources %}
    - + {{ dataset_form.linked_resources }}
    {% endblock dataset_linked_resources %} diff --git a/geonode/locale/it/LC_MESSAGES/django.mo b/geonode/locale/it/LC_MESSAGES/django.mo index ca8e5e1987454996ec2d980155ea22c053d2d976..1b322e2d5ac2449ce20b34988a68104c7eb05a20 100644 GIT binary patch delta 34171 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 QO`8Sg-n^!5V8Xcn55+gi!vFvP diff --git a/geonode/locale/it/LC_MESSAGES/django.po b/geonode/locale/it/LC_MESSAGES/django.po index 8791b1cfec4..7b4f071266c 100644 --- a/geonode/locale/it/LC_MESSAGES/django.po +++ b/geonode/locale/it/LC_MESSAGES/django.po @@ -39,7 +39,7 @@ msgstr "" "Project-Id-Version: GeoNode\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-07-08 09:02+0300\n" -"PO-Revision-Date: 2023-07-20 11:35+0200\n" +"PO-Revision-Date: 2023-10-11 11:47+0200\n" "Last-Translator: Julien Collaer \n" "Language-Team: Italian (http://www.transifex.com/geonode/geonode/language/it/)\n" "Language: it\n" @@ -947,6 +947,9 @@ msgstr "Classifica velocità" msgid "Link to" msgstr "Collegamento a" +msgid "Related resources" +msgstr "Risorse correlate" + msgid "File" msgstr "File" @@ -3283,6 +3286,11 @@ msgid_plural "Maps" msgstr[0] "Mappa" msgstr[1] "Mappe" +msgid "map" +msgid_plural "maps" +msgstr[0] "mappa" +msgstr[1] "mappe" + msgid "Map Layers" msgstr "Livelli della mappa" diff --git a/geonode/maps/templates/layouts/map_panels.html b/geonode/maps/templates/layouts/map_panels.html index 96b3892117c..1dc3deb8ff6 100644 --- a/geonode/maps/templates/layouts/map_panels.html +++ b/geonode/maps/templates/layouts/map_panels.html @@ -319,7 +319,7 @@ {% endblock map_title %} {% block map_linked_resources %}
    - + {{ map_form.linked_resources }}
    {% endblock map_linked_resources %} diff --git a/geonode/maps/templates/maps/map_metadata.html b/geonode/maps/templates/maps/map_metadata.html index 0fece0b382f..dcc99f734e7 100644 --- a/geonode/maps/templates/maps/map_metadata.html +++ b/geonode/maps/templates/maps/map_metadata.html @@ -87,19 +87,3 @@

    {% trans "Metadata Provider" %}

    {{ block.super }} {% endblock body_outer %} - -{% block extra_script %} -{{ block.super }} - - -{% endblock extra_script %} From 43188af8a18a21879a4c8ba2d8e4d3c6abcf5eae Mon Sep 17 00:00:00 2001 From: Emanuele Tajariol Date: Fri, 13 Oct 2023 10:26:58 +0200 Subject: [PATCH 269/330] [Fixes #11585] Move linked resources widget to the "optional metadata" tab (#11586) --- .../templates/layouts/doc_panels.html | 12 ++++++------ .../geoapps/templates/layouts/app_panels.html | 14 +++++++------- geonode/layers/templates/layouts/panels.html | 12 ++++++------ geonode/locale/it/LC_MESSAGES/django.mo | Bin 161218 -> 161218 bytes geonode/locale/it/LC_MESSAGES/django.po | 3 ++- .../maps/templates/layouts/map_panels.html | 13 +++++++------ 6 files changed, 28 insertions(+), 26 deletions(-) diff --git a/geonode/documents/templates/layouts/doc_panels.html b/geonode/documents/templates/layouts/doc_panels.html index 0c7c10667ba..e2dffb26eea 100644 --- a/geonode/documents/templates/layouts/doc_panels.html +++ b/geonode/documents/templates/layouts/doc_panels.html @@ -313,12 +313,6 @@ {{ document_form.title }}
    {% endblock doc_title %} - {% block doc_linked_resources %} -
    - - {{ document_form.linked_resources }} -
    - {% endblock doc_linked_resources %} {% block doc_abstract %}
    @@ -555,6 +549,12 @@ {{ document_form.extra_metadata }}
    {% endblock doc_extra_metadata %} + {% block doc_linked_resources %} +
    + + {{ document_form.linked_resources }} +
    + {% endblock doc_linked_resources %}
    diff --git a/geonode/geoapps/templates/layouts/app_panels.html b/geonode/geoapps/templates/layouts/app_panels.html index 855c10b1429..ddedc0eb6bf 100644 --- a/geonode/geoapps/templates/layouts/app_panels.html +++ b/geonode/geoapps/templates/layouts/app_panels.html @@ -297,13 +297,6 @@ {{ geoapp_form.title }} {% endblock %} - {% block geoapp_linked_resources %} -
    - - {{ geoapp_form.linked_resources }} -
    - {% endblock geoapp_linked_resources %} -
    @@ -501,6 +494,13 @@ {{ geoapp_form.extra_metadata }}
    {% endblock geoapp_extra_metadata %} + {% block geoapp_linked_resources %} +
    + + {{ geoapp_form.linked_resources }} +
    + {% endblock geoapp_linked_resources %} +
    {% endblock maintenance_block %}
    diff --git a/geonode/layers/templates/layouts/panels.html b/geonode/layers/templates/layouts/panels.html index dfd9d14e40a..4ee805635e8 100644 --- a/geonode/layers/templates/layouts/panels.html +++ b/geonode/layers/templates/layouts/panels.html @@ -331,12 +331,6 @@ {{ dataset_form.title }}
    {% endblock dataset_title %} - {% block dataset_linked_resources %} -
    - - {{ dataset_form.linked_resources }} -
    - {% endblock dataset_linked_resources %} {% block dataset_abstract %}
    @@ -563,6 +557,12 @@ {{ dataset_form.extra_metadata }}
    {% endblock layer_extra_metadata %} + {% block dataset_linked_resources %} +
    + + {{ dataset_form.linked_resources }} +
    + {% endblock dataset_linked_resources %}
    diff --git a/geonode/locale/it/LC_MESSAGES/django.mo b/geonode/locale/it/LC_MESSAGES/django.mo index 1b322e2d5ac2449ce20b34988a68104c7eb05a20..431f7fffb9b3ea4379b6793efad59a8fcaffacb6 100644 GIT binary patch delta 24 gcmX@~n)A?W&W0AoEljrsS#okx)3@IjWO|$o0F1;6s{jB1 delta 24 gcmX@~n)A?W&W0AoEljrsS&E8MbGF|XWO|$o0F7A+yZ`_I diff --git a/geonode/locale/it/LC_MESSAGES/django.po b/geonode/locale/it/LC_MESSAGES/django.po index 7b4f071266c..6826098fc12 100644 --- a/geonode/locale/it/LC_MESSAGES/django.po +++ b/geonode/locale/it/LC_MESSAGES/django.po @@ -948,7 +948,8 @@ msgid "Link to" msgstr "Collegamento a" msgid "Related resources" -msgstr "Risorse correlate" +msgstr "Risorse collegate" +#msgstr "Risorse correlate" msgid "File" msgstr "File" diff --git a/geonode/maps/templates/layouts/map_panels.html b/geonode/maps/templates/layouts/map_panels.html index 1dc3deb8ff6..eb163abdf2d 100644 --- a/geonode/maps/templates/layouts/map_panels.html +++ b/geonode/maps/templates/layouts/map_panels.html @@ -317,12 +317,6 @@ {{ map_form.title }}
    {% endblock map_title %} - {% block map_linked_resources %} -
    - - {{ map_form.linked_resources }} -
    - {% endblock map_linked_resources %} {% block map_abstract %}
    @@ -550,6 +544,13 @@ {{ map_form.extra_metadata }}
    {% endblock map_extra_metadata %} + {% block map_linked_resources %} +
    + + {{ map_form.linked_resources }} +
    + {% endblock map_linked_resources %} +
    {% endblock maintenance_block %}
    From 0501d041fff04815ff2671f7704350ce37f40044 Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Fri, 13 Oct 2023 12:09:37 +0200 Subject: [PATCH 270/330] Rename workflow build --- .github/workflows/52n-build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/52n-build.yaml b/.github/workflows/52n-build.yaml index 18a9b21175f..cf82d61d0ee 100644 --- a/.github/workflows/52n-build.yaml +++ b/.github/workflows/52n-build.yaml @@ -1,7 +1,7 @@ -name: Push Thünen Atlas Docker Image(s) +name: "[thuenen_4.x -> 4.x] Push Thünen Atlas Docker Image(s)" -concurrency: - group: "geonode_build" +concurrency: + group: "geonode_build_thuenen_4.x" cancel-in-progress: true env: @@ -15,7 +15,7 @@ on: push: branches: - "thuenen_4.x" - paths-ignore: + paths-ignore: - "./.github/workflows/52n-dockerhub-description.yaml" - "./README_thuenen.md" @@ -32,7 +32,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - - + - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v4 From bba1840ca4e41c473d76d78d41b834faf96d9a35 Mon Sep 17 00:00:00 2001 From: Henning Bredel Date: Fri, 13 Oct 2023 12:24:05 +0200 Subject: [PATCH 271/330] Rename workflow file --- .github/workflows/{52n-build.yaml => 52n-build-4.x.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{52n-build.yaml => 52n-build-4.x.yaml} (100%) diff --git a/.github/workflows/52n-build.yaml b/.github/workflows/52n-build-4.x.yaml similarity index 100% rename from .github/workflows/52n-build.yaml rename to .github/workflows/52n-build-4.x.yaml From c3da6186e07365f148d6b5de8196048011bf050e Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Mon, 16 Oct 2023 12:54:40 +0200 Subject: [PATCH 272/330] fix image template src reference (#11604) --- geonode/layers/api/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/geonode/layers/api/serializers.py b/geonode/layers/api/serializers.py index 3d39fdefe20..d5733663a56 100644 --- a/geonode/layers/api/serializers.py +++ b/geonode/layers/api/serializers.py @@ -101,7 +101,7 @@ def get_attribute(self, instance): elif _field.featureinfo_type == Attribute.TYPE_IMAGE: _template += ( '' + %s
    ' % (_field.attribute, _field.attribute, _label, _label) ) elif _field.featureinfo_type in ( @@ -242,4 +242,4 @@ class DatasetMetadataSerializer(serializers.Serializer): metadata_file = MetadataFileField(required=True) class Meta: - fields = "metadata_file" + fields = "metadata_file" \ No newline at end of file From cf67edae004be5fbf8fcfcbea408d3dd9c569416 Mon Sep 17 00:00:00 2001 From: Emanuele Tajariol Date: Mon, 16 Oct 2023 12:55:54 +0200 Subject: [PATCH 273/330] [Fixes #11574] Fix CircleCI black tests (#11580) * [Fixes #11574] Fix CircleCI black tests * [Fixes #11574] Fix CircleCI black tests * [Fixes #11574] Fix CircleCI black tests * [Fixes #11574] Fix CircleCI black tests * Adding black * Try some failing changes * Try some failing changes * Try some failing changes * Fix forced error * Update api.py * Run flake action on master and any 4.x branch --------- Co-authored-by: Giovanni Allegri --- .circleci/config.yml | 2 - .github/workflows/codeql-analysis.yml | 72 --------------------------- .github/workflows/flake8.yml | 40 +++++++++++++++ 3 files changed, 40 insertions(+), 74 deletions(-) delete mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/flake8.yml diff --git a/.circleci/config.yml b/.circleci/config.yml index 9babc1a6978..22de58db724 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -90,8 +90,6 @@ jobs: - run: name: Run code quality checks command: | - docker-compose --env-file .env_test -f docker-compose-test.yml exec django bash -c 'black --check geonode' - docker-compose --env-file .env_test -f docker-compose-test.yml exec django bash -c 'flake8 geonode' docker-compose --env-file .env_test -f docker-compose-test.yml exec django bash -c 'codecov; bash <(curl -s https://codecov.io/bash) -t 2c0e7780-1640-45f0-93a3-e103b057d8c8' working_directory: ./ diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index a75fd07bd10..00000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,72 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ "master", 4.0.x, 4.1.x ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "master", 4.0.x, 4.1.x ] - schedule: - - cron: '38 4 * * 5' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'javascript', 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml new file mode 100644 index 00000000000..a8304f8a1ac --- /dev/null +++ b/.github/workflows/flake8.yml @@ -0,0 +1,40 @@ +name: "Code formatting" + +on: + push: + branches: [ master, 4.* ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master, 4.* ] + +jobs: + validate: + + strategy: + fail-fast: false + matrix: + python-version: [ '3.10', '3.11'] + + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Python ${{matrix.python-version}} + uses: actions/setup-python@v2 + with: + python-version: ${{matrix.python-version}} + + - name: Install flake8 & black + run: pip install flake8 black + + - name: "Check: flake8" + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 geonode --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 geonode --count --statistics + + - name: "Check: black" + run: black --check geonode From 47a9aa6f86575d07a70ecf19573fccde7687cc42 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Mon, 16 Oct 2023 13:06:04 +0200 Subject: [PATCH 274/330] fix black formatting (#11605) --- geonode/layers/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geonode/layers/api/serializers.py b/geonode/layers/api/serializers.py index d5733663a56..304e89467c5 100644 --- a/geonode/layers/api/serializers.py +++ b/geonode/layers/api/serializers.py @@ -242,4 +242,4 @@ class DatasetMetadataSerializer(serializers.Serializer): metadata_file = MetadataFileField(required=True) class Meta: - fields = "metadata_file" \ No newline at end of file + fields = "metadata_file" From 9ca2f13a083e9ca37cd368106864f32e33176499 Mon Sep 17 00:00:00 2001 From: Henning Bredel <881756+ridoo@users.noreply.github.com> Date: Tue, 17 Oct 2023 10:32:45 +0200 Subject: [PATCH 275/330] Enumerate unzipped files (#10842) * Keep all uploaded zip content accessible iterdir() is platform dependent, that is the order of the returned items may be different on different platforms. In cases where a zip file contains multiple base_file candidates it will be overridden by the last one found (which varies on different platforms). Also, different files with the same extension (file1.csv, file2.csv) will not be accessible from file_paths as they get overridden, too. The fix enumerates all files to make them accessible from file_paths. * Sorts files during unzip Ensures that unpacked content is sorted before getting handled * Resolve minor issues * Ensure index on extensions found multiple times --- geonode/storage/data_retriever.py | 31 +++++++++++++++++++------ geonode/storage/tests.py | 16 ++++++++++++- geonode/storage/tests/data/example.zip | Bin 140 -> 324 bytes 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/geonode/storage/data_retriever.py b/geonode/storage/data_retriever.py index 059a3122a74..bc23997a8a2 100644 --- a/geonode/storage/data_retriever.py +++ b/geonode/storage/data_retriever.py @@ -205,17 +205,34 @@ def _unzip(self, zip_name: str) -> Mapping: at the end the zip is deleted """ zip_file = self.file_paths["base_file"] - the_zip = zipfile.ZipFile(zip_file, allowZip64=True) - the_zip.extractall(self.temporary_folder) + with zipfile.ZipFile(zip_file, allowZip64=True) as the_zip: + the_zip.extractall(self.temporary_folder) + available_choices = get_allowed_extensions() not_main_files = ["xml", "sld", "zip", "kmz"] base_file_choices = [x for x in available_choices if x not in not_main_files] - for _file in Path(self.temporary_folder).iterdir(): - if any([_file.name.endswith(_ext) for _ext in base_file_choices]): - self.file_paths["base_file"] = Path(str(_file)) - elif not zipfile.is_zipfile(str(_file)): + sorted_files = sorted(Path(self.temporary_folder).iterdir()) + for _file in sorted_files: + if not zipfile.is_zipfile(str(_file)): + if any([_file.name.endswith(_ext) for _ext in base_file_choices]): + self.file_paths["base_file"] = Path(str(_file)) ext = _file.name.split(".")[-1] - self.file_paths[f"{ext}_file"] = Path(str(_file)) + if f"{ext}_file" in self.file_paths: + existing = self.file_paths[f"{ext}_file"] + self.file_paths[f"{ext}_file"] = [ + Path(str(_file)), + *(existing if isinstance(existing, list) else [existing]), + ] + else: + self.file_paths[f"{ext}_file"] = Path(str(_file)) + + tmp = self.file_paths.copy() + for key, value in self.file_paths.items(): + if isinstance(value, list): + for index, file_path in enumerate(value): + n = f"{key}_{index}" if index > 0 else key + tmp[n] = file_path + self.file_paths = tmp # remiving the zip file os.remove(zip_name) diff --git a/geonode/storage/tests.py b/geonode/storage/tests.py index 48d1f3ebfe2..9a96186ab3e 100644 --- a/geonode/storage/tests.py +++ b/geonode/storage/tests.py @@ -573,7 +573,21 @@ def test_zip_file_should_correctly_recognize_main_extension_with_csv(self): self.assertIsNotNone(storage_manager.data_retriever.temporary_folder) _files = storage_manager.get_retrieved_paths() - self.assertTrue("example.csv" in _files.get("base_file")) + # Selected base_file is not defined in case of multiple csv files + self.assertTrue(_files.get("base_file").endswith(".csv")) + + def test_zip_file_should_correctly_index_file_extensions(self): + # reinitiate the storage manager with the zip file + storage_manager = self.sut( + remote_files={"base_file": os.path.join(f"{self.project_root}", "tests/data/example.zip")} + ) + storage_manager.clone_remote_files() + + self.assertIsNotNone(storage_manager.data_retriever.temporary_folder) + _files = storage_manager.get_retrieved_paths() + self.assertIsNotNone(_files.get("csv_file")) + # extensions found more than once get indexed + self.assertIsNotNone(_files.get("csv_file_1")) @override_settings( SUPPORTED_DATASET_FILE_TYPES=[ diff --git a/geonode/storage/tests/data/example.zip b/geonode/storage/tests/data/example.zip index 099888b8ef30952a98553b0ffe978c41fdd87b58..bb54c689f548c71a5455a78973833b76f8feecb9 100644 GIT binary patch literal 324 zcmWIWW@Zs#W?neK-5V1YrgS22mj92IAC;#N2|MRK4WlveO>EK89P^Cc4F> zc3QSwb2!Ao5P(xJCs1#GNk(cBPMwTQ_RP5KRRQV-0fmMojUXDy60B=?{ko8PJxC2O+ HfjA5RA1F*t literal 140 zcmWIWW@Zs#U|`^22#J>neK-5V1Q1UYh`E6{wIVUMASYEXxw!1Khp&&}mU9e~++tEY rE!(a+9O7UI@MdHZVZf~&XaWNxhyVj5!vefn*+629Kxhf1ZNUlv5U3nd From 9d6a4ca0105db2f4bedab5b844fb7bf33b720dda Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Wed, 18 Oct 2023 10:42:01 +0200 Subject: [PATCH 276/330] [Fixes #11610] Make thumbs backgrounds tests run (#11611) * make thumbs bagrounds run * Fix thumbs backgrounds tests --- geonode/thumbs/background.py | 4 ++-- geonode/thumbs/tests/__init__.py | 1 + geonode/thumbs/tests/test_backgrounds.py | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/geonode/thumbs/background.py b/geonode/thumbs/background.py index 85bceb64159..1df55fc0dbc 100644 --- a/geonode/thumbs/background.py +++ b/geonode/thumbs/background.py @@ -542,8 +542,8 @@ def fetch(self, bbox: typing.List, *args, **kwargs): return background def build_kvp_request(self, baseurl, layer, style, xyz): - return f"{baseurl}?&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer={layer}&style={style} \ - &tilematrixset={self.options['tilematrixset']}&TileMatrix={xyz[2]}&TileRow={xyz[1]}&TileCol={xyz[0]}" + return f"{baseurl}?&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer={layer}&style={style}\ +&tilematrixset={self.options['tilematrixset']}&TileMatrix={xyz[2]}&TileRow={xyz[1]}&TileCol={xyz[0]}" def build_request(self, xyz): request_encoding = self.options.get("requestencoding", "KVP") diff --git a/geonode/thumbs/tests/__init__.py b/geonode/thumbs/tests/__init__.py index dee8ede78da..0bc5c5d5525 100644 --- a/geonode/thumbs/tests/__init__.py +++ b/geonode/thumbs/tests/__init__.py @@ -19,3 +19,4 @@ from .test_unit import * # noqa: F401, F403 from .test_integration import * # noqa: F401, F403 +from .test_backgrounds import * # noqa: F401, F403 diff --git a/geonode/thumbs/tests/test_backgrounds.py b/geonode/thumbs/tests/test_backgrounds.py index 896ef0685dc..d99adf680ce 100644 --- a/geonode/thumbs/tests/test_backgrounds.py +++ b/geonode/thumbs/tests/test_backgrounds.py @@ -276,7 +276,7 @@ THUMBNAIL_BACKGROUND = { "class": "geonode.thumbs.background.GenericWMTSBackground", "options": { - "url": "myserver.com/WMTS", + "url": "https://myserver.com/WMTS", "layer": "Hosted_basemap_inforac_3857", "style": "default", "tilematrixset": "default028mm", @@ -345,8 +345,8 @@ def test_get_tiles_coords(self, *args): @override_settings(THUMBNAIL_BACKGROUND=THUMBNAIL_BACKGROUND) @patch("geonode.thumbs.background.WMTS_TILEMATRIXSET_LEVELS", WMTS_TILEMATRIX_LEVELS) def test_build_request(self, *args): - expected_imgurl = "https://myserver.com/WMTS?&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png& \ - layer=Hosted_basemap_inforac_3857&style=default&tilematrixset=default028mm&TileMatrix=4&TileRow=5&TileCol=7" + expected_imgurl = "https://myserver.com/WMTS?&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&\ +layer=Hosted_basemap_inforac_3857&style=default&tilematrixset=default028mm&TileMatrix=4&TileRow=5&TileCol=7" background = GenericWMTSBackground(thumbnail_width=500, thumbnail_height=200) imgurl = background.build_request((7, 5, 4)) self.assertEqual(expected_imgurl, imgurl) From 5f99756d4ce2c2d555a17899dde3e3dbd505c66c Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 19 Oct 2023 12:57:59 +0200 Subject: [PATCH 277/330] [Fixes #11617] Introduce icons for thesauri keywords (#11618) * Introduce icons for thesauri keywords * fix black --- .../migrations/0087_thesauruskeyword_icon.py | 18 ++++++++++++++++++ geonode/base/models.py | 4 ++++ geonode/facets/providers/thesaurus.py | 3 ++- 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 geonode/base/migrations/0087_thesauruskeyword_icon.py diff --git a/geonode/base/migrations/0087_thesauruskeyword_icon.py b/geonode/base/migrations/0087_thesauruskeyword_icon.py new file mode 100644 index 00000000000..87dd7e45f96 --- /dev/null +++ b/geonode/base/migrations/0087_thesauruskeyword_icon.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.22 on 2023-10-18 14:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0086_linkedresource'), + ] + + operations = [ + migrations.AddField( + model_name='thesauruskeyword', + name='icon', + field=models.CharField(blank=True, max_length=512, null=True, help_text='It can be a fa-class name or a URL to an image'), + ), + ] diff --git a/geonode/base/models.py b/geonode/base/models.py index b5907a902ba..a2708f3b644 100644 --- a/geonode/base/models.py +++ b/geonode/base/models.py @@ -545,6 +545,10 @@ class ThesaurusKeyword(models.Model): thesaurus = models.ForeignKey("Thesaurus", related_name="thesaurus", on_delete=models.CASCADE) + icon = models.CharField( + max_length=512, help_text="It can be a fa-class name or a URL to an image", null=True, blank=True + ) + def __str__(self): return str(self.alt_label) diff --git a/geonode/facets/providers/thesaurus.py b/geonode/facets/providers/thesaurus.py index f8014728c9d..7594c009716 100644 --- a/geonode/facets/providers/thesaurus.py +++ b/geonode/facets/providers/thesaurus.py @@ -79,7 +79,7 @@ def get_facet_items( q = ( queryset.filter(**filter) - .values("tkeywords", "tkeywords__alt_label") + .values("tkeywords", "tkeywords__alt_label", "tkeywords__icon") .annotate(count=Count("tkeywords")) .annotate( localized_label=Subquery( @@ -102,6 +102,7 @@ def get_facet_items( "label": r["localized_label"] or r["tkeywords__alt_label"], "is_localized": r["localized_label"] is not None, "count": r["count"], + "icon": r["tkeywords__icon"], } for r in q[start:end].all() ] From 0868e34013404bdf99b52689a5a1db3cb7a454be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Oct 2023 13:03:02 +0200 Subject: [PATCH 278/330] Bump markdown from 3.4.4 to 3.5 (#11572) * Bump markdown from 3.4.4 to 3.5 Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.4.4 to 3.5. - [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md) - [Commits](https://github.com/Python-Markdown/markdown/compare/3.4.4...3.5) --- updated-dependencies: - dependency-name: markdown dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Update setup.cfg --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Giovanni Allegri --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 39864fb3294..d4c591ceeb0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -82,7 +82,7 @@ drf-extensions==0.7.1 drf-writable-nested==0.7.0 drf-spectacular==0.26.5 dynamic-rest==2.1.2 -Markdown==3.4.4 +Markdown==3.5 pinax-notifications==6.0.0 pinax-ratings==4.0.0 diff --git a/setup.cfg b/setup.cfg index 3c2147edf46..789f545d99a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -108,7 +108,7 @@ install_requires = drf-writable-nested==0.7.0 drf-spectacular==0.26.5 dynamic-rest==2.1.2 - Markdown==3.4.4 + Markdown==3.5 pinax-notifications==6.0.0 pinax-ratings==4.0.0 From 31ea8c8ddefded4bda913704716dcde4d583b8da Mon Sep 17 00:00:00 2001 From: Alessio Fabiani Date: Thu, 19 Oct 2023 14:35:12 +0200 Subject: [PATCH 279/330] [Snyk] Security upgrade urllib3 from 1.26.17 to 1.26.18 (#11619) * fix: requirements.txt to reduce vulnerabilities The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-URLLIB3-6002459 * Update setup.cfg --------- Co-authored-by: snyk-bot Co-authored-by: Giovanni Allegri --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index d4c591ceeb0..3d5dada6563 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ beautifulsoup4==4.12.2 httplib2<0.22.1 hyperlink==21.0.0 idna>=2.5,<3.5 -urllib3==1.26.17 +urllib3==1.26.18 Paver==1.3.4 python-slugify==8.0.1 decorator==5.1.1 diff --git a/setup.cfg b/setup.cfg index 789f545d99a..42ff67cbaeb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,7 +36,7 @@ install_requires = httplib2<0.22.1 hyperlink==21.0.0 idna>=2.5,<3.5 - urllib3==1.26.17 + urllib3==1.26.18 Paver==1.3.4 python-slugify==8.0.1 decorator==5.1.1 From 90dc8ef511bfb3257819a35b122878f01ba5a385 Mon Sep 17 00:00:00 2001 From: Emanuele Tajariol Date: Thu, 19 Oct 2023 14:35:47 +0200 Subject: [PATCH 280/330] [Fixes #11603] Improve codecov integration (#11607) * [Fixes #11603] Improve codecov integration * [Fixes #11603] Improve codecov integration * [Fixes #11603] Improve codecov integration * [Fixes #11603] Improve codecov integration * [Fixes #11603] Improve codecov integration --- .circleci/config.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 22de58db724..782732b6ff3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -11,6 +11,8 @@ jobs: type: boolean save_docker_cache: type: boolean + codecov_name: + type: string test_suite: type: string default: "" @@ -90,7 +92,7 @@ jobs: - run: name: Run code quality checks command: | - docker-compose --env-file .env_test -f docker-compose-test.yml exec django bash -c 'codecov; bash <(curl -s https://codecov.io/bash) -t 2c0e7780-1640-45f0-93a3-e103b057d8c8' + docker-compose --env-file .env_test -f docker-compose-test.yml exec django bash -c ' bash <(curl -s https://codecov.io/bash) -t 2c0e7780-1640-45f0-93a3-e103b057d8c8 -F <>' working_directory: ./ workflows: @@ -99,31 +101,37 @@ workflows: jobs: - build: name: geonode_test_suite_smoke + codecov_name: smoke_tests load_docker_cache: false save_docker_cache: false test_suite: ./test.sh geonode.tests.smoke geonode.tests.test_message_notifications geonode.tests.test_rest_api geonode.tests.test_search geonode.tests.test_utils geonode.tests.test_headers - build: name: geonode_test_suite + codecov_name: main_tests load_docker_cache: false save_docker_cache: false test_suite: ./test.sh $(python -c "import sys;from geonode import settings;sys.stdout.write('\'' '\''.join([a+'\''.tests'\'' for a in settings.GEONODE_APPS if '\''security'\'' not in a and '\''geoserver'\'' not in a]))") geonode.thumbs.tests geonode.people.tests geonode.people.socialaccount.providers.geonode_openid_connect.tests - build: name: geonode_test_security + codecov_name: security_tests load_docker_cache: false save_docker_cache: false test_suite: ./test.sh $(python -c "import sys;from geonode import settings;sys.stdout.write('\'' '\''.join([a+'\''.tests'\'' for a in settings.GEONODE_APPS if '\''security'\'' in a]))") - build: name: geonode_test_gis_backend + codecov_name: gis load_docker_cache: false save_docker_cache: false test_suite: ./test.sh $(python -c "import sys;from geonode import settings;sys.stdout.write('\'' '\''.join([a+'\''.tests'\'' for a in settings.GEONODE_APPS if '\''geoserver'\'' in a]))") - build: name: geonode_test_rest_apis + codecov_name: api load_docker_cache: false save_docker_cache: false test_suite: ./test.sh geonode.base.api.tests geonode.layers.api.tests geonode.maps.api.tests geonode.documents.api.tests geonode.geoapps.api.tests geonode.upload.api.tests - build: name: geonode_test_csw + codecov_name: csw load_docker_cache: false save_docker_cache: false test_suite: ./test.sh geonode.tests.csw geonode.catalogue.backends.tests From c437a0a69054217976fa519b245e82966543dbe5 Mon Sep 17 00:00:00 2001 From: Giovanni Allegri Date: Thu, 19 Oct 2023 17:39:13 +0200 Subject: [PATCH 281/330] [Fixes #11622] Rename icon filed to image field for thesaurus keywords (#11623) * Rename icon filed to image field for thesaurus keywords * add migration * fix formatting --- .../migrations/0088_auto_20231019_1244.py | 22 +++++++++++++++++++ geonode/base/models.py | 4 +--- geonode/facets/providers/thesaurus.py | 4 ++-- 3 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 geonode/base/migrations/0088_auto_20231019_1244.py diff --git a/geonode/base/migrations/0088_auto_20231019_1244.py b/geonode/base/migrations/0088_auto_20231019_1244.py new file mode 100644 index 00000000000..c96c97f6a2d --- /dev/null +++ b/geonode/base/migrations/0088_auto_20231019_1244.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.22 on 2023-10-19 12:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0087_thesauruskeyword_icon'), + ] + + operations = [ + migrations.RemoveField( + model_name='thesauruskeyword', + name='icon', + ), + migrations.AddField( + model_name='thesauruskeyword', + name='image', + field=models.CharField(blank=True, help_text='A URL to an image', max_length=512, null=True), + ), + ] diff --git a/geonode/base/models.py b/geonode/base/models.py index a2708f3b644..b316ae4a33b 100644 --- a/geonode/base/models.py +++ b/geonode/base/models.py @@ -545,9 +545,7 @@ class ThesaurusKeyword(models.Model): thesaurus = models.ForeignKey("Thesaurus", related_name="thesaurus", on_delete=models.CASCADE) - icon = models.CharField( - max_length=512, help_text="It can be a fa-class name or a URL to an image", null=True, blank=True - ) + image = models.CharField(max_length=512, help_text="A URL to an image", null=True, blank=True) def __str__(self): return str(self.alt_label) diff --git a/geonode/facets/providers/thesaurus.py b/geonode/facets/providers/thesaurus.py index 7594c009716..047ac9b82ea 100644 --- a/geonode/facets/providers/thesaurus.py +++ b/geonode/facets/providers/thesaurus.py @@ -79,7 +79,7 @@ def get_facet_items( q = ( queryset.filter(**filter) - .values("tkeywords", "tkeywords__alt_label", "tkeywords__icon") + .values("tkeywords", "tkeywords__alt_label", "tkeywords__image") .annotate(count=Count("tkeywords")) .annotate( localized_label=Subquery( @@ -102,7 +102,7 @@ def get_facet_items( "label": r["localized_label"] or r["tkeywords__alt_label"], "is_localized": r["localized_label"] is not None, "count": r["count"], - "icon": r["tkeywords__icon"], + "image": r["tkeywords__image"], } for r in q[start:end].all() ] From da856b5f1f674201005db2a5132b6d8c1776d7a0 Mon Sep 17 00:00:00 2001 From: Emanuele Tajariol Date: Mon, 23 Oct 2023 12:00:38 +0200 Subject: [PATCH 282/330] [Fixes #11620] Facets: refact view as a class (#11625) Co-authored-by: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> --- geonode/facets/tests.py | 52 +++--- geonode/facets/urls.py | 7 +- geonode/facets/views.py | 351 +++++++++++++++++++--------------------- 3 files changed, 190 insertions(+), 220 deletions(-) diff --git a/geonode/facets/tests.py b/geonode/facets/tests.py index 3245fe72d9a..8b4812a110f 100644 --- a/geonode/facets/tests.py +++ b/geonode/facets/tests.py @@ -42,8 +42,8 @@ from geonode.facets.providers.category import CategoryFacetProvider from geonode.facets.providers.keyword import KeywordFacetProvider from geonode.facets.providers.region import RegionFacetProvider +from geonode.facets.views import ListFacetsView, GetFacetView from geonode.tests.base import GeoNodeBaseTestSupport -import geonode.facets.views as views logger = logging.getLogger(__name__) @@ -85,7 +85,7 @@ def _create_thesauri(cls): for tn in range(2): t = Thesaurus.objects.create(identifier=f"t_{tn}", title=f"Thesaurus {tn}", order=100 + tn * 10) cls.thesauri[tn] = t - for tl in ( + for tl in ( # fmt: skip "en", "it", ): @@ -94,7 +94,7 @@ def _create_thesauri(cls): for tkn in range(10): tk = ThesaurusKeyword.objects.create(thesaurus=t, alt_label=f"T{tn}_K{tkn}_ALT") cls.thesauri_k[f"{tn}_{tkn}"] = tk - for tkl in ( + for tkl in ( # fmt: skip "en", "it", ): @@ -104,7 +104,7 @@ def _create_thesauri(cls): def _create_regions(cls): cls.regions = {} - for code, name in ( + for code, name in ( # fmt: skip ("R0", "Region0"), ("R1", "Region1"), ("R2", "Region2"), @@ -115,7 +115,7 @@ def _create_regions(cls): def _create_categories(cls): cls.cats = {} - for code, name in ( + for code, name in ( # fmt: skip ("C0", "Cat0"), ("C1", "Cat1"), ("C2", "Cat2"), @@ -126,7 +126,7 @@ def _create_categories(cls): def _create_keywords(cls): cls.kw = {} - for code, name in ( + for code, name in ( # fmt: skip ("K0", "Keyword0"), ("K1", "Keyword1"), ("K2", "Keyword2"), @@ -186,14 +186,14 @@ def _create_resources(self): if (x % 6) in (0, 1, 2): d.featured = True - for reg, idx in ( + for reg, idx in ( # fmt: skip ("R0", (0, 1)), ("R1", (0, 2, 8, 13)), ): if x in idx: d.regions.add(self.regions[reg]) - for kw, idx in ( + for kw, idx in ( # fmt: skip ("K0", (0, 3, 4, 5)), ("K1", [1, 4]), ("K2", [2, 5]), @@ -201,7 +201,7 @@ def _create_resources(self): if x in idx: d.keywords.add(self.kw[kw]) - for cat, idx in ( + for cat, idx in ( # fmt: skip ("C0", [0, 2, 4]), ("C1", [5, 15, 16]), ("C2", [18, 19]), @@ -218,7 +218,7 @@ def _facets_to_map(facets): def test_facets_base(self): req = self.rf.get(reverse("list_facets"), data={"lang": "en"}) - res: JsonResponse = views.list_facets(req) + res: JsonResponse = ListFacetsView.as_view()(req) obj = json.loads(res.content) self.assertIn("facets", obj) facets_list = obj["facets"] @@ -238,13 +238,13 @@ def test_facets_rich(self): # run the request req = self.rf.get(reverse("list_facets"), data={"include_topics": 1, "lang": "en"}) - res: JsonResponse = views.list_facets(req) + res: JsonResponse = ListFacetsView.as_view()(req) obj = json.loads(res.content) facets_list = obj["facets"] self.assertEqual(8, len(facets_list)) fmap = self._facets_to_map(facets_list) - for expected in ( + for expected in ( # fmt: skip { "name": "category", "topics": { @@ -356,7 +356,7 @@ def test_bad_lang(self): # run the request with a valid language req = self.rf.get(reverse("get_facet", args=["t_0"]), data={"lang": "en"}) - res: JsonResponse = views.get_facet(req, "t_0") + res: JsonResponse = GetFacetView.as_view()(req, "t_0") obj = json.loads(res.content) self.assertEqual(2, obj["topics"]["total"]) @@ -366,7 +366,7 @@ def test_bad_lang(self): # run the request with an INVALID language req = self.rf.get(reverse("get_facet", args=["t_0"]), data={"lang": "ZZ"}) - res: JsonResponse = views.get_facet(req, "t_0") + res: JsonResponse = GetFacetView.as_view()(req, "t_0") obj = json.loads(res.content) self.assertEqual(2, obj["topics"]["total"]) @@ -374,18 +374,6 @@ def test_bad_lang(self): self.assertEqual("T0_K0_ALT", obj["topics"]["items"][0]["label"]) # check for the alternate label self.assertFalse(obj["topics"]["items"][0]["is_localized"]) # check for the localization flag - def test_topics(self): - for facet, keys, exp in ( - ("t_0", [self.thesauri_k["0_0"].id, self.thesauri_k["0_1"].id, -999], 2), - ("category", ["C1", "C2", "nomatch"], 2), - ("owner", [self.user.id, -100], 1), - ("region", ["R0", "R1", "nomatch"], 2), - ): - req = self.rf.get(reverse("get_facet_topics", args=[facet]), data={"lang": "en", "key": keys}) - res: JsonResponse = views.get_facet_topics(req, facet) - obj = json.loads(res.content) - self.assertEqual(exp, len(obj["topics"]["items"]), f"Unexpected topic count {exp} for facet {facet}") - def test_prefiltering(self): reginfo = RegionFacetProvider().get_info() regfilter = reginfo["filter"] @@ -403,7 +391,7 @@ def test_prefiltering(self): (reginfo["name"], {t1filter: self.thesauri_k["1_0"].id}, 2, 3), ): req = self.rf.get(reverse("get_facet", args=[facet]), data=filters) - res: JsonResponse = views.get_facet(req, facet) + res: JsonResponse = GetFacetView.as_view()(req, facet) obj = json.loads(res.content) self.assertEqual(totals, obj["topics"]["total"], f"Bad totals for facet '{facet} and filter {filters}") self.assertEqual(count0, obj["topics"]["items"][0]["count"], f"Bad count0 for facet '{facet}") @@ -423,7 +411,7 @@ def test_prefiltering_tkeywords(self): (featname, {t1filter: tkey_1_1}, expected_feat), ): req = self.rf.get(reverse("get_facet", args=[facet]), data=params) - res: JsonResponse = views.get_facet(req, facet) + res: JsonResponse = GetFacetView.as_view()(req, facet) obj = json.loads(res.content) self.assertEqual( @@ -439,13 +427,13 @@ def test_prefiltering_tkeywords(self): # Run the single request req = self.rf.get(reverse("list_facets"), data={"include_topics": 1, t1filter: tkey_1_1}) - res: JsonResponse = views.list_facets(req) + res: JsonResponse = ListFacetsView.as_view()(req) obj = json.loads(res.content) facets_list = obj["facets"] fmap = self._facets_to_map(facets_list) - for name, items in ( + for name, items in ( # fmt: skip (regname, expected_region), (featname, expected_feat), ): @@ -466,7 +454,7 @@ def test_config(self): ("owner", "select", 8), ): req = self.rf.get(reverse("get_facet", args=[facet]), data={"include_config": True}) - res: JsonResponse = views.get_facet(req, facet) + res: JsonResponse = GetFacetView.as_view()(req, facet) obj = json.loads(res.content) self.assertIn("config", obj, "Config info not found in payload") conf = obj["config"] @@ -525,7 +513,7 @@ def t(tk): (kwname, {t0filter: t("0_0"), regfilter: "R0", "key": ["K0"]}, {"K0": None}), ): req = self.rf.get(reverse("get_facet", args=[facet]), data=params) - res: JsonResponse = views.get_facet(req, facet) + res: JsonResponse = GetFacetView.as_view()(req, facet) obj = json.loads(res.content) # self.assertEqual(totals, obj["topics"]["total"], f"Bad totals for facet '{facet} and params {params}") diff --git a/geonode/facets/urls.py b/geonode/facets/urls.py index 42bf08de85b..8d96976e53c 100644 --- a/geonode/facets/urls.py +++ b/geonode/facets/urls.py @@ -18,10 +18,9 @@ ######################################################################### from django.urls import path -from . import views +from .views import ListFacetsView, GetFacetView urlpatterns = [ - path("facets", views.list_facets, name="list_facets"), - path("facets/", views.get_facet, name="get_facet"), - path("facets//topics", views.get_facet_topics, name="get_facet_topics"), + path("facets", ListFacetsView.as_view(), name="list_facets"), + path("facets/", GetFacetView.as_view(), name="get_facet"), ] diff --git a/geonode/facets/views.py b/geonode/facets/views.py index a02c8aa961f..5bd186b7045 100644 --- a/geonode/facets/views.py +++ b/geonode/facets/views.py @@ -21,11 +21,10 @@ from urllib.parse import urlencode from rest_framework.authentication import SessionAuthentication, BasicAuthentication -from rest_framework.decorators import api_view, authentication_classes +from rest_framework.views import APIView -from django.http import HttpResponseNotFound, JsonResponse, HttpResponseBadRequest +from django.http import HttpResponseNotFound, JsonResponse from django.urls import reverse - from django.conf import settings from geonode.base.api.views import ResourceBaseViewSet @@ -44,188 +43,172 @@ logger = logging.getLogger(__name__) -@api_view(["GET"]) -@authentication_classes([SessionAuthentication, BasicAuthentication]) -def list_facets(request, **kwargs): - lang, lang_requested = _resolve_language(request) - add_links = _resolve_boolean(request, PARAM_ADD_LINKS, False) - include_topics = _resolve_boolean(request, PARAM_INCLUDE_TOPICS, False) - include_config = _resolve_boolean(request, PARAM_INCLUDE_CONFIG, False) - - facets = [] - prefiltered = None - - for provider in facet_registry.get_providers(): - logger.debug("Fetching data from provider %r", provider) - info = provider.get_info(lang=lang) +class BaseFacetingView(APIView): + authentication_classes = [SessionAuthentication, BasicAuthentication] + + @classmethod + def _get_topics( + cls, + provider, + queryset, + page: int = 0, + page_size: int = DEFAULT_FACET_PAGE_SIZE, + lang: str = "en", + topic_contains: str = None, + keys: set = {}, + **kwargs, + ): + start = page * page_size + end = start + page_size + + cnt, items = provider.get_facet_items( + queryset, start=start, end=end, lang=lang, topic_contains=topic_contains, keys=keys + ) + if keys: + keys.difference_update({str(t["key"]) for t in items}) + if keys: + ext = provider.get_topics(keys, lang) + items.extend(ext) + cnt += len(ext) + logger.debug("Extending facets to %d for %s", cnt, provider.name) + + return {"page": page, "page_size": page_size, "start": start, "total": cnt, "items": items} + + @classmethod + def _prefilter_topics(cls, request): + """ + Perform some prefiltering on resources, such as + - auth visibility + - filtering by other facets already applied + :param request: + :return: a QuerySet on ResourceBase + """ + logger.debug("Filtering by user '%s'", request.user) + filters = {k: vlist for k, vlist in request.query_params.lists() if k.startswith("filter{")} + logger.warning(f"FILTERING BY {filters}") + + if filters: + viewset = ResourceBaseViewSet(request=request, format_kwarg={}, kwargs=filters) + viewset.initial(request) + return get_visible_resources(queryset=viewset.filter_queryset(viewset.get_queryset()), user=request.user) + else: + # return ResourceBase.objects + return get_visible_resources(ResourceBase.objects, request.user) + + @classmethod + def _resolve_language(cls, request) -> (str, bool): + """ + :return: the resolved language, a boolean telling if the language was requested + """ + # first try with an explicit request using params + if lang := request.GET.get(PARAM_LANG, None): + return lang, True + # 2nd try: use LANGUAGE_CODE + try: + return request.LANGUAGE_CODE.split("-")[0], False + except AttributeError: + return settings.LANGUAGE_CODE, False + + @classmethod + def _resolve_boolean(cls, request, name, fallback=None): + """ + Parse boolean query params + """ + val = request.GET.get(name, None) + if val is None: + return fallback + + val = val.lower() + if val.startswith("t") or val.startswith("y") or val == "1": + return True + elif val.startswith("f") or val.startswith("n") or val == "0": + return False + else: + return fallback + + +class ListFacetsView(BaseFacetingView): + def get(self, request, *args, **kwargs): + lang, lang_requested = self._resolve_language(request) + add_links = self._resolve_boolean(request, PARAM_ADD_LINKS, False) + include_topics = self._resolve_boolean(request, PARAM_INCLUDE_TOPICS, False) + include_config = self._resolve_boolean(request, PARAM_INCLUDE_CONFIG, False) + + facets = [] + prefiltered = None + + for provider in facet_registry.get_providers(): + logger.debug("Fetching data from provider %r", provider) + info = provider.get_info(lang=lang) + + if include_config: + info["config"] = provider.config + + if add_links: + link_args = {PARAM_ADD_LINKS: True} + if lang_requested: # only add lang param if specified in current call + link_args[PARAM_LANG] = lang + info["link"] = f"{reverse('get_facet', args=[info['name']])}?{urlencode(link_args)}" + + if include_topics: + prefiltered = prefiltered or self._prefilter_topics(request) + info["topics"] = self._get_topics(provider, queryset=prefiltered, lang=lang) + + facets.append(info) + + logger.debug("Returning facets %r", facets) + return JsonResponse({"facets": facets}) + + +class GetFacetView(BaseFacetingView): + def get(self, request, facet): + logger.debug("get_facet -> %r for user '%r'", facet, request.user.username) + + # retrieve provider for the requested facet + provider: FacetProvider = facet_registry.get_provider(facet) + if not provider: + return HttpResponseNotFound() + + # parse some query params + lang, lang_requested = self._resolve_language(request) + add_link = self._resolve_boolean(request, PARAM_ADD_LINKS, False) + include_config = self._resolve_boolean(request, PARAM_INCLUDE_CONFIG, False) + + topic_contains = request.GET.get(PARAM_TOPIC_CONTAINS, None) + keys = set(request.query_params.getlist("key")) + + page = int(request.GET.get(PARAM_PAGE, 0)) + page_size = int(request.GET.get(PARAM_PAGE_SIZE, DEFAULT_FACET_PAGE_SIZE)) + + info = provider.get_info(lang) if include_config: info["config"] = provider.config - if add_links: - link_args = {PARAM_ADD_LINKS: True} - if lang_requested: # only add lang param if specified in current call - link_args[PARAM_LANG] = lang - info["link"] = f"{reverse('get_facet', args=[info['name']])}?{urlencode(link_args)}" - - if include_topics: - prefiltered = prefiltered or _prefilter_topics(request) - info["topics"] = _get_topics(provider, queryset=prefiltered, lang=lang) - - facets.append(info) - - logger.debug("Returning facets %r", facets) - return JsonResponse({"facets": facets}) - - -@api_view(["GET"]) -@authentication_classes([SessionAuthentication, BasicAuthentication]) -def get_facet(request, facet): - logger.debug("get_facet -> %r for user '%r'", facet, request.user.username) - - # retrieve provider for the requested facet - provider: FacetProvider = facet_registry.get_provider(facet) - if not provider: - return HttpResponseNotFound() - - # parse some query params - lang, lang_requested = _resolve_language(request) - add_link = _resolve_boolean(request, PARAM_ADD_LINKS, False) - include_config = _resolve_boolean(request, PARAM_INCLUDE_CONFIG, False) - - topic_contains = request.GET.get(PARAM_TOPIC_CONTAINS, None) - keys = set(request.query_params.getlist("key")) - - page = int(request.GET.get(PARAM_PAGE, 0)) - page_size = int(request.GET.get(PARAM_PAGE_SIZE, DEFAULT_FACET_PAGE_SIZE)) - - info = provider.get_info(lang) - if include_config: - info["config"] = provider.config - - qs = _prefilter_topics(request) - topics = _get_topics( - provider, queryset=qs, page=page, page_size=page_size, lang=lang, topic_contains=topic_contains, keys=keys - ) - - if add_link: - exist_prev = page > 0 - exist_next = topics["total"] > (page + 1) * page_size - link = reverse("get_facet", args=[info["name"]]) - for exist, link_name, p in ( - (exist_prev, "prev", page - 1), - (exist_next, "next", page + 1), - ): - link_param = {PARAM_PAGE: p, PARAM_PAGE_SIZE: page_size, PARAM_LANG: lang, PARAM_ADD_LINKS: True} - if lang_requested: # only add lang param if specified in current call - link_param[PARAM_LANG] = lang - if topic_contains: - link_param[PARAM_TOPIC_CONTAINS] = topic_contains - info[link_name] = f"{link}?{urlencode(link_param)}" if exist else None - - if topic_contains: - # in the payload let's rmb this is a filtered output - info["topic_contains"] = topic_contains - - info["topics"] = topics - - return JsonResponse(info) - - -@api_view(["GET"]) -def get_facet_topics(request, facet): - logger.debug("get_facet_topics -> %r", facet) - - # retrieve provider for the requested facet - provider: FacetProvider = facet_registry.get_provider(facet) - if not provider: - return HttpResponseNotFound("Facet not found") - - # parse some query params - lang, lang_requested = _resolve_language(request) - keys = request.query_params.getlist("key") - if not keys: - return HttpResponseBadRequest("Missing key parameter") - - ret = {"topics": {"items": provider.get_topics(keys, lang=lang)}} - return JsonResponse(ret) - - -def _get_topics( - provider, - queryset, - page: int = 0, - page_size: int = DEFAULT_FACET_PAGE_SIZE, - lang: str = "en", - topic_contains: str = None, - keys: set = {}, - **kwargs, -): - start = page * page_size - end = start + page_size - - cnt, items = provider.get_facet_items( - queryset, start=start, end=end, lang=lang, topic_contains=topic_contains, keys=keys - ) - - if keys: - keys.difference_update({str(t["key"]) for t in items}) - if keys: - ext = provider.get_topics(keys, lang) - items.extend(ext) - cnt += len(ext) - logger.debug("Extending facets to %d for %s", cnt, provider.name) - - return {"page": page, "page_size": page_size, "start": start, "total": cnt, "items": items} - - -def _prefilter_topics(request): - """ - Perform some prefiltering on resources, such as - - auth visibility - - filtering by other facets already applied - :param request: - :return: a QuerySet on ResourceBase - """ - logger.debug("Filtering by user '%s'", request.user) - filters = {k: vlist for k, vlist in request.query_params.lists() if k.startswith("filter{")} - logger.warning(f"FILTERING BY {filters}") - - if filters: - viewset = ResourceBaseViewSet(request=request, format_kwarg={}, kwargs=filters) - viewset.initial(request) - return get_visible_resources(queryset=viewset.filter_queryset(viewset.get_queryset()), user=request.user) - else: - # return ResourceBase.objects - return get_visible_resources(ResourceBase.objects, request.user) - - -def _resolve_language(request) -> (str, bool): - """ - :return: the resolved language, a boolean telling if the language was requested - """ - # first try with an explicit request using params - if lang := request.GET.get(PARAM_LANG, None): - return lang, True - # 2nd try: use LANGUAGE_CODE - try: - return request.LANGUAGE_CODE.split("-")[0], False - except AttributeError: - return settings.LANGUAGE_CODE, False - - -def _resolve_boolean(request, name, fallback=None): - """ - Parse boolean query params - """ - val = request.GET.get(name, None) - if val is None: - return fallback - - val = val.lower() - if val.startswith("t") or val.startswith("y") or val == "1": - return True - elif val.startswith("f") or val.startswith("n") or val == "0": - return False - else: - return fallback + qs = self._prefilter_topics(request) + topics = self._get_topics( + provider, queryset=qs, page=page, page_size=page_size, lang=lang, topic_contains=topic_contains, keys=keys + ) + + if add_link: + exist_prev = page > 0 + exist_next = topics["total"] > (page + 1) * page_size + link = reverse("get_facet", args=[info["name"]]) + for exist, link_name, p in ( + (exist_prev, "prev", page - 1), + (exist_next, "next", page + 1), + ): + link_param = {PARAM_PAGE: p, PARAM_PAGE_SIZE: page_size, PARAM_LANG: lang, PARAM_ADD_LINKS: True} + if lang_requested: # only add lang param if specified in current call + link_param[PARAM_LANG] = lang + if topic_contains: + link_param[PARAM_TOPIC_CONTAINS] = topic_contains + info[link_name] = f"{link}?{urlencode(link_param)}" if exist else None + + if topic_contains: + # in the payload let's rmb this is a filtered output + info["topic_contains"] = topic_contains + + info["topics"] = topics + + return JsonResponse(info) From f1a905b0070fe11e6f232fa97f432721038f8ebe Mon Sep 17 00:00:00 2001 From: mattiagiupponi <51856725+mattiagiupponi@users.noreply.github.com> Date: Mon, 23 Oct 2023 14:16:44 +0200 Subject: [PATCH 283/330] Makes more clear the is_enable function in themes (#11631) * Makes more clear the is_enable function in themes * Update models.py * Update 0015_alter_geonodethemecustomization_is_enabled.py * fix format --- ...ter_geonodethemecustomization_is_enabled.py | 18 ++++++++++++++++++ geonode/themes/models.py | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 geonode/themes/migrations/0015_alter_geonodethemecustomization_is_enabled.py diff --git a/geonode/themes/migrations/0015_alter_geonodethemecustomization_is_enabled.py b/geonode/themes/migrations/0015_alter_geonodethemecustomization_is_enabled.py new file mode 100644 index 00000000000..fcd18f8ee3c --- /dev/null +++ b/geonode/themes/migrations/0015_alter_geonodethemecustomization_is_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.21 on 2023-10-23 10:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('geonode_themes', '0014_auto_20220214_0910'), + ] + + operations = [ + migrations.AlterField( + model_name='geonodethemecustomization', + name='is_enabled', + field=models.BooleanField(default=False, help_text='Set this theme as the current global theme for GeoNode. This will disable the current theme (if any)'), + ), + ] diff --git a/geonode/themes/models.py b/geonode/themes/models.py index 40f49478a1f..b94f9c6dd3c 100644 --- a/geonode/themes/models.py +++ b/geonode/themes/models.py @@ -54,7 +54,8 @@ class GeoNodeThemeCustomization(models.Model): name = models.CharField(max_length=100, help_text="This will not appear anywhere.") description = models.TextField(null=True, blank=True, help_text="This will not appear anywhere.") is_enabled = models.BooleanField( - default=False, help_text="Enabling this theme will disable the current enabled theme (if any)" + default=False, + help_text="Set this theme as the current global theme for GeoNode. This will disable the current theme (if any)", ) logo = models.ImageField(upload_to="img/%Y/%m", null=True, blank=True) extra_css = models.TextField( From b30312136a1c903d9e4b6ee568f23a56e573dc6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Wallschl=C3=A4ger?= Date: Mon, 23 Oct 2023 15:08:10 +0200 Subject: [PATCH 284/330] [Fixes #10290] complete ISO contact roles per ressource base with multiplicity (#10367) * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * fixed typo * created a centralized enum with the roles. added contacts to geonode_metadata_full.html. refactored * multiple poc are displayed in _resourcebase_info_panel * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Updates for PATCH for multiple contacts along with tests for each role. * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Fixes GeoNode#10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * Fixed default contact roles for new resource and added tests * black * black * Fixed AttributeError with TaggitProfileSelect2Custom * Fixes #10290 complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * - Fix formatting * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * [Fixes #10290] complete ISO contact roles per ressource base with multiplicity * [Fixes #10290] complete ISO contact roles per ressource base with multiplicity * Revert "[Fixes #10290] complete ISO contact roles per ressource base with multiplicity" This reverts commit 44b294b26a0ed0491ca30171c7612bee44c7b944. * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * issue#10290_complete_ISO_contact_roles_per_ressource_base_with_multiplicity * revert unintended changes from recent commit * revert unintended changes from recent commit * revert unintended changes from recent commit * revert unintended changes from recent commit --------- Co-authored-by: Malte Iwanicki Co-authored-by: Malte Iwanicki <45853662+MalteIwanicki@users.noreply.github.com> Co-authored-by: ahmdthr Co-authored-by: Florian Hoedt Co-authored-by: Giovanni Allegri Co-authored-by: Alessio Fabiani Co-authored-by: Marcel Wallschlaeger --- geonode/base/api/serializers.py | 67 ++- geonode/base/forms.py | 93 ++- .../base/migrations/0086_linkedresource.py | 26 +- geonode/base/models.py | 332 ++++++++--- .../base/_resourcebase_info_panel.html | 6 +- geonode/base/templatetags/base_tags.py | 6 + geonode/base/tests.py | 106 +++- geonode/base/widgets.py | 18 + .../backends/pycsw_local_mappings.py | 1 + .../templates/catalogue/full_metadata.xml | 131 +---- .../templates/geonode_metadata_full.html | 12 +- geonode/catalogue/views.py | 33 +- geonode/documents/api/tests.py | 302 ++++++++++ .../0037_delete_documentresourcelink.py | 15 +- .../documents/document_metadata.html | 8 +- .../documents/document_metadata_advanced.html | 8 +- .../templates/layouts/doc_panels.html | 46 +- geonode/documents/views.py | 62 +- .../geoapps/templates/apps/app_metadata.html | 2 +- .../templates/apps/app_metadata_advanced.html | 2 +- .../geoapps/templates/layouts/app_panels.html | 30 +- geonode/geoapps/views.py | 65 +- geonode/geoserver/helpers.py | 8 +- .../harvesting/harvesters/geonodeharvester.py | 1 + geonode/layers/api/tests.py | 298 ++++++++++ .../templates/datasets/dataset_metadata.html | 8 +- .../datasets/dataset_metadata_advanced.html | 8 +- geonode/layers/templates/layouts/panels.html | 56 +- geonode/layers/templatetags/contact_roles.py | 43 ++ geonode/layers/tests.py | 41 ++ geonode/layers/views.py | 72 +-- geonode/locale/de/LC_MESSAGES/django.mo | Bin 156640 -> 156832 bytes geonode/locale/de/LC_MESSAGES/django.po | 6 + geonode/locale/en/LC_MESSAGES/django.mo | Bin 143908 -> 144085 bytes geonode/locale/en/LC_MESSAGES/django.po | 6 + geonode/locale/fr/LC_MESSAGES/django.mo | Bin 161109 -> 161262 bytes geonode/locale/fr/LC_MESSAGES/django.po | 6 + geonode/locale/it/LC_MESSAGES/django.mo | Bin 161218 -> 20525 bytes geonode/locale/it/LC_MESSAGES/django.po | 6 + geonode/maps/api/tests.py | 1 - geonode/maps/models.py | 2 +- .../maps/templates/layouts/map_panels.html | 52 +- geonode/maps/templates/maps/map_metadata.html | 2 +- .../templates/maps/map_metadata_advanced.html | 2 +- geonode/maps/views.py | 51 +- geonode/people/__init__.py | 63 ++ geonode/people/forms.py | 1 + geonode/resource/utils.py | 17 +- geonode/settings.py | 7 + geonode/templates/metadata_detail.html | 555 ++++++++++++++++-- 50 files changed, 2106 insertions(+), 577 deletions(-) create mode 100644 geonode/layers/templatetags/contact_roles.py diff --git a/geonode/base/api/serializers.py b/geonode/base/api/serializers.py index 077c605361b..01887f4fe4e 100644 --- a/geonode/base/api/serializers.py +++ b/geonode/base/api/serializers.py @@ -28,12 +28,14 @@ from django.forms.models import model_to_dict from django.contrib.auth import get_user_model from django.db.models.query import QuerySet +from geonode.people import Roles from django.http import QueryDict from deprecated import deprecated from rest_framework import serializers from rest_framework_gis import fields from rest_framework.reverse import reverse, NoReverseMatch +from rest_framework.exceptions import ParseError from dynamic_rest.serializers import DynamicEphemeralSerializer, DynamicModelSerializer from dynamic_rest.fields.fields import DynamicRelationField, DynamicComputedField @@ -379,15 +381,44 @@ def to_representation(self, instance): class ContactRoleField(DynamicComputedField): - def __init__(self, contat_type, **kwargs): - self.contat_type = contat_type + default_error_messages = { + "required": ("ContactRoleField This field is required."), + } + + def __init__(self, contact_type, **kwargs): + self.contact_type = contact_type super().__init__(**kwargs) def get_attribute(self, instance): - return getattr(instance, self.contat_type) + return getattr(instance, self.contact_type) def to_representation(self, value): - return UserSerializer(embed=True, many=False).to_representation(value) + return [UserSerializer(embed=True, many=False).to_representation(v) for v in value] + + def get_pks_of_users_to_set(self, value): + pks_of_users_to_set = [] + for val in value: + # make it possible to set contact roles via username or pk through API + if "username" in val and "pk" in val: + pk = val["pk"] + username = val["username"] + pk_user = get_user_model().objects.get(pk=pk) + username_user = get_user_model().objects.get(username=username) + if pk_user.pk != username_user.pk: + raise ParseError( + detail=f"user with pk: {pk} and username: {username} is not the same ... ", code=403 + ) + pks_of_users_to_set.append(pk) + elif "username" in val: + username = val["username"] + username_user = get_user_model().objects.get(username=[username]) + pks_of_users_to_set.append(username_user.pk) + elif "pk" in val: + pks_of_users_to_set.append(val["pk"]) + return pks_of_users_to_set + + def to_internal_value(self, value): + return get_user_model().objects.filter(pk__in=self.get_pks_of_users_to_set(value)) class ExtentBboxField(DynamicComputedField): @@ -479,16 +510,22 @@ def __init__(self, *args, **kwargs): self.fields["uuid"] = serializers.CharField(read_only=True) self.fields["resource_type"] = serializers.CharField(required=False) self.fields["polymorphic_ctype_id"] = serializers.CharField(read_only=True) - self.fields["owner"] = DynamicRelationField( - UserSerializer, embed=True, many=False, read_only=True, required=False - ) - self.fields["poc"] = ContactRoleField("poc", read_only=True) - self.fields["metadata_author"] = ContactRoleField("metadata_author", read_only=True) - self.fields["title"] = serializers.CharField() + self.fields["owner"] = DynamicRelationField(UserSerializer, embed=True, many=False, read_only=True) + self.fields["metadata_author"] = ContactRoleField(Roles.METADATA_AUTHOR.name, required=False) + self.fields["processor"] = ContactRoleField(Roles.PROCESSOR.name, required=False) + self.fields["publisher"] = ContactRoleField(Roles.PUBLISHER.name, required=False) + self.fields["custodian"] = ContactRoleField(Roles.CUSTODIAN.name, required=False) + self.fields["poc"] = ContactRoleField(Roles.POC.name, required=False) + self.fields["distributor"] = ContactRoleField(Roles.DISTRIBUTOR.name, required=False) + self.fields["resource_user"] = ContactRoleField(Roles.RESOURCE_USER.name, required=False) + self.fields["resource_provider"] = ContactRoleField(Roles.RESOURCE_PROVIDER.name, required=False) + self.fields["originator"] = ContactRoleField(Roles.ORIGINATOR.name, required=False) + self.fields["principal_investigator"] = ContactRoleField(Roles.PRINCIPAL_INVESTIGATOR.name, required=False) + self.fields["title"] = serializers.CharField(required=False) self.fields["abstract"] = serializers.CharField(required=False) self.fields["attribution"] = serializers.CharField(required=False) self.fields["doi"] = serializers.CharField(required=False) - self.fields["alternate"] = serializers.CharField(read_only=True) + self.fields["alternate"] = serializers.CharField(read_only=True, required=False) self.fields["date"] = serializers.DateTimeField(required=False) self.fields["date_type"] = serializers.CharField(required=False) self.fields["temporal_extent_start"] = serializers.DateTimeField(required=False) @@ -559,6 +596,14 @@ class Meta: "owner", "poc", "metadata_author", + "processor", + "publisher", + "custodian", + "distributor", + "resource_user", + "resource_provider", + "originator", + "principal_investigator", "keywords", "tkeywords", "regions", diff --git a/geonode/base/forms.py b/geonode/base/forms.py index 1c720aac2d0..8c6bd6bd816 100644 --- a/geonode/base/forms.py +++ b/geonode/base/forms.py @@ -57,11 +57,12 @@ ThesaurusLabel, TopicCategory, ) -from geonode.base.widgets import TaggitSelect2Custom +from geonode.base.widgets import TaggitSelect2Custom, TaggitProfileSelect2Custom from geonode.base.fields import MultiThesauriField from geonode.documents.models import Document from geonode.layers.models import Dataset from geonode.base.utils import validate_extra_metadata, remove_country_from_languagecode +from geonode.people import Roles logger = logging.getLogger(__name__) @@ -348,6 +349,16 @@ def _get_thesauro_title_label(item, lang): return tname.first() +class ContactRoleMultipleChoiceField(forms.ModelMultipleChoiceField): + def clean(self, value) -> QuerySet: + try: + users = get_user_model().objects.filter(username__in=value) + except TypeError: + # value of not supported type ... + raise forms.ValidationError(_("Something went wrong in finding the profile(s) in a contact role form ...")) + return users + + class LinkedResourceForm(forms.ModelForm): linked_resources = forms.ModelMultipleChoiceField( label=_("Related resources"), @@ -418,8 +429,8 @@ class ResourceBaseForm(TranslationModelForm, LinkedResourceForm): data_quality_statement = forms.CharField(label=_("Data quality statement"), required=False, widget=TinyMCE()) owner = forms.ModelChoiceField( - empty_label=_("Owner"), - label=_("Owner"), + empty_label=_(Roles.OWNER.label), + label=_(Roles.OWNER.label), required=True, queryset=get_user_model().objects.exclude(username="AnonymousUser"), widget=autocomplete.ModelSelect2(url="autocomplete_profile"), @@ -448,20 +459,74 @@ class ResourceBaseForm(TranslationModelForm, LinkedResourceForm): widget=ResourceBaseDateTimePicker(options={"format": "YYYY-MM-DD HH:mm a"}), ) - poc = forms.ModelChoiceField( - empty_label=_("Person outside GeoNode (fill form)"), - label=_("Point of Contact"), - required=False, + metadata_author = ContactRoleMultipleChoiceField( + label=_(Roles.METADATA_AUTHOR.label), + required=Roles.METADATA_AUTHOR.is_required, queryset=get_user_model().objects.exclude(username="AnonymousUser"), - widget=autocomplete.ModelSelect2(url="autocomplete_profile"), + widget=TaggitProfileSelect2Custom(url="autocomplete_profile"), ) - metadata_author = forms.ModelChoiceField( - empty_label=_("Person outside GeoNode (fill form)"), - label=_("Metadata Author"), - required=False, + processor = ContactRoleMultipleChoiceField( + label=_(Roles.PROCESSOR.label), + required=Roles.PROCESSOR.is_required, queryset=get_user_model().objects.exclude(username="AnonymousUser"), - widget=autocomplete.ModelSelect2(url="autocomplete_profile"), + widget=TaggitProfileSelect2Custom(url="autocomplete_profile"), + ) + + publisher = ContactRoleMultipleChoiceField( + label=_(Roles.PUBLISHER.label), + required=Roles.PUBLISHER.is_required, + queryset=get_user_model().objects.exclude(username="AnonymousUser"), + widget=TaggitProfileSelect2Custom(url="autocomplete_profile"), + ) + + custodian = ContactRoleMultipleChoiceField( + label=_(Roles.CUSTODIAN.label), + required=Roles.CUSTODIAN.is_required, + queryset=get_user_model().objects.exclude(username="AnonymousUser"), + widget=TaggitProfileSelect2Custom(url="autocomplete_profile"), + ) + + poc = ContactRoleMultipleChoiceField( + label=_(Roles.POC.label), + required=Roles.POC.is_required, + queryset=get_user_model().objects.exclude(username="AnonymousUser"), + widget=TaggitProfileSelect2Custom(url="autocomplete_profile"), + ) + + distributor = ContactRoleMultipleChoiceField( + label=_(Roles.DISTRIBUTOR.label), + required=Roles.DISTRIBUTOR.is_required, + queryset=get_user_model().objects.exclude(username="AnonymousUser"), + widget=TaggitProfileSelect2Custom(url="autocomplete_profile"), + ) + + resource_user = ContactRoleMultipleChoiceField( + label=_(Roles.RESOURCE_USER.label), + required=Roles.RESOURCE_USER.is_required, + queryset=get_user_model().objects.exclude(username="AnonymousUser"), + widget=TaggitProfileSelect2Custom(url="autocomplete_profile"), + ) + + resource_provider = ContactRoleMultipleChoiceField( + label=_(Roles.RESOURCE_PROVIDER.label), + required=Roles.RESOURCE_PROVIDER.is_required, + queryset=get_user_model().objects.exclude(username="AnonymousUser"), + widget=TaggitProfileSelect2Custom(url="autocomplete_profile"), + ) + + originator = ContactRoleMultipleChoiceField( + label=_(Roles.ORIGINATOR.label), + required=Roles.ORIGINATOR.is_required, + queryset=get_user_model().objects.exclude(username="AnonymousUser"), + widget=TaggitProfileSelect2Custom(url="autocomplete_profile"), + ) + + principal_investigator = ContactRoleMultipleChoiceField( + label=_(Roles.PRINCIPAL_INVESTIGATOR.label), + required=Roles.PRINCIPAL_INVESTIGATOR.is_required, + queryset=get_user_model().objects.exclude(username="AnonymousUser"), + widget=TaggitProfileSelect2Custom(url="autocomplete_profile"), ) keywords = TagField( @@ -512,7 +577,7 @@ def __init__(self, *args, **kwargs): } ) - if field in ["poc", "owner"] and not self.can_change_perms: + if field in ["owner"] and not self.can_change_perms: self.fields[field].disabled = True def disable_keywords_widget_for_non_superuser(self, user): diff --git a/geonode/base/migrations/0086_linkedresource.py b/geonode/base/migrations/0086_linkedresource.py index cc5929e788d..e5b5f42d435 100644 --- a/geonode/base/migrations/0086_linkedresource.py +++ b/geonode/base/migrations/0086_linkedresource.py @@ -5,19 +5,31 @@ class Migration(migrations.Migration): - dependencies = [ - ('base', '0085_alter_resourcebase_uuid'), + ("base", "0085_alter_resourcebase_uuid"), ] operations = [ migrations.CreateModel( - name='LinkedResource', + name="LinkedResource", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='linked_to', to='base.resourcebase')), - ('target', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='linked_by', to='base.resourcebase')), - ('internal', models.BooleanField(default=False)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="linked_to", to="base.resourcebase" + ), + ), + ( + "target", + models.ForeignKey( + blank=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="linked_by", + to="base.resourcebase", + ), + ), + ("internal", models.BooleanField(default=False)), ], ), ] diff --git a/geonode/base/models.py b/geonode/base/models.py index b316ae4a33b..5768df93281 100644 --- a/geonode/base/models.py +++ b/geonode/base/models.py @@ -24,6 +24,7 @@ import uuid import logging import traceback +from typing import List, Optional, Union, Tuple from sequences.models import Sequence from sequences import get_next_value @@ -39,6 +40,7 @@ from django.contrib.auth.models import Group from django.core.files.base import ContentFile from django.contrib.auth import get_user_model +from django.db.models.query import QuerySet from django.db.models.fields.json import JSONField from django.utils.functional import cached_property, classproperty from django.contrib.gis.geos import GEOSGeometry, Polygon, Point @@ -81,6 +83,7 @@ from geonode.security.permissions import VIEW_PERMISSIONS, OWNER_PERMISSIONS from geonode.notifications_helper import send_notification, get_notification_recipients +from geonode.people import Roles from geonode.people.enumerations import ROLE_VALUES from urllib.parse import urlsplit, urljoin @@ -101,37 +104,6 @@ class ContactRole(models.Model): choices=ROLE_VALUES, max_length=255, help_text=_("function performed by the responsible " "party") ) - def clean(self): - """ - Make sure there is only one poc and author per resource - """ - - if not hasattr(self, "resource"): - # The ModelForm will already raise a Validation error for a missing resource. - # Re-raising an empty error here ensures the rest of this method isn't - # executed. - raise ValidationError("") - - if (self.role == self.resource.poc) or (self.role == self.resource.metadata_author): - contacts = self.resource.contacts.filter(contactrole__role=self.role) - if contacts.count() == 1: - # only allow this if we are updating the same contact - if self.contact != contacts.get(): - raise ValidationError(f"There can be only one {self.role} for a given resource") - if self.contact is None: - # verify that any unbound contact is only associated to one - # resource - bounds = ContactRole.objects.filter(contact=self.contact).count() - if bounds > 1: - raise ValidationError("There can be one and only one resource linked to an unbound contact" % self.role) - elif bounds == 1: - # verify that if there was one already, it corresponds to this - # instance - if ContactRole.objects.filter(contact=self.contact).get().id != self.id: - raise ValidationError( - "There can be one and only one resource linked to an unbound contact" % self.role - ) - class Meta: unique_together = (("contact", "resource", "role"),) @@ -1141,14 +1113,6 @@ def organizationname(self): def restriction_code(self): return self.restriction_code_type.gn_description if self.restriction_code_type else None - @property - def publisher(self): - return self.poc.get_full_name() or self.poc.username - - @property - def contributor(self): - return self.metadata_author.get_full_name() or self.metadata_author.username - @property def topiccategory(self): return self.category.identifier if self.category else None @@ -1666,10 +1630,10 @@ def set_missing_info(self): pass if user: - if self.poc is None: - self.poc = user - if self.metadata_author is None: - self.metadata_author = user + if len(self.poc) == 0: + self.poc = [user] + if len(self.metadata_author) == 0: + self.metadata_author = [user] from guardian.models import UserObjectPermission @@ -1689,45 +1653,273 @@ def maintenance_frequency_title(self): def language_title(self): return [v for v in enumerations.ALL_LANGUAGES if v[0] == self.language][0][1].title() - def _set_poc(self, poc): - # reset any poc assignation to this resource - ContactRole.objects.filter(role="pointOfContact", resource=self).delete() - # create the new assignation - ContactRole.objects.create(role="pointOfContact", resource=self, contact=poc) + # Contact Roles + def add_missing_metadata_author_or_poc(self): + """ + Set metadata_author and/or point of contact (poc) to a resource when any of them is missing + """ + if len(self.metadata_author) == 0: + self.metadata_author = [self.owner] + if len(self.poc) == 0: + self.poc = [self.owner] + + @staticmethod + def get_multivalue_role_property_names() -> List[str]: + """returns list of property names for all contact roles able to + handle multiple profile_users - def _get_poc(self): - try: - the_poc = ContactRole.objects.get(role="pointOfContact", resource=self).contact - except ContactRole.DoesNotExist: - the_poc = None - return the_poc + Returns: + _type_: List(str) + _description: list of names + """ + return [role.name for role in Roles.get_multivalue_ones()] - poc = property(_get_poc, _set_poc) + @staticmethod + def get_multivalue_required_role_property_names() -> List[str]: + """returns list of property names for all contact roles that are required - def _set_metadata_author(self, metadata_author): - # reset any metadata_author assignation to this resource - ContactRole.objects.filter(role="author", resource=self).delete() - # create the new assignation - ContactRole.objects.create(role="author", resource=self, contact=metadata_author) + Returns: + _type_: List(str) + _description: list of names + """ + return [role.name for role in (set(Roles.get_multivalue_ones()) & set(Roles.get_required_ones()))] - def _get_metadata_author(self): + @staticmethod + def get_ui_toggled_role_property_names() -> List[str]: + """returns list of property names for all contact roles that are toggled of in metadata_editor + + Returns: + _type_: List(str) + _description: list of names + """ + return [role.name for role in (set(Roles.get_toggled_ones()) & set(Roles.get_toggled_ones()))] + + # typing not possible due to: from geonode.base.forms import ResourceBaseForm; unable due to circular ... + def set_contact_roles_from_metadata_edit(self, resource_base_form) -> bool: + """gets a ResourceBaseForm and extracts the Contact Role elements from it + + Args: + resource_base_form (ResourceBaseForm): ResourceBaseForm with contact roles set + + Returns: + bool: true if all contact roles could be set, else false + """ + failed = False + for role in self.get_multivalue_role_property_names(): + try: + if resource_base_form.cleaned_data[role].exists(): + self.__setattr__(role, resource_base_form.cleaned_data[role]) + except AttributeError: + logger.warning(f"unable to set contact role {role} for {self} ...") + failed = True + return failed + + def __get_contact_role_elements__(self, role: str) -> Optional[List[settings.AUTH_USER_MODEL]]: + """general getter of for all contact roles except owner + + Args: + role (str): string coresponding to ROLE_VALUES in geonode/people/enumarations, defining which propery is requested + Returns: + Optional[List[settings.AUTH_USER_MODEL]]: returns the requested contact role from the database + """ try: - the_ma = ContactRole.objects.get(role="author", resource=self).contact + contact_role = ContactRole.objects.filter(role=role, resource=self) + contacts = [cr.contact for cr in contact_role] except ContactRole.DoesNotExist: - the_ma = None - return the_ma + contacts = None + return contacts - def add_missing_metadata_author_or_poc(self): + # types allowed as input for Contact role properties + CONTACT_ROLE_USER_PROFILES_ALLOWED_TYPES = Union[settings.AUTH_USER_MODEL, QuerySet, List[settings.AUTH_USER_MODEL]] + + def __set_contact_role_element__(self, user_profile: CONTACT_ROLE_USER_PROFILES_ALLOWED_TYPES, role: str): + """general setter for all contact roles except owner in resource base + + Args: + user_profile (CONTACT_ROLE_USER_PROFILES_ALLOWED_TYPES): _description_ + role (str): tring coresponding to ROLE_VALUES in geonode/people/enumarations, defining which propery is to set """ - Set metadata_author and/or point of contact (poc) to a resource when any of them is missing + + def __create_role__( + resource, role: str, user_profile: settings.AUTH_USER_MODEL + ) -> List[settings.AUTH_USER_MODEL]: + return ContactRole.objects.create(role=role, resource=resource, contact=user_profile) + + if isinstance(user_profile, QuerySet): + ContactRole.objects.filter(role=role, resource=self).delete() + return [__create_role__(self, role, user) for user in user_profile] + + elif isinstance(user_profile, get_user_model()): + ContactRole.objects.filter(role=role, resource=self).delete() + return __create_role__(self, role, user_profile) + + elif isinstance(user_profile, list) and all(isinstance(x, get_user_model()) for x in user_profile): + ContactRole.objects.filter(role=role, resource=self).delete() + return [__create_role__(self, role, profile) for profile in user_profile] + + elif user_profile is None: + ContactRole.objects.filter(role=role, resource=self).delete() + else: + logger.error(f"Bad profile format for role: {role} ...") + + def get_defined_multivalue_contact_roles(self) -> List[Tuple[List[settings.AUTH_USER_MODEL], str]]: + """Returns all set contact roles of the ressource with additional ROLE_VALUES from geonode.people.enumarations.ROLE_VALUES. Mainly used to generate output xml more easy. + + Returns: + _type_: List[Tuple[List[people object], roles_label_name]] + _description: list tuples including two elements: 1. list of people have a certain role. 2. role label + """ + return { + role.label: self.__getattribute__(role.name) + for role in Roles.get_multivalue_ones() + if self.__getattribute__(role.name) + } + + def get_first_contact_of_role(self, role: str) -> Optional[ContactRole]: + """ + Get the first contact from the specified role. + + Parameters: + role (str): The role of the contact. + + Returns: + ContactRole or None: The first contact with the specified role, or None if not found. """ - if not self.metadata_author: - self.metadata_author = self.owner - if not self.poc: - self.poc = self.owner + if contact := self.__get_contact_role_elements__(role): + return contact[0] + else: + return None + + # Contact Role: POC (pointOfContact) + def __get_poc__(self) -> List[settings.AUTH_USER_MODEL]: + return self.__get_contact_role_elements__(role="pointOfContact") + + def __set_poc__(self, user_profile): + return self.__set_contact_role_element__(user_profile=user_profile, role="pointOfContact") + + poc = property(__get_poc__, __set_poc__) + + @property + def poc_csv(self): + return ",".join(p.get_full_name() or p.username for p in self.poc) + + # Contact Role: metadata_author + def _get_metadata_author(self): + return self.__get_contact_role_elements__(role="author") + + def _set_metadata_author(self, user_profile): + return self.__set_contact_role_element__(user_profile=user_profile, role="author") metadata_author = property(_get_metadata_author, _set_metadata_author) + @property + def metadata_author_csv(self): + return ",".join(p.get_full_name() or p.username for p in self.metadata_author) + + # Contact Role: PROCESSOR + def _get_processor(self): + return self.__get_contact_role_elements__(role=Roles.PROCESSOR.name) + + def _set_processor(self, user_profile): + return self.__set_contact_role_element__(user_profile=user_profile, role=Roles.PROCESSOR.name) + + processor = property(_get_processor, _set_processor) + + @property + def processor_csv(self): + return ",".join(p.get_full_name() or p.username for p in self.processor) + + # Contact Role: PUBLISHER + def _get_publisher(self): + return self.__get_contact_role_elements__(role=Roles.PUBLISHER.name) + + def _set_publisher(self, user_profile): + return self.__set_contact_role_element__(user_profile=user_profile, role=Roles.PUBLISHER.name) + + publisher = property(_get_publisher, _set_publisher) + + @property + def publisher_csv(self): + return ",".join(p.get_full_name() or p.username for p in self.publisher) + + # Contact Role: CUSTODIAN + def _get_custodian(self): + return self.__get_contact_role_elements__(role=Roles.CUSTODIAN.name) + + def _set_custodian(self, user_profile): + return self.__set_contact_role_element__(user_profile=user_profile, role=Roles.CUSTODIAN.name) + + custodian = property(_get_custodian, _set_custodian) + + @property + def custodian_csv(self): + return ",".join(p.get_full_name() or p.username for p in self.custodian) + + # Contact Role: DISTRIBUTOR + def _get_distributor(self): + return self.__get_contact_role_elements__(role=Roles.DISTRIBUTOR.name) + + def _set_distributor(self, user_profile): + return self.__set_contact_role_element__(user_profile=user_profile, role=Roles.DISTRIBUTOR.name) + + distributor = property(_get_distributor, _set_distributor) + + @property + def distributor_csv(self): + return ",".join(p.get_full_name() or p.username for p in self.distributor) + + # Contact Role: RESOURCE_USER + def _get_resource_user(self): + return self.__get_contact_role_elements__(role=Roles.RESOURCE_USER.name) + + def _set_resource_user(self, user_profile): + return self.__set_contact_role_element__(user_profile=user_profile, role=Roles.RESOURCE_USER.name) + + resource_user = property(_get_resource_user, _set_resource_user) + + @property + def resource_user_csv(self): + return ",".join(p.get_full_name() or p.username for p in self.resource_user) + + # Contact Role: RESOURCE_PROVIDER + def _get_resource_provider(self): + return self.__get_contact_role_elements__(role=Roles.RESOURCE_PROVIDER.name) + + def _set_resource_provider(self, user_profile): + return self.__set_contact_role_element__(user_profile=user_profile, role=Roles.RESOURCE_PROVIDER.name) + + resource_provider = property(_get_resource_provider, _set_resource_provider) + + @property + def resource_provider_csv(self): + return ",".join(p.get_full_name() or p.username for p in self.resource_provider) + + # Contact Role: ORIGINATOR + def _get_originator(self): + return self.__get_contact_role_elements__(role=Roles.ORIGINATOR.name) + + def _set_originator(self, user_profile): + return self.__set_contact_role_element__(user_profile=user_profile, role=Roles.ORIGINATOR.name) + + originator = property(_get_originator, _set_originator) + + @property + def originator_csv(self): + return ",".join(p.get_full_name() or p.username for p in self.originator) + + # Contact Role: PRINCIPAL_INVESTIGATOR + def _get_principal_investigator(self): + return self.__get_contact_role_elements__(role=Roles.PRINCIPAL_INVESTIGATOR.name) + + def _set_principal_investigator(self, user_profile): + return self.__set_contact_role_element__(user_profile=user_profile, role=Roles.PRINCIPAL_INVESTIGATOR.name) + + principal_investigator = property(_get_principal_investigator, _set_principal_investigator) + + @property + def principal_investigator_csv(self): + return ",".join(p.get_full_name() or p.username for p in self.principal_investigator) + def get_linked_resources(self, as_target: bool = False): """ Get all the linked resources to this ResourceBase instance. diff --git a/geonode/base/templates/base/_resourcebase_info_panel.html b/geonode/base/templates/base/_resourcebase_info_panel.html index a5f6110b6d6..4d9c5a80a04 100644 --- a/geonode/base/templates/base/_resourcebase_info_panel.html +++ b/geonode/base/templates/base/_resourcebase_info_panel.html @@ -108,9 +108,11 @@
    {% endif %} - {% if resource.poc.user %} + {% if resource.poc %}
    {% trans "Point of Contact" %}
    -
    {{ resource.poc.user.username }}
    + {% for user in resource.poc %} +
    {{ user.username }}
    + {% endfor%} {% endif %} {% if resource.group %} diff --git a/geonode/base/templatetags/base_tags.py b/geonode/base/templatetags/base_tags.py index 2306f411d2c..23960e0eeaa 100644 --- a/geonode/base/templatetags/base_tags.py +++ b/geonode/base/templatetags/base_tags.py @@ -58,6 +58,12 @@ def template_trans(text): return text +@register.filter(name="get_item") +def get_item(dictionary, key): + """Get a element for a dict by name""" + return dictionary.get(key) + + @register.simple_tag def num_ratings(obj): ct = ContentType.objects.get_for_model(obj) diff --git a/geonode/base/tests.py b/geonode/base/tests.py index a217d78a0f4..d61b021ad66 100644 --- a/geonode/base/tests.py +++ b/geonode/base/tests.py @@ -157,11 +157,111 @@ def test_add_missing_metadata_author_or_poc(self): Test that calling add_missing_metadata_author_or_poc resource method sets a missing metadata_author and/or point of contact (poc) to resource owner """ - user = get_user_model().objects.create(username="zlatan_i") + user, _ = get_user_model().objects.get_or_create(username="zlatan_i") + self.rb.owner = user self.rb.add_missing_metadata_author_or_poc() - self.assertEqual(self.rb.metadata_author.username, "zlatan_i") - self.assertEqual(self.rb.poc.username, "zlatan_i") + self.assertTrue("zlatan_i" in [author.username for author in self.rb.metadata_author]) + self.assertTrue("zlatan_i" in [author.username for author in self.rb.poc]) + + +class TestCreationOfContactRolesByDifferentInputTypes(ThumbnailTests): + + """ + Test that contact roles can be set as people profile + """ + + def test_set_contact_role_as_people_profile(self): + user, _ = get_user_model().objects.get_or_create(username="zlatan_i") + + self.rb.owner = user + self.rb.metadata_author = user + self.rb.poc = user + self.rb.publisher = user + self.rb.custodian = user + self.rb.distributor = user + self.rb.resource_user = user + self.rb.resource_provider = user + self.rb.originator = user + self.rb.principal_investigator = user + self.rb.processor = user + + self.assertTrue("zlatan_i" in [cr.username for cr in self.rb.metadata_author]) + self.assertTrue("zlatan_i" in [cr.username for cr in self.rb.poc]) + self.assertTrue("zlatan_i" in [cr.username for cr in self.rb.publisher]) + self.assertTrue("zlatan_i" in [cr.username for cr in self.rb.custodian]) + self.assertTrue("zlatan_i" in [cr.username for cr in self.rb.distributor]) + self.assertTrue("zlatan_i" in [cr.username for cr in self.rb.resource_user]) + self.assertTrue("zlatan_i" in [cr.username for cr in self.rb.resource_provider]) + self.assertTrue("zlatan_i" in [cr.username for cr in self.rb.originator]) + self.assertTrue("zlatan_i" in [cr.username for cr in self.rb.principal_investigator]) + self.assertTrue("zlatan_i" in [cr.username for cr in self.rb.processor]) + + """ + Test that contact roles can be set as list of people profiles + """ + + def test_set_contact_role_as_list_of_people(self): + user, _ = get_user_model().objects.get_or_create(username="zlatan_i") + user2, _ = get_user_model().objects.get_or_create(username="sven_z") + + profile_list = [user, user2] + + self.rb.owner = user + self.rb.metadata_author = profile_list + self.rb.poc = profile_list + self.rb.publisher = profile_list + self.rb.custodian = profile_list + self.rb.distributor = profile_list + self.rb.resource_user = profile_list + self.rb.resource_provider = profile_list + self.rb.originator = profile_list + self.rb.principal_investigator = profile_list + self.rb.processor = profile_list + + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.metadata_author]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.poc]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.publisher]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.custodian]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.distributor]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.resource_user]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.resource_provider]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.originator]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.principal_investigator]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.processor]) + + """ + Test that contact roles can be set as queryset + """ + + def test_set_contact_role_as_queryset(self): + user, _ = get_user_model().objects.get_or_create(username="zlatan_i") + user2, _ = get_user_model().objects.get_or_create(username="sven_z") + + query = get_user_model().objects.filter(username__in=["zlatan_i", "sven_z"]) + + self.rb.owner = user + self.rb.metadata_author = query + self.rb.poc = query + self.rb.publisher = query + self.rb.custodian = query + self.rb.distributor = query + self.rb.resource_user = query + self.rb.resource_provider = query + self.rb.originator = query + self.rb.principal_investigator = query + self.rb.processor = query + + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.metadata_author]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.poc]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.publisher]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.custodian]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.distributor]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.resource_user]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.resource_provider]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.originator]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.principal_investigator]) + self.assertTrue("zlatan_i" and "sven_z" in [cr.username for cr in self.rb.processor]) class RenderMenuTagTest(GeoNodeBaseTestSupport): diff --git a/geonode/base/widgets.py b/geonode/base/widgets.py index a10395ab485..b1281de7dd7 100644 --- a/geonode/base/widgets.py +++ b/geonode/base/widgets.py @@ -1,3 +1,5 @@ +from typing import List + from dal_select2_taggit.widgets import TaggitSelect2 @@ -20,3 +22,19 @@ def value_from_datadict(self, data, files, name): return value except TypeError: return "" + + +class TaggitProfileSelect2Custom(TaggitSelect2): + """Overriding Select2 tag widget for ContactRoleField.""" + + def value_from_datadict(self, data, files, name) -> List[str]: + """Handle multi-profiles. + + returns list of selected elements + """ + try: + ret_list = data[name] + except KeyError: + ret_list = [] + finally: + return ret_list diff --git a/geonode/catalogue/backends/pycsw_local_mappings.py b/geonode/catalogue/backends/pycsw_local_mappings.py index 4ba4f7902ff..4bbb8648b88 100644 --- a/geonode/catalogue/backends/pycsw_local_mappings.py +++ b/geonode/catalogue/backends/pycsw_local_mappings.py @@ -17,6 +17,7 @@ # ######################################################################### +# based on https://github.com/geopython/pycsw/blob/master/pycsw/core/config.py MD_CORE_MODEL = { "typename": "pycsw:CoreMetadata", "outputschema": "http://pycsw.org/metadata", diff --git a/geonode/catalogue/templates/catalogue/full_metadata.xml b/geonode/catalogue/templates/catalogue/full_metadata.xml index 955f81c3e55..10e66e24c08 100644 --- a/geonode/catalogue/templates/catalogue/full_metadata.xml +++ b/geonode/catalogue/templates/catalogue/full_metadata.xml @@ -12,58 +12,57 @@ dataset - - {% with layer.poc as poc %} + {% for contact_roles, label in layer.get_defined_contact_roles %} + {% for contact_role in contact_roles %} - - {% if poc.name %} {{ poc.name }} {% endif %} + {% else %}> + {{ contact_role.first_name }} {{ contact_role.last_name}}{% endif %} - - {% if poc.organization %} {{ poc.organization }} {% endif %} + + {% if contact_role.organization %} {{ contact_role.organization }} {% endif %} - - {% if poc.position %}{{ poc.position }} {% endif %} + + {% if contact_role.position %}{{ contact_role.position }} {% endif %} - - {% if poc.voice %}{{ poc.voice }}{% endif %} + + {% if contact_role.voice %}{{ contact_role.voice }}{% endif %} - - {% if poc.fax %}{{ poc.fax }} {%endif %} + + {% if contact_role.fax %}{{ contact_role.fax }} {%endif %} - - {% if poc.delivery %}{{ poc.delivery }}{% endif %} + + {% if contact_role.delivery %}{{ contact_role.delivery }}{% endif %} - - {% if poc.city %}{{ poc.city }}{% endif %} + + {% if contact_role.city %}{{ contact_role.city }}{% endif %} - - {% if poc.area %}{{ poc.area }}{% endif %} + + {% if contact_role.area %}{{ contact_role.area }}{% endif %} - - {% if poc.zipcode %}{{ poc.zipcode }}{% endif %} + + {% if contact_role.zipcode %}{{ contact_role.zipcode }}{% endif %} - - {% if poc.country %}{{ poc.country }}{% endif %} + + {% if contact_role.country %}{{ contact_role.country }}{% endif %} - - {% if poc.email %}{{ poc.email }}{% endif %} + + {% if contact_role.email %}{{ contact_role.email }}{% endif %} - {% if poc.user %} - {{ SITEURL }}{{ layer.poc.get_absolute_url }} + {{ SITEURL }}{{ contact_role.get_absolute_url }} WWW:LINK-1.0-http--link @@ -73,87 +72,15 @@ - {% endif %} - pointOfContact + {{ label }} - {% endwith %} - - {% with layer.metadata_author as metadata_author %} - - - - {% if metadata_author.name %} {{ metadata_author.name }} {% endif %} - - - {% if metadata_author.organization %} {{ metadata_author.organization }} {% endif %} - - - {% if metadata_author.position %}{{ metadata_author.position }} {% endif %} - - - - - - - {% if metadata_author.voice %}{{ metadata_author.voice }}{% endif %} - - - {% if metadata_author.fax %}{{ metadata_author.fax }} {%endif %} - - - - - - - {% if metadata_author.delivery %}{{ metadata_author.delivery }}{% endif %} - - - {% if metadata_author.city %}{{ metadata_author.city }}{% endif %} - - - {% if metadata_author.area %}{{ metadata_author.area }}{% endif %} - - - {% if metadata_author.zipcode %}{{ metadata_author.zipcode }}{% endif %} - - - {% if metadata_author.country %}{{ metadata_author.country }}{% endif %} - - - {% if metadata_author.email %}{{ metadata_author.email }}{% endif %} - - - - {% if metadata_author.user %} - - - - {{ SITEURL }}{{ layer.metadata_author.get_absolute_url }} - - - WWW:LINK-1.0-http--link - - - GeoNode profile page - - - - {% endif %} - - - - author - - - - {% endwith %} - - + {% endfor %} + {% endfor %} {{layer.csw_insert_date|date:"Y-m-d\TH:i:s\Z"}} @@ -295,7 +222,7 @@ - originator + owner diff --git a/geonode/catalogue/templates/geonode_metadata_full.html b/geonode/catalogue/templates/geonode_metadata_full.html index adcddf43918..47413188a32 100644 --- a/geonode/catalogue/templates/geonode_metadata_full.html +++ b/geonode/catalogue/templates/geonode_metadata_full.html @@ -72,10 +72,14 @@

    {{ resource.title }}

    {% trans "Responsible" %}
    {{resource.owner}}
    -
    {% trans "Point of Contact" %}
    -
    {{extra_res_md.poc_last_name}}
    -
    {{extra_res_md.poc_email}}
    - + {% for role in extra_res_md.roles %} +
    {% trans role.label %}
    + + {% for user in role.users %} +
    {{user.last_name}}
    +
    {{ user.email}}
    + {% endfor %} + {% endfor %}
    {% trans "Purpose" %}
    {% if resource.purpose %} diff --git a/geonode/catalogue/views.py b/geonode/catalogue/views.py index eb79f6f90af..8e64559f8c1 100644 --- a/geonode/catalogue/views.py +++ b/geonode/catalogue/views.py @@ -29,11 +29,12 @@ from geonode.base.models import ResourceBase from geonode.layers.models import Dataset from geonode.base.auth import get_or_create_token -from geonode.base.models import ContactRole, SpatialRepresentationType +from geonode.base.models import SpatialRepresentationType from geonode.groups.models import GroupProfile from geonode.utils import resolve_object from django.db import connection from django.core.exceptions import ObjectDoesNotExist +from geonode.people import Roles @csrf_exempt @@ -286,11 +287,18 @@ def csw_render_extra_format_txt(request, layeruuid, resname): content += fst(attr.attribute_label) + s content += fst(attr.description) + sc - pocr = ContactRole.objects.get(resource_id=resource.id, role="pointOfContact") - pocp = get_user_model().objects.get(id=pocr.contact_id) - content += f"Point of Contact{sc}" - content += f"name{s}{fst(pocp.last_name)}{sc}" - content += f"e-mail{s}{fst(pocp.email)}{sc}" + @staticmethod + def __append_contact_role__(content, cr_attr_name, title_in_txt): + cr = resource.__getattribute__(cr_attr_name) + if cr is not None or (isinstance(list, cr) and len(0)): + content += f"{title_in_txt}{sc}" + for user in cr: + content += f"name{s}{fst(user.last_name)}{sc}" + content += f"e-mail{s}{fst(user.email)}{sc}" + return content + + for role in set(Roles).difference([Roles.OWNER]): + content = __append_contact_role__(content, role.name, role.label) logger = logging.getLogger(__name__) logger.error(content) @@ -319,10 +327,15 @@ def csw_render_extra_format_html(request, layeruuid, resname): s = f"{attr.attribute}{attr.attribute_label}{attr.description}" extra_res_md["atrributes"] += s - pocr = ContactRole.objects.get(resource_id=resource.id, role="pointOfContact") - pocp = get_user_model().objects.get(id=pocr.contact_id) - extra_res_md["poc_last_name"] = pocp.last_name - extra_res_md["poc_email"] = pocp.email + extra_res_md["roles"] = [] + for role in Roles: + cr = resource.__getattribute__(role.name) + if not isinstance(cr, list): + cr = [cr] + users = [{"pk": user.id, "last_name": user.last_name, "email": user.email} for user in cr] + if users: + extra_res_md["roles"].append({"label": role.label, "users": users}) + return render(request, "geonode_metadata_full.html", context={"resource": resource, "extra_res_md": extra_res_md}) diff --git a/geonode/documents/api/tests.py b/geonode/documents/api/tests.py index 9a08fbd8671..3f6b4d6bf2e 100644 --- a/geonode/documents/api/tests.py +++ b/geonode/documents/api/tests.py @@ -143,6 +143,308 @@ def test_creation_should_create_the_doc(self): self.assertEqual("xml", extension) self.assertTrue(Document.objects.filter(title="New document for testing").exists()) + def test_patch_point_of_contact(self): + document = Document.objects.first() + url = urljoin(f"{reverse('documents-list')}/", f"{document.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"poc": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id in [poc.get("pk") for poc in response.json().get("document").get("poc")] for user_id in user_ids + ) + ) + # Resetting all point of contact + response = self.client.patch(url, data={"poc": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id not in [poc.get("pk") for poc in response.json().get("document").get("poc")] + for user_id in user_ids + ) + ) + + def test_patch_metadata_author(self): + layer = Document.objects.first() + url = urljoin(f"{reverse('documents-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"metadata_author": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + in [ + metadata_author.get("pk") + for metadata_author in response.json().get("document").get("metadata_author") + ] + for user_id in user_ids + ) + ) + # Resetting all metadata authors + response = self.client.patch(url, data={"metadata_author": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + not in [ + metadata_author.get("pk") + for metadata_author in response.json().get("document").get("metadata_author") + ] + for user_id in user_ids + ) + ) + + def test_patch_processor(self): + layer = Document.objects.first() + url = urljoin(f"{reverse('documents-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"processor": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + # check if all set processors are in the return json + self.assertTrue( + all( + user_id in [processor.get("pk") for processor in response.json().get("document").get("processor")] + for user_id in user_ids + ) + ) + # Resetting all processors + response = self.client.patch(url, data={"processor": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id not in [processor.get("pk") for processor in response.json().get("document").get("processor")] + for user_id in user_ids + ) + ) + + def test_patch_publisher(self): + layer = Document.objects.first() + url = urljoin(f"{reverse('documents-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"publisher": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id in [publisher.get("pk") for publisher in response.json().get("document").get("publisher")] + for user_id in user_ids + ) + ) + # Resetting all publishers + response = self.client.patch(url, data={"publisher": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id not in [publisher.get("pk") for publisher in response.json().get("document").get("publisher")] + for user_id in user_ids + ) + ) + + def test_patch_custodian(self): + layer = Document.objects.first() + url = urljoin(f"{reverse('documents-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"custodian": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id in [custodian.get("pk") for custodian in response.json().get("document").get("custodian")] + for user_id in user_ids + ) + ) + # Resetting all custodians + response = self.client.patch(url, data={"custodian": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id not in [custodian.get("pk") for custodian in response.json().get("document").get("custodian")] + for user_id in user_ids + ) + ) + + def test_patch_distributor(self): + layer = Document.objects.first() + url = urljoin(f"{reverse('documents-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"distributor": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id in [distributor.get("pk") for distributor in response.json().get("document").get("distributor")] + for user_id in user_ids + ) + ) + # Resetting all distributers + response = self.client.patch(url, data={"distributor": []}, format="json") + + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + not in [distributor.get("pk") for distributor in response.json().get("document").get("distributor")] + for user_id in user_ids + ) + ) + + def test_patch_resource_user(self): + layer = Document.objects.first() + url = urljoin(f"{reverse('documents-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"resource_user": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + in [resource_user.get("pk") for resource_user in response.json().get("document").get("resource_user")] + for user_id in user_ids + ) + ) + # Resetting all resource users + response = self.client.patch(url, data={"resource_user": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + not in [ + resource_user.get("pk") for resource_user in response.json().get("document").get("resource_user") + ] + for user_id in user_ids + ) + ) + + def test_patch_resource_provider(self): + layer = Document.objects.first() + url = urljoin(f"{reverse('documents-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"resource_provider": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + in [ + resource_provider.get("pk") + for resource_provider in response.json().get("document").get("resource_provider") + ] + for user_id in user_ids + ) + ) + # Resetting all principal investigator + response = self.client.patch(url, data={"resource_provider": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + not in [ + resource_provider.get("pk") + for resource_provider in response.json().get("document").get("resource_provider") + ] + for user_id in user_ids + ) + ) + + def test_patch_originator(self): + layer = Document.objects.first() + url = urljoin(f"{reverse('documents-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"originator": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id in [originator.get("pk") for originator in response.json().get("document").get("originator")] + for user_id in user_ids + ) + ) + # Resetting all originators + response = self.client.patch(url, data={"originator": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + not in [originator.get("pk") for originator in response.json().get("document").get("originator")] + for user_id in user_ids + ) + ) + + def test_patch_principal_investigator(self): + layer = Document.objects.first() + url = urljoin(f"{reverse('documents-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"principal_investigator": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + in [ + principal_investigator.get("pk") + for principal_investigator in response.json().get("document").get("principal_investigator") + ] + for user_id in user_ids + ) + ) + # Resetting all principal investigator + response = self.client.patch(url, data={"principal_investigator": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + not in [ + principal_investigator.get("pk") + for principal_investigator in response.json().get("document").get("principal_investigator") + ] + for user_id in user_ids + ) + ) + def test_creation_should_create_the_doc_and_update_the_bbox(self): """ If file_path is not available, should raise error diff --git a/geonode/documents/migrations/0037_delete_documentresourcelink.py b/geonode/documents/migrations/0037_delete_documentresourcelink.py index a03389fd6b6..72e49c51ae4 100644 --- a/geonode/documents/migrations/0037_delete_documentresourcelink.py +++ b/geonode/documents/migrations/0037_delete_documentresourcelink.py @@ -4,17 +4,18 @@ class Migration(migrations.Migration): - dependencies = [ - ('documents', '0036_clean_document_thumbnails'), - ('base', '0086_linkedresource'), + ("documents", "0036_clean_document_thumbnails"), + ("base", "0086_linkedresource"), ] operations = [ - migrations.RunSQL("INSERT INTO base_linkedresource(source_id, target_id, internal)" - "SELECT document_id, object_id, false as internal " - "FROM documents_documentresourcelink;"), + migrations.RunSQL( + "INSERT INTO base_linkedresource(source_id, target_id, internal)" + "SELECT document_id, object_id, false as internal " + "FROM documents_documentresourcelink;" + ), migrations.DeleteModel( - name='DocumentResourceLink', + name="DocumentResourceLink", ), ] diff --git a/geonode/documents/templates/documents/document_metadata.html b/geonode/documents/templates/documents/document_metadata.html index 80b553f9c35..5e2a1669995 100644 --- a/geonode/documents/templates/documents/document_metadata.html +++ b/geonode/documents/templates/documents/document_metadata.html @@ -31,12 +31,12 @@

    {% trans "Metadata" %} {% blocktrans with document.t

    - {% if document_form.errors or category_form.errors or author_form.errors or poc.errors %} + {% if document_form.errors or category_form.errors or metadata_author_form.errors or poc.errors %}

    {% blocktrans %}Error updating metadata. Please check the following fields: {% endblocktrans %}

      - {% if author_form.errors %} + {% if metadata_author_form.errors %}
    • {% trans "Metadata Author" %}
    • - {{ author_form.errors }} + {{ metadata_author_form.errors }} {% endif %} {% if poc_form.errors %}
    • {% trans "Point of Contact" %}
    • @@ -69,7 +69,7 @@

      {% trans "Point of Contact" %}

      diff --git a/geonode/documents/templates/documents/document_metadata_advanced.html b/geonode/documents/templates/documents/document_metadata_advanced.html index ddb70a929ac..f7f8dc083a9 100644 --- a/geonode/documents/templates/documents/document_metadata_advanced.html +++ b/geonode/documents/templates/documents/document_metadata_advanced.html @@ -37,12 +37,12 @@

      {% trans "Edit Metadata" %}

      - {% if document_form.errors or category_form.errors or author_form.errors or poc.errors %} + {% if document_form.errors or category_form.errors or metadata_author_form.errors or poc.errors %}

      {% blocktrans %}Error updating metadata. Please check the following fields: {% endblocktrans %}

        - {% if author_form.errors %} + {% if metadata_author_form.errors %}
      • {% trans "Metadata Author" %}
      • - {{ author_form.errors }} + {{ metadata_author_form.errors }} {% endif %} {% if poc_form.errors %}
      • {% trans "Point of Contact" %}
      • @@ -132,7 +132,7 @@

        {% trans "Point of Contact" %}

        diff --git a/geonode/documents/templates/layouts/doc_panels.html b/geonode/documents/templates/layouts/doc_panels.html index e2dffb26eea..d354531d314 100644 --- a/geonode/documents/templates/layouts/doc_panels.html +++ b/geonode/documents/templates/layouts/doc_panels.html @@ -1,6 +1,8 @@ {% load i18n %} {% load static %} {% load floppyforms %} +{% load base_tags %} +{% load contact_roles %} @@ -460,7 +462,7 @@ {{ document_form.constraints_other }} -
        +
    {% endblock doc_constraints_other %}
    @@ -558,15 +560,42 @@
    - {% block document_poc %}
    {% trans "Responsible Parties" %}
    -
    - - {{ document_form.poc }} + {% block document_poc %} +
    + + {{ document_form.poc }} +
    + {% endblock document_poc %} +
    +
    +
    {% trans "Responsible and Permissions" %}
    +
    + {% block document_owner %} +
    + + {{ document_form.owner }} +
    + {% endblock document_owner %} +
    +
    + {% trans "toggle more Contact Roles" %} + {% block document_more_contact_roles %} +
    +
    {% trans "more metadata contact roles" %}
    + {% for contact_role in UI_ROLES_IN_TOGGLE_VIEW %} + {% getattribute document_form contact_role as cr %} +
    +
    + + {{ cr}} +
    +
    + {% endfor %}
    - {% endblock %} + {% endblock document_more_contact_roles %}
    {% trans "Responsible and Permissions" %}
    @@ -583,12 +612,13 @@
    + - + {% block extra_metadata_content %} - {% endblock %} + {% endblock extra_metadata_content %} {% endblock ownership %} diff --git a/geonode/documents/views.py b/geonode/documents/views.py index 8a57d9ff887..66b8df09bb7 100644 --- a/geonode/documents/views.py +++ b/geonode/documents/views.py @@ -23,12 +23,10 @@ import warnings import traceback - from django.urls import reverse from django.conf import settings from django.contrib import messages from django.shortcuts import render, get_object_or_404 -from django.forms.utils import ErrorList from django.utils.translation import ugettext as _ from django.contrib.auth.decorators import login_required from django.template import loader @@ -317,8 +315,7 @@ def document_metadata( # Add metadata_author or poc if missing document.add_missing_metadata_author_or_poc() - poc = document.poc - metadata_author = document.metadata_author + topic_category = document.category current_keywords = [keyword.name for keyword in document.keywords.all()] @@ -380,8 +377,6 @@ def document_metadata( tkeywords_form.fields[tid].initial = values if request.method == "POST" and document_form.is_valid() and category_form.is_valid() and tkeywords_form.is_valid(): - new_poc = document_form.cleaned_data["poc"] - new_author = document_form.cleaned_data["metadata_author"] new_keywords = current_keywords if request.keyword_readonly else document_form.cleaned_data["keywords"] new_regions = document_form.cleaned_data["regions"] @@ -393,31 +388,9 @@ def document_metadata( ): new_category = TopicCategory.objects.get(id=int(category_form.cleaned_data["category_choice_field"])) - if new_poc is None: - if poc is None: - poc_form = ProfileForm(request.POST, prefix="poc", instance=poc) - else: - poc_form = ProfileForm(request.POST, prefix="poc") - if poc_form.is_valid(): - if len(poc_form.cleaned_data["profile"]) == 0: - # FIXME use form.add_error in django > 1.7 - errors = poc_form._errors.setdefault("profile", ErrorList()) - errors.append(_("You must set a point of contact for this resource")) - if poc_form.has_changed and poc_form.is_valid(): - new_poc = poc_form.save() - - if new_author is None: - if metadata_author is None: - author_form = ProfileForm(request.POST, prefix="author", instance=metadata_author) - else: - author_form = ProfileForm(request.POST, prefix="author") - if author_form.is_valid(): - if len(author_form.cleaned_data["profile"]) == 0: - # FIXME use form.add_error in django > 1.7 - errors = author_form._errors.setdefault("profile", ErrorList()) - errors.append(_("You must set an author for this resource")) - if author_form.has_changed and author_form.is_valid(): - new_author = author_form.save() + # update contact roles + document.set_contact_roles_from_metadata_edit(document_form) + document.save() document = document_form.instance resource_manager.update( @@ -425,11 +398,7 @@ def document_metadata( instance=document, keywords=new_keywords, regions=new_regions, - vals=dict( - poc=new_poc or document.poc, - metadata_author=new_author or document.metadata_author, - category=new_category, - ), + vals=dict(category=new_category), notify=True, extra_metadata=json.loads(document_form.cleaned_data["extra_metadata"]), ) @@ -490,18 +459,15 @@ def document_metadata( # - POST Request Ends here - # Request.GET - if poc is not None: - document_form.fields["poc"].initial = poc.id - poc_form = ProfileForm(prefix="poc") - poc_form.hidden = True - - if metadata_author is not None: - document_form.fields["metadata_author"].initial = metadata_author.id - author_form = ProfileForm(prefix="author") - author_form.hidden = True + # define contact role forms + contact_role_forms_context = {} + for role in document.get_multivalue_role_property_names(): + document_form.fields[role].initial = [p.username for p in document.__getattribute__(role)] + role_form = ProfileForm(prefix=role) + role_form.hidden = True + contact_role_forms_context[f"{role}_form"] = role_form metadata_author_groups = get_user_visible_groups(request.user) - if not AdvancedSecurityWorkflowManager.is_allowed_to_publish(request.user, document): document_form.fields["is_published"].widget.attrs.update({"disabled": "true"}) if not AdvancedSecurityWorkflowManager.is_allowed_to_approve(request.user, document): @@ -517,8 +483,6 @@ def document_metadata( "panel_template": panel_template, "custom_metadata": custom_metadata, "document_form": document_form, - "poc_form": poc_form, - "author_form": author_form, "category_form": category_form, "tkeywords_form": tkeywords_form, "metadata_author_groups": metadata_author_groups, @@ -528,6 +492,8 @@ def document_metadata( set(getattr(settings, "UI_DEFAULT_MANDATORY_FIELDS", [])) | set(getattr(settings, "UI_REQUIRED_FIELDS", [])) ), + **contact_role_forms_context, + "UI_ROLES_IN_TOGGLE_VIEW": document.get_ui_toggled_role_property_names(), }, ) diff --git a/geonode/geoapps/templates/apps/app_metadata.html b/geonode/geoapps/templates/apps/app_metadata.html index 17b2de35a97..c195b43958d 100644 --- a/geonode/geoapps/templates/apps/app_metadata.html +++ b/geonode/geoapps/templates/apps/app_metadata.html @@ -68,7 +68,7 @@

    {% trans "Point of Contact" %}

    diff --git a/geonode/geoapps/templates/apps/app_metadata_advanced.html b/geonode/geoapps/templates/apps/app_metadata_advanced.html index 21d45962dc8..ee0440f4a53 100644 --- a/geonode/geoapps/templates/apps/app_metadata_advanced.html +++ b/geonode/geoapps/templates/apps/app_metadata_advanced.html @@ -130,7 +130,7 @@

    {% trans "Point of Contact" %}

    diff --git a/geonode/geoapps/templates/layouts/app_panels.html b/geonode/geoapps/templates/layouts/app_panels.html index ddedc0eb6bf..b255bbe6ecd 100644 --- a/geonode/geoapps/templates/layouts/app_panels.html +++ b/geonode/geoapps/templates/layouts/app_panels.html @@ -1,6 +1,7 @@ {% load i18n %} {% load static %} {% load floppyforms %} +{% load contact_roles %} @@ -516,22 +517,35 @@ {% endblock %}
    {% trans "Responsible and Permissions" %}
    + {% block geoapp_owner %}
    - {{ geoapp_form.owner }} -
    -
    - - - {{ geoapp_form.metadata_author }} -
    +
    + {% endblock geoapp_owner %} +
    + + {% trans "toggle more Contact Roles" %} + {% block geoapp_more_contact_roles %} +
    +
    {% trans "more metadata contact roles" %}
    + {% for contact_role in UI_ROLES_IN_TOGGLE_VIEW %} + {% getattribute geoapp_form contact_role as cr %} +
    +
    + + {{ cr}} +
    + {% endfor %}
    + {% endblock geoapp_more_contact_roles %} + + - + {% block extra_metadata_content %} {% endblock %} diff --git a/geonode/geoapps/views.py b/geonode/geoapps/views.py index 788dcaccbe1..6a2dd3b3344 100644 --- a/geonode/geoapps/views.py +++ b/geonode/geoapps/views.py @@ -24,7 +24,6 @@ from django.conf import settings from django.shortcuts import render -from django.forms.utils import ErrorList from django.utils.translation import ugettext as _ from django.contrib.auth.decorators import login_required from django.http import HttpResponse, HttpResponseRedirect, Http404 @@ -207,8 +206,6 @@ def geoapp_metadata( # Add metadata_author or poc if missing geoapp_obj.add_missing_metadata_author_or_poc() resource_type = geoapp_obj.resource_type - poc = geoapp_obj.poc - metadata_author = geoapp_obj.metadata_author topic_category = geoapp_obj.category current_keywords = [keyword.name for keyword in geoapp_obj.keywords.all()] @@ -271,8 +268,6 @@ def geoapp_metadata( tkeywords_form.fields[tid].initial = values if request.method == "POST" and geoapp_form.is_valid() and category_form.is_valid() and tkeywords_form.is_valid(): - new_poc = geoapp_form.cleaned_data.pop("poc") - new_author = geoapp_form.cleaned_data.pop("metadata_author") new_keywords = current_keywords if request.keyword_readonly else geoapp_form.cleaned_data.pop("keywords") new_regions = geoapp_form.cleaned_data.pop("regions") @@ -283,42 +278,13 @@ def geoapp_metadata( and category_form.cleaned_data["category_choice_field"] ): new_category = TopicCategory.objects.get(id=int(category_form.cleaned_data["category_choice_field"])) - - if new_poc is None: - if poc is None: - poc_form = ProfileForm(request.POST, prefix="poc", instance=poc) - else: - poc_form = ProfileForm(request.POST, prefix="poc") - if poc_form.is_valid(): - if len(poc_form.cleaned_data["profile"]) == 0: - # FIXME use form.add_error in django > 1.7 - errors = poc_form._errors.setdefault("profile", ErrorList()) - errors.append(_("You must set a point of contact for this resource")) - poc = None - if poc_form.has_changed and poc_form.is_valid(): - new_poc = poc_form.save() - - if new_author is None: - if metadata_author is None: - author_form = ProfileForm(request.POST, prefix="author", instance=metadata_author) - else: - author_form = ProfileForm(request.POST, prefix="author") - if author_form.is_valid(): - if len(author_form.cleaned_data["profile"]) == 0: - # FIXME use form.add_error in django > 1.7 - errors = author_form._errors.setdefault("profile", ErrorList()) - errors.append(_("You must set an author for this resource")) - metadata_author = None - if author_form.has_changed and author_form.is_valid(): - new_author = author_form.save() - geoapp_form.cleaned_data.pop("ptype") - additional_vals = dict( - poc=new_poc or geoapp_obj.poc, - metadata_author=new_author or geoapp_obj.metadata_author, - category=new_category, - ) + # update contact roles + geoapp_obj.set_contact_roles_from_metadata_edit(geoapp_form) + geoapp_obj.save() + + additional_vals = dict(category=new_category) geoapp_form.cleaned_data.pop("metadata") extra_metadata = geoapp_form.cleaned_data.pop("extra_metadata") @@ -388,16 +354,13 @@ def geoapp_metadata( return HttpResponse(json.dumps(out), content_type="application/json", status=400) # - POST Request Ends here - - # Request.GET - if poc is not None: - geoapp_form.fields["poc"].initial = poc.id - poc_form = ProfileForm(prefix="poc") - poc_form.hidden = True - - if metadata_author is not None: - geoapp_form.fields["metadata_author"].initial = metadata_author.id - author_form = ProfileForm(prefix="author") - author_form.hidden = True + # define contact role forms + contact_role_forms_context = {} + for role in geoapp_obj.get_multivalue_role_property_names(): + geoapp_form.fields[role].initial = [p.username for p in geoapp_obj.__getattribute__(role)] + role_form = ProfileForm(prefix=role) + role_form.hidden = True + contact_role_forms_context[f"{role}_form"] = role_form metadata_author_groups = get_user_visible_groups(request.user) @@ -416,8 +379,6 @@ def geoapp_metadata( "panel_template": panel_template, "custom_metadata": custom_metadata, "geoapp_form": geoapp_form, - "poc_form": poc_form, - "author_form": author_form, "category_form": category_form, "tkeywords_form": tkeywords_form, "metadata_author_groups": metadata_author_groups, @@ -427,6 +388,8 @@ def geoapp_metadata( set(getattr(settings, "UI_DEFAULT_MANDATORY_FIELDS", [])) | set(getattr(settings, "UI_REQUIRED_FIELDS", [])) ), + **contact_role_forms_context, + "UI_ROLES_IN_TOGGLE_VIEW": geoapp_obj.get_ui_toggled_role_property_names(), }, ) diff --git a/geonode/geoserver/helpers.py b/geonode/geoserver/helpers.py index cc9135d243f..8067f272adb 100755 --- a/geonode/geoserver/helpers.py +++ b/geonode/geoserver/helpers.py @@ -2081,19 +2081,19 @@ def sync_instance_with_geoserver(instance_id, *args, **kwargs): if updatemetadata: gs_resource.metadata_links = metadata_links - + default_poc = instance.get_first_contact_of_role(role="poc") # Update Attribution link - if instance.poc: + if default_poc: # gsconfig now utilizes an attribution dictionary gs_resource.attribution = { - "title": str(instance.poc), + "title": str(instance.poc_csv), "width": None, "height": None, "href": None, "url": None, "type": None, } - profile = get_user_model().objects.get(username=instance.poc.username) + profile = get_user_model().objects.get(username=default_poc.username) site_url = ( settings.SITEURL.rstrip("/") if settings.SITEURL.startswith("http") else settings.SITEURL ) diff --git a/geonode/harvesting/harvesters/geonodeharvester.py b/geonode/harvesting/harvesters/geonodeharvester.py index ec31b84b1d4..0f9de873476 100644 --- a/geonode/harvesting/harvesters/geonodeharvester.py +++ b/geonode/harvesting/harvesters/geonodeharvester.py @@ -364,6 +364,7 @@ def _get_resource_descriptor( # these work for both datasets and documents uuid=resource["uuid"], language=resource["language"], + # TODO issue#10290 point_of_contact=self._get_contact_descriptor("pointOfContact", resource["poc"]), author=self._get_contact_descriptor("author", resource["metadata_author"]), date_stamp=resource_datestamp, diff --git a/geonode/layers/api/tests.py b/geonode/layers/api/tests.py index 88ba047ed03..79b4330977b 100644 --- a/geonode/layers/api/tests.py +++ b/geonode/layers/api/tests.py @@ -324,6 +324,304 @@ def test_layer_replace_should_work(self, _validate_input_source): # evaluate that the number of available layer is not changed self.assertEqual(Dataset.objects.count(), cnt) + def test_patch_point_of_contact(self): + layer = Dataset.objects.first() + url = urljoin(f"{reverse('datasets-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"poc": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all(user_id in [poc.get("pk") for poc in response.json().get("dataset").get("poc")] for user_id in user_ids) + ) + # Resetting all point of contact + response = self.client.patch(url, data={"poc": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id not in [poc.get("pk") for poc in response.json().get("dataset").get("poc")] + for user_id in user_ids + ) + ) + + def test_patch_metadata_author(self): + layer = Dataset.objects.first() + url = urljoin(f"{reverse('datasets-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"metadata_author": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + in [ + metadata_author.get("pk") + for metadata_author in response.json().get("dataset").get("metadata_author") + ] + for user_id in user_ids + ) + ) + # Resetting all metadata authors + response = self.client.patch(url, data={"metadata_author": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + not in [ + metadata_author.get("pk") + for metadata_author in response.json().get("dataset").get("metadata_author") + ] + for user_id in user_ids + ) + ) + + def test_patch_processor(self): + layer = Dataset.objects.first() + url = urljoin(f"{reverse('datasets-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"processor": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id in [processor.get("pk") for processor in response.json().get("dataset").get("processor")] + for user_id in user_ids + ) + ) + # Resetting all processors + response = self.client.patch(url, data={"processor": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id not in [processor.get("pk") for processor in response.json().get("dataset").get("processor")] + for user_id in user_ids + ) + ) + + def test_patch_publisher(self): + layer = Dataset.objects.first() + url = urljoin(f"{reverse('datasets-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"publisher": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id in [publisher.get("pk") for publisher in response.json().get("dataset").get("publisher")] + for user_id in user_ids + ) + ) + # Resetting all publishers + response = self.client.patch(url, data={"publisher": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id not in [publisher.get("pk") for publisher in response.json().get("dataset").get("publisher")] + for user_id in user_ids + ) + ) + + def test_patch_custodian(self): + layer = Dataset.objects.first() + url = urljoin(f"{reverse('datasets-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"custodian": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id in [custodian.get("pk") for custodian in response.json().get("dataset").get("custodian")] + for user_id in user_ids + ) + ) + # Resetting all custodians + response = self.client.patch(url, data={"custodian": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id not in [custodian.get("pk") for custodian in response.json().get("dataset").get("custodian")] + for user_id in user_ids + ) + ) + + def test_patch_distributor(self): + layer = Dataset.objects.first() + url = urljoin(f"{reverse('datasets-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"distributor": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id in [distributor.get("pk") for distributor in response.json().get("dataset").get("distributor")] + for user_id in user_ids + ) + ) + # Resetting all distributers + response = self.client.patch(url, data={"distributor": []}, format="json") + + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + not in [distributor.get("pk") for distributor in response.json().get("dataset").get("distributor")] + for user_id in user_ids + ) + ) + + def test_patch_resource_user(self): + layer = Dataset.objects.first() + url = urljoin(f"{reverse('datasets-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"resource_user": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + in [resource_user.get("pk") for resource_user in response.json().get("dataset").get("resource_user")] + for user_id in user_ids + ) + ) + # Resetting all resource users + response = self.client.patch(url, data={"resource_user": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + not in [ + resource_user.get("pk") for resource_user in response.json().get("dataset").get("resource_user") + ] + for user_id in user_ids + ) + ) + + def test_patch_resource_provider(self): + layer = Dataset.objects.first() + url = urljoin(f"{reverse('datasets-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"resource_provider": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + in [ + resource_provider.get("pk") + for resource_provider in response.json().get("dataset").get("resource_provider") + ] + for user_id in user_ids + ) + ) + # Resetting all principal investigator + response = self.client.patch(url, data={"resource_provider": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + not in [ + resource_provider.get("pk") + for resource_provider in response.json().get("dataset").get("resource_provider") + ] + for user_id in user_ids + ) + ) + + def test_patch_originator(self): + layer = Dataset.objects.first() + url = urljoin(f"{reverse('datasets-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"originator": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id in [originator.get("pk") for originator in response.json().get("dataset").get("originator")] + for user_id in user_ids + ) + ) + # Resetting all originators + response = self.client.patch(url, data={"originator": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id not in [originator.get("pk") for originator in response.json().get("dataset").get("originator")] + for user_id in user_ids + ) + ) + + def test_patch_principal_investigator(self): + layer = Dataset.objects.first() + url = urljoin(f"{reverse('datasets-list')}/", f"{layer.id}") + self.client.login(username="admin", password="admin") + get_user_model().objects.get_or_create(username="ninja") + get_user_model().objects.get_or_create(username="turtle") + users = get_user_model().objects.exclude(pk=-1) + user_ids = [user.pk for user in users] + patch_data = {"principal_investigator": [{"pk": uid} for uid in user_ids]} + response = self.client.patch(url, data=patch_data, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + in [ + principal_investigator.get("pk") + for principal_investigator in response.json().get("dataset").get("principal_investigator") + ] + for user_id in user_ids + ) + ) + # Resetting all principal investigator + response = self.client.patch(url, data={"principal_investigator": []}, format="json") + self.assertEqual(200, response.status_code) + self.assertTrue( + all( + user_id + not in [ + principal_investigator.get("pk") + for principal_investigator in response.json().get("dataset").get("principal_investigator") + ] + for user_id in user_ids + ) + ) + def test_metadata_update_for_not_supported_method(self): layer = Dataset.objects.first() url = reverse("datasets-replace-metadata", args=(layer.id,)) diff --git a/geonode/layers/templates/datasets/dataset_metadata.html b/geonode/layers/templates/datasets/dataset_metadata.html index cfb423709ac..69fe111eca2 100644 --- a/geonode/layers/templates/datasets/dataset_metadata.html +++ b/geonode/layers/templates/datasets/dataset_metadata.html @@ -43,12 +43,12 @@

    {% trans "Metadata" %} {% blocktrans with dataset.ti Some of your original metadata may have been lost.{% endblocktrans %}

    {% endif %} - {% if dataset_form.errors or attribute_form.errors or category_form.errors or author_form.errors or poc.errors or tkeywords_form.errors %} + {% if dataset_form.errors or attribute_form.errors or category_form.errors or metadata_author_form.errors or poc.errors or tkeywords_form.errors %}

    {% blocktrans %}Error updating metadata. Please check the following fields: {% endblocktrans %}

      - {% if author_form.errors %} + {% if metadata_author_form.errors %}
    • {% trans "Metadata Author" %}
    • - {{ author_form.errors }} + {{ metadata_author_form.errors }} {% endif %} {% if poc_form.errors %}
    • {% trans "Point of Contact" %}
    • @@ -91,7 +91,7 @@

      {% trans "Point of Contact" %}

      diff --git a/geonode/layers/templates/datasets/dataset_metadata_advanced.html b/geonode/layers/templates/datasets/dataset_metadata_advanced.html index fff3b8aef0f..c7169f960cb 100644 --- a/geonode/layers/templates/datasets/dataset_metadata_advanced.html +++ b/geonode/layers/templates/datasets/dataset_metadata_advanced.html @@ -52,12 +52,12 @@

      {% trans "Edit Metadata" %}

      {% endblock metadata_uploaded_check %} {% block dataset_form_errors %} - {% if dataset_form.errors or attribute_form.errors or category_form.errors or author_form.errors or poc.errors %} + {% if dataset_form.errors or attribute_form.errors or category_form.errors or metadata_author_form.errors or poc.errors %}

      {% blocktrans %}Error updating metadata. Please check the following fields: {% endblocktrans %}

        - {% if author_form.errors %} + {% if metadata_author_form.errors %}
      • {% trans "Metadata Author" %}
      • - {{ author_form.errors }} + {{ metadata_author_form.errors }} {% endif %} {% if poc_form.errors %}
      • {% trans "Point of Contact" %}
      • @@ -210,7 +210,7 @@

        {% trans "Point of Contact" %}

        {% block metadata_provider %} {% endblock metadata_provider %} diff --git a/geonode/layers/templates/layouts/panels.html b/geonode/layers/templates/layouts/panels.html index 4ee805635e8..40a9c3e4321 100644 --- a/geonode/layers/templates/layouts/panels.html +++ b/geonode/layers/templates/layouts/panels.html @@ -1,6 +1,8 @@ {% load i18n %} {% load static %} {% load floppyforms %} +{% load contact_roles %} +