Skip to content

Commit 615a9cb

Browse files
authored
[Fixes #13885] Support for COG Remote Layers (#13887)
* [Fixes #13885] implement support for COG * [Fixes #13885] restrict thumbnail creation for cog * [Fixes #13885] make identify authority common for all
1 parent fba6951 commit 615a9cb

File tree

8 files changed

+300
-46
lines changed

8 files changed

+300
-46
lines changed

geonode/base/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,7 @@ def can_have_style(self):
971971

972972
@property
973973
def can_have_thumbnail(self):
974-
return True
974+
return self.subtype not in {"3dtiles", "cog"}
975975

976976
@property
977977
def raw_purpose(self):

geonode/thumbs/thumbnails.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ def create_thumbnail(
9898
"""
9999

100100
instance.refresh_from_db()
101-
102101
default_thumbnail_name = _generate_thumbnail_name(instance)
103102
mime_type = "image/png"
104103
width = settings.THUMBNAIL_SIZE["width"]

geonode/upload/handlers/base.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from typing import List
2424
import zipfile
2525
import re
26+
import pyproj
2627
from datetime import datetime
2728

2829
import slugify
@@ -402,3 +403,24 @@ def _copy_dynamic_model_rollback(self, exec_id, istance_name=None, *args, **kwar
402403

403404
def _copy_geonode_resource_rollback(self, exec_id, istance_name=None, *args, **kwargs):
404405
self._create_geonode_resource_rollback(exec_id, istance_name=istance_name)
406+
407+
def identify_authority(self, layer):
408+
try:
409+
layer_wkt = layer.GetSpatialRef().ExportToWkt()
410+
_name = "EPSG"
411+
_code = pyproj.CRS(layer_wkt).to_epsg(min_confidence=20)
412+
if _code is None:
413+
layer_proj4 = layer.GetSpatialRef().ExportToProj4()
414+
_code = pyproj.CRS(layer_proj4).to_epsg(min_confidence=20)
415+
if _code is None:
416+
raise Exception("CRS authority code not found, fallback to default behaviour")
417+
except Exception:
418+
spatial_ref = layer.GetSpatialRef()
419+
spatial_ref.AutoIdentifyEPSG()
420+
_name = spatial_ref.GetAuthorityName(None) or spatial_ref.GetAttrValue("AUTHORITY", 0)
421+
_code = (
422+
spatial_ref.GetAuthorityCode("PROJCS")
423+
or spatial_ref.GetAuthorityCode("GEOGCS")
424+
or spatial_ref.GetAttrValue("AUTHORITY", 1)
425+
)
426+
return f"{_name}:{_code}"

geonode/upload/handlers/common/raster.py

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1717
#
1818
#########################################################################
19-
import pyproj
2019
from geonode.assets.local import LocalAssetHandler
2120
from geonode.upload.publisher import DataPublisher
2221
import json
@@ -260,27 +259,6 @@ def extract_resource_to_publish(self, files, action, layer_name, alternate, **kw
260259
}
261260
]
262261

263-
def identify_authority(self, layer):
264-
try:
265-
layer_wkt = layer.GetSpatialRef().ExportToWkt()
266-
_name = "EPSG"
267-
_code = pyproj.CRS(layer_wkt).to_epsg(min_confidence=20)
268-
if _code is None:
269-
layer_proj4 = layer.GetSpatialRef().ExportToProj4()
270-
_code = pyproj.CRS(layer_proj4).to_epsg(min_confidence=20)
271-
if _code is None:
272-
raise Exception("CRS authority code not found, fallback to default behaviour")
273-
except Exception:
274-
spatial_ref = layer.GetSpatialRef()
275-
spatial_ref.AutoIdentifyEPSG()
276-
_name = spatial_ref.GetAuthorityName(None) or spatial_ref.GetAttrValue("AUTHORITY", 0)
277-
_code = (
278-
spatial_ref.GetAuthorityCode("PROJCS")
279-
or spatial_ref.GetAuthorityCode("GEOGCS")
280-
or spatial_ref.GetAttrValue("AUTHORITY", 1)
281-
)
282-
return f"{_name}:{_code}"
283-
284262
def import_resource(self, files: dict, execution_id: str, **kwargs) -> str:
285263
"""
286264
Main function to import the resource.

geonode/upload/handlers/common/vector.py

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@
6868
from geonode.upload.orchestrator import orchestrator
6969
from django.db.models import Q
7070

71-
import pyproj
7271
from geonode.geoserver.security import delete_dataset_cache, set_geowebcache_invalidate_cache
7372
from geonode.geoserver.helpers import get_time_info
7473
from geonode.upload.utils import ImporterRequestAction as ira
@@ -419,27 +418,6 @@ def extract_resource_to_publish(self, files, action, layer_name, alternate, **kw
419418
if self.fixup_name(_l.GetName()) == layer_name
420419
]
421420

422-
def identify_authority(self, layer):
423-
try:
424-
layer_wkt = layer.GetSpatialRef().ExportToWkt()
425-
_name = "EPSG"
426-
_code = pyproj.CRS(layer_wkt).to_epsg(min_confidence=20)
427-
if _code is None:
428-
layer_proj4 = layer.GetSpatialRef().ExportToProj4()
429-
_code = pyproj.CRS(layer_proj4).to_epsg(min_confidence=20)
430-
if _code is None:
431-
raise Exception("CRS authority code not found, fallback to default behaviour")
432-
except Exception:
433-
spatial_ref = layer.GetSpatialRef()
434-
spatial_ref.AutoIdentifyEPSG()
435-
_name = spatial_ref.GetAuthorityName(None) or spatial_ref.GetAttrValue("AUTHORITY", 0)
436-
_code = (
437-
spatial_ref.GetAuthorityCode("PROJCS")
438-
or spatial_ref.GetAuthorityCode("GEOGCS")
439-
or spatial_ref.GetAttrValue("AUTHORITY", 1)
440-
)
441-
return f"{_name}:{_code}"
442-
443421
def get_ogr2ogr_driver(self):
444422
"""
445423
Should return the Driver object that is used to open the layers via OGR2OGR
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
#########################################################################
2+
#
3+
# Copyright (C) 2026 OSGeo
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
#
18+
#########################################################################
19+
import logging
20+
import requests
21+
from osgeo import gdal
22+
23+
from geonode.layers.models import Dataset
24+
from geonode.upload.handlers.common.remote import BaseRemoteResourceHandler
25+
from geonode.upload.api.exceptions import ImportException
26+
from geonode.upload.orchestrator import orchestrator
27+
28+
logger = logging.getLogger("importer")
29+
30+
31+
class RemoteCOGResourceHandler(BaseRemoteResourceHandler):
32+
33+
@property
34+
def supported_file_extension_config(self):
35+
return {}
36+
37+
@staticmethod
38+
def can_handle(_data) -> bool:
39+
"""
40+
This endpoint will return True or False if with the info provided
41+
the handler is able to handle the file or not
42+
"""
43+
if "url" in _data and "cog" in _data.get("type", "").lower():
44+
return True
45+
return False
46+
47+
@staticmethod
48+
def is_valid_url(url, **kwargs):
49+
"""
50+
Check if the URL is reachable and supports HTTP Range requests
51+
"""
52+
logger.debug(f"Checking COG URL validity (HEAD): {url}")
53+
try:
54+
# Reachability check using HEAD
55+
head_res = requests.head(url, timeout=10, allow_redirects=True)
56+
logger.debug(f"HTTP HEAD status: {head_res.status_code}")
57+
head_res.raise_for_status()
58+
59+
accept_ranges = head_res.headers.get("Accept-Ranges", "").lower()
60+
61+
# Check for range request support
62+
if accept_ranges == "bytes":
63+
logger.debug("Server explicitly supports Accept-Ranges: bytes")
64+
return True
65+
66+
# Some servers might not return Accept-Ranges in HEAD, so we try a small range request
67+
logger.debug("Accept-Ranges header missing, trying a small Range GET...")
68+
range_res = requests.get(url, headers={"Range": "bytes=0-1"}, timeout=10, stream=True)
69+
logger.debug(f"Range GET status: {range_res.status_code}")
70+
try:
71+
if range_res.status_code != 206:
72+
raise ImportException(
73+
"The remote server does not support HTTP Range requests, which are required for COG."
74+
)
75+
finally:
76+
range_res.close()
77+
except Exception as e:
78+
logger.debug(f"is_valid_url ERROR: {str(e)}")
79+
logger.exception(e)
80+
if isinstance(e, ImportException):
81+
raise e
82+
raise ImportException("Error checking COG URL")
83+
84+
return True
85+
86+
def create_geonode_resource(
87+
self,
88+
layer_name: str,
89+
alternate: str,
90+
execution_id: str,
91+
resource_type: Dataset = Dataset,
92+
asset=None,
93+
):
94+
"""
95+
Base function to create the resource into geonode.
96+
"""
97+
logger.debug(f"Entering create_geonode_resource for {layer_name}")
98+
_exec = orchestrator.get_execution_object(execution_id)
99+
params = _exec.input_params.copy()
100+
url = params.get("url")
101+
102+
# Extract metadata via GDAL VSICURL
103+
gdal.UseExceptions()
104+
logger.debug(f"Attempting to open COG with GDAL: /vsicurl/{url}")
105+
try:
106+
# Set GDAL config options for faster failure
107+
gdal.SetThreadLocalConfigOption("GDAL_HTTP_TIMEOUT", "15")
108+
gdal.SetThreadLocalConfigOption("GDAL_HTTP_MAX_RETRY", "1")
109+
110+
vsiurl = f"/vsicurl/{url}"
111+
ds = gdal.OpenEx(vsiurl)
112+
if ds is None:
113+
logger.debug(f"GDAL failed to open dataset: {vsiurl}")
114+
raise ImportException(f"Could not open remote COG: {url}")
115+
116+
if not ds.GetSpatialRef():
117+
raise ImportException(f"Could not extract spatial reference from COG: {url}")
118+
119+
srid = self.identify_authority(ds)
120+
121+
# Get BBox
122+
gt = ds.GetGeoTransform()
123+
width = ds.RasterXSize
124+
height = ds.RasterYSize
125+
126+
# Check for rotation
127+
is_rotated = gt[2] != 0 or gt[4] != 0
128+
129+
if is_rotated:
130+
logger.info("COG has rotation/skew - calculating envelope bbox")
131+
# Calculate all four corners
132+
corners = [
133+
(gt[0], gt[3]),
134+
(gt[0] + width * gt[1], gt[3] + width * gt[4]),
135+
(gt[0] + width * gt[1] + height * gt[2], gt[3] + width * gt[4] + height * gt[5]),
136+
(gt[0] + height * gt[2], gt[3] + height * gt[5]),
137+
]
138+
xs = [x for x, y in corners]
139+
ys = [y for x, y in corners]
140+
bbox = [min(xs), min(ys), max(xs), max(ys)]
141+
else:
142+
# Simple calculation for north-up images
143+
minx = gt[0]
144+
maxy = gt[3]
145+
maxx = gt[0] + width * gt[1]
146+
miny = gt[3] + height * gt[5]
147+
bbox = [minx, miny, maxx, maxy]
148+
149+
ds = None # close dataset
150+
logger.debug("GDAL operations finished.")
151+
except Exception as e:
152+
logger.debug(f"GDAL ERROR: {str(e)}")
153+
logger.exception(e)
154+
if isinstance(e, ImportException):
155+
raise e
156+
raise ImportException(f"Failed to extract metadata from COG: {url}")
157+
resource = super().create_geonode_resource(layer_name, alternate, execution_id, resource_type, asset)
158+
resource.set_bbox_polygon(bbox, srid)
159+
return resource
160+
161+
def generate_resource_payload(self, layer_name, alternate, asset, _exec, workspace, **kwargs):
162+
payload = super().generate_resource_payload(layer_name, alternate, asset, _exec, workspace, **kwargs)
163+
payload.update(
164+
{
165+
"name": alternate,
166+
}
167+
)
168+
return payload
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#########################################################################
2+
#
3+
# Copyright (C) 2026 OSGeo
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
#
18+
#########################################################################
19+
from django.test import TestCase
20+
from mock import MagicMock, patch
21+
from geonode.upload.api.exceptions import ImportException
22+
from django.contrib.auth import get_user_model
23+
from geonode.upload.handlers.remote.cog import RemoteCOGResourceHandler
24+
from geonode.upload.orchestrator import orchestrator
25+
from geonode.layers.models import Dataset
26+
from geonode.base.models import Link
27+
28+
29+
class TestRemoteCOGResourceHandler(TestCase):
30+
databases = ("default", "datastore")
31+
32+
@classmethod
33+
def setUpClass(cls):
34+
super().setUpClass()
35+
cls.handler = RemoteCOGResourceHandler()
36+
cls.valid_url = "http://example.com/test.tif"
37+
cls.user, _ = get_user_model().objects.get_or_create(username="admin")
38+
cls.valid_payload = {
39+
"url": cls.valid_url,
40+
"type": "cog",
41+
"title": "COG Test",
42+
}
43+
cls.owner = cls.user
44+
45+
def test_can_handle_cog(self):
46+
self.assertTrue(self.handler.can_handle(self.valid_payload))
47+
self.assertTrue(self.handler.can_handle({"url": "http://example.com/y.tiff", "type": "cog"}))
48+
self.assertFalse(self.handler.can_handle({"url": "http://example.com/y.jpg", "type": "image"}))
49+
50+
@patch("geonode.upload.handlers.remote.cog.requests.head")
51+
@patch("geonode.upload.handlers.remote.cog.requests.get")
52+
@patch("geonode.upload.handlers.common.remote.requests.get")
53+
def test_is_valid_url_success(self, mock_base_get, mock_get, mock_head):
54+
mock_head.return_value.headers = {"Accept-Ranges": "bytes"}
55+
mock_head.return_value.status_code = 200
56+
mock_base_get.return_value.status_code = 200
57+
58+
self.assertTrue(self.handler.is_valid_url(self.valid_url))
59+
60+
@patch("geonode.upload.handlers.remote.cog.requests.head")
61+
@patch("geonode.upload.handlers.remote.cog.requests.get")
62+
@patch("geonode.upload.handlers.common.remote.requests.get")
63+
def test_is_valid_url_no_range_support(self, mock_base_get, mock_get, mock_head):
64+
mock_head.return_value.headers = {}
65+
mock_get.return_value.status_code = 404 # Not 206
66+
mock_base_get.return_value.status_code = 200
67+
68+
with self.assertRaises(ImportException):
69+
self.handler.is_valid_url(self.valid_url)
70+
71+
@patch("geonode.upload.handlers.remote.cog.gdal.OpenEx")
72+
def test_create_geonode_resource(self, mock_gdal_openex):
73+
# Mock GDAL dataset
74+
mock_ds = MagicMock()
75+
mock_srs = MagicMock()
76+
mock_srs.GetAuthorityName.return_value = "EPSG"
77+
mock_srs.GetAuthorityCode.return_value = "4326"
78+
mock_ds.GetSpatialRef.return_value = mock_srs
79+
mock_ds.GetGeoTransform.return_value = [0, 1, 0, 0, 0, -1]
80+
mock_ds.RasterXSize = 100
81+
mock_ds.RasterYSize = 100
82+
mock_gdal_openex.return_value = mock_ds
83+
84+
exec_id = orchestrator.create_execution_request(
85+
user=self.owner,
86+
func_name="funct1",
87+
step="step",
88+
input_params=self.valid_payload,
89+
)
90+
91+
resource = self.handler.create_geonode_resource(
92+
"test_cog",
93+
"test_cog_alternate",
94+
execution_id=str(exec_id),
95+
resource_type=Dataset,
96+
)
97+
98+
self.assertIsNotNone(resource)
99+
self.assertEqual(resource.subtype, "cog")
100+
self.assertEqual(resource.alternate, "test_cog_alternate")
101+
self.assertEqual(resource.srid, "EPSG:4326")
102+
self.assertIsNotNone(resource.bbox_polygon)
103+
104+
# Verify Link creation (where URL is stored)
105+
link = Link.objects.get(resource=resource, link_type="data")
106+
self.assertEqual(link.url, self.valid_url)
107+
self.assertEqual(link.extension, "cog")
108+
self.assertEqual(link.name, resource.alternate)

0 commit comments

Comments
 (0)