diff --git a/geonode/geoserver/helpers.py b/geonode/geoserver/helpers.py index b3a1ff73fe2..e47f3274bd1 100755 --- a/geonode/geoserver/helpers.py +++ b/geonode/geoserver/helpers.py @@ -74,6 +74,7 @@ get_legend_url, is_monochromatic_image, set_resource_default_links, + normalize_bbox_to_float_list, ) from .geofence import GeoFenceClient, GeoFenceUtils @@ -2026,18 +2027,9 @@ def sync_instance_with_geoserver(instance_id, *args, **kwargs): # are bypassed by custom create/updates we need to ensure the # bbox is calculated properly. srid = gs_resource.projection - bbox = gs_resource.native_bbox - ll_bbox = gs_resource.latlon_bbox - try: - instance.set_bbox_polygon([bbox[0], bbox[2], bbox[1], bbox[3]], srid) - except GeoNodeException as e: - if not ll_bbox: - raise - else: - logger.exception(e) - instance.srid = "EPSG:4326" - Dataset.objects.filter(id=instance.id).update(srid=instance.srid) - instance.set_ll_bbox_polygon([ll_bbox[0], ll_bbox[2], ll_bbox[1], ll_bbox[3]]) + bbox = normalize_bbox_to_float_list(gs_resource.native_bbox) + ll_bbox = normalize_bbox_to_float_list(gs_resource.latlon_bbox) + instance.set_bbox_and_srid(bbox=bbox, ll_bbox=ll_bbox, srid=srid) if instance.srid: instance.srid_url = ( diff --git a/geonode/layers/models.py b/geonode/layers/models.py index f4f406dddd9..66e77cf6357 100644 --- a/geonode/layers/models.py +++ b/geonode/layers/models.py @@ -19,6 +19,7 @@ import itertools import re import logging +from typing import List, Union from django.conf import settings from django.db import models, transaction @@ -30,7 +31,8 @@ from tinymce.models import HTMLField from geonode.client.hooks import hookset -from geonode.utils import build_absolute_uri, check_shp_columnnames +from geonode import GeoNodeException +from geonode.utils import build_absolute_uri, check_shp_columnnames, check_bbox_validity, normalize_bbox_to_float_list from geonode.security.models import PermissionLevelMixin from geonode.groups.conf import settings as groups_settings from geonode.security.permissions import ( @@ -340,23 +342,57 @@ def recalc_bbox_on_geoserver(self, force_bbox=None): logger.error("No resource returned from GeoServer after bbox update") return False - bbox = resource.native_bbox - ll = resource.latlon_bbox + bbox = normalize_bbox_to_float_list(resource.native_bbox) + ll = normalize_bbox_to_float_list(resource.latlon_bbox) srid = resource.projection if not bbox or not ll: logger.error("GeoServer did not return updated bbox values") return False - # bbox order from GeoServer: [minx, maxx, miny, maxy] with transaction.atomic(): - self.set_bbox_polygon([bbox[0], bbox[2], bbox[1], bbox[3]], srid) - self.set_ll_bbox_polygon([ll[0], ll[2], ll[1], ll[3]]) - self.srid = srid or self.srid + self.set_bbox_and_srid(bbox=bbox, ll_bbox=ll, srid=srid) self.save(update_fields=["srid"]) return True + def set_bbox_and_srid( + self, + bbox: List[Union[int, float]], + ll_bbox: List[Union[int, float]], + srid: str, + ) -> None: + """ + Set dataset bbox, ll_bbox and spatial reference identifier. + Args: + bbox: Bounding box as [minx, maxx, miny, maxy], each element is int or float. + If invalid or unavailable, the method falls back to ll_bbox with EPSG:4326. + ll_bbox: Lat/Lon bounding box as [minx, maxx, miny, maxy], each element is int or float. + Must be valid, otherwise a GeoNodeException is raised. + srid: Spatial Reference Identifier (string). + Returns: + None + """ + if not check_bbox_validity(ll_bbox): + raise GeoNodeException("Lat/Lon BBox was not provided or is invalid") + + ll_bbox_gn = [ll_bbox[0], ll_bbox[2], ll_bbox[1], ll_bbox[3]] + # Try to use native bbox if available + if check_bbox_validity(bbox) and srid: + try: + native_bbox_gn = [bbox[0], bbox[2], bbox[1], bbox[3]] + self.set_bbox_polygon(native_bbox_gn, srid) + self.set_ll_bbox_polygon(ll_bbox_gn) + self.srid = srid + return + except GeoNodeException as e: + logger.warning(f"Failed to set bbox with SRID {srid}, falling back to EPSG:4326: {e}") + + # Fallback to lat/lon bbox + self.set_bbox_polygon(ll_bbox_gn, "EPSG:4326") + self.set_ll_bbox_polygon(ll_bbox_gn) + self.srid = "EPSG:4326" + def __str__(self): return str(self.alternate) diff --git a/geonode/layers/tests.py b/geonode/layers/tests.py index 16a55de4f9b..353c360a681 100644 --- a/geonode/layers/tests.py +++ b/geonode/layers/tests.py @@ -817,6 +817,31 @@ def test_dataset_recalc_bbox_on_geoserver_success(self, mock_get_layer, mock_get self.assertIsNotNone(self.dataset.bbox_polygon) self.assertIsNotNone(self.dataset.ll_bbox_polygon) + @patch("geonode.base.models.bbox_to_projection", side_effect=Exception("Unsupported CRS")) + def test_set_bbox_and_srid_fallback_to_ll_bbox(self, _mock_bbox_to_projection): + """ + If native bbox reprojection fails, fallback to EPSG:4326 and ll_bbox for both polygons. + """ + native_bbox = [1535760, 1786050, 4652670, 5126620] + ll_bbox = [8.7, 9.2, 45.1, 45.5] + + self.dataset.set_bbox_and_srid(bbox=native_bbox, ll_bbox=ll_bbox, srid="EPSG:3003") + + self.assertEqual(self.dataset.srid, "EPSG:4326") + self.assertIsNotNone(self.dataset.bbox_polygon) + self.assertIsNotNone(self.dataset.ll_bbox_polygon) + self.assertEqual(self.dataset.bbox_polygon.wkt, self.dataset.ll_bbox_polygon.wkt) + + def test_set_bbox_and_srid_uses_native_bbox_when_supported(self): + native_bbox = [0, 10, 0, 10] + ll_bbox = [0, 10, 0, 10] + + self.dataset.set_bbox_and_srid(bbox=native_bbox, ll_bbox=ll_bbox, srid="EPSG:4326") + + self.assertEqual(self.dataset.srid, "EPSG:4326") + self.assertIsNotNone(self.dataset.bbox_polygon) + self.assertIsNotNone(self.dataset.ll_bbox_polygon) + @patch("geonode.layers.models.Dataset.recalc_bbox_on_geoserver") def test_recalc_bbox_view_success(self, mock_recalc_bbox): """Test the recalc_bbox view returns 200 when successful.""" diff --git a/geonode/utils.py b/geonode/utils.py index 315d380c83f..e750a1bd02b 100755 --- a/geonode/utils.py +++ b/geonode/utils.py @@ -46,6 +46,7 @@ from math import atan, exp, log, pi, tan from zipfile import ZipFile, is_zipfile, ZIP_DEFLATED from geonode.upload.api.exceptions import GeneralUploadException +from typing import List, Optional, Union from django.conf import settings from django.db.models import signals @@ -422,6 +423,37 @@ def bbox_swap(bbox): return [_bbox[0], _bbox[2], _bbox[1], _bbox[3]] +def check_bbox_validity(value: List[Union[int, float]]) -> bool: + """ + Validate that a bounding box value is a non-empty list of at least 4 numeric values. + + Args: + value: Bounding box to validate, expected as [minx, maxx, miny, maxy]. + Accepts int or float values. None is treated as invalid. + + Returns: + True if `value` is a list with at least 4 numeric (int or float) elements, False otherwise. + """ + if ( + not value + or not isinstance(value, list) + or len(value) < 4 + or not all(isinstance(x, (int, float)) for x in value) + ): + return False + return True + + +def normalize_bbox_to_float_list(value) -> Optional[List[float]]: + """Normalize bbox input to a 4-item float list or return None when invalid.""" + if not value or len(value) < 4: + return None + try: + return [float(x) for x in value[:4]] + except (TypeError, ValueError): + return None + + def bbox_to_wkt(x0, x1, y0, y1, srid="4326", include_srid=True): if srid and str(srid).startswith("EPSG:"): srid = srid[5:]