Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions geonode/base/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ def test_base_resources(self):
response = self.client.get(url, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 5)
self.assertEqual(response.data["total"], 28)
self.assertEqual(response.data["total"], 29)

url = f"{reverse('base-resources-list')}?filter{{metadata_only}}=false"
# Anonymous
Expand Down Expand Up @@ -984,7 +984,7 @@ def test_sort_resources(self):
response = self.client.get(f"{url}?sort[]=title", format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 5)
self.assertEqual(response.data["total"], 28)
self.assertEqual(response.data["total"], 29)
# Pagination
self.assertEqual(len(response.data["resources"]), 10)

Expand All @@ -997,7 +997,7 @@ def test_sort_resources(self):
response = self.client.get(f"{url}?sort[]=-title", format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 5)
self.assertEqual(response.data["total"], 28)
self.assertEqual(response.data["total"], 29)
# Pagination
self.assertEqual(len(response.data["resources"]), 10)

Expand Down
17 changes: 13 additions & 4 deletions geonode/geoserver/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
from geonode.layers.models import Dataset
from geonode.services.enumerations import CASCADED

from . import BACKEND_PACKAGE
from .tasks import geoserver_cascading_delete, geoserver_post_save_datasets
from geonode.geoserver import BACKEND_PACKAGE
from geonode.geoserver.tasks import geoserver_cascading_delete, geoserver_post_save_datasets

logger = logging.getLogger("geonode.geoserver.signals")

Expand Down Expand Up @@ -104,14 +104,23 @@ def geoserver_pre_save_maplayer(instance, sender, **kwargs):
# Set dataset
if instance.dataset is None:
dataset_queryset = Dataset.objects.filter(Q(alternate=instance.name) | Q(name=instance.name))
if instance.local and instance.store:
if instance.store:
dataset_queryset = dataset_queryset.filter(store=instance.store)
elif instance.ows_url:
dataset_queryset = dataset_queryset.filter(remote_service__base_url=instance.ows_url)
try:
instance.dataset = dataset_queryset.get()
except (Dataset.DoesNotExist, Dataset.MultipleObjectsReturned):
except Dataset.DoesNotExist:
pass
except Dataset.MultipleObjectsReturned:
# Expected since #14381: the same endpoint can be registered by several services, so a
# name can match one dataset per service. Nothing on the map layer tells them apart.
logger.warning(
"Ambiguous dataset lookup for map layer '%s' (store=%s, ows_url=%s), leaving it unresolved",
instance.name,
instance.store,
instance.ows_url,
)


@deprecated(version="3.2.1", reason="Use direct calls to the ReourceManager.")
Expand Down
9 changes: 4 additions & 5 deletions geonode/harvesting/harvesters/arcgis.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
import arcrest
import requests
from django.contrib.gis import geos
from django.template.defaultfilters import slugify

from geonode.layers.enumerations import GXP_PTYPES
from geonode.layers.models import Dataset
Expand Down Expand Up @@ -204,7 +203,6 @@ def _get_resource_descriptor(
_, service_name, service_type = parse_remote_url(harvestable_resource.unique_identifier)
epsg_code, spatial_extent = _parse_spatial_extent(layer_representation["extent"])
ows_url = harvestable_resource.harvester.remote_url
store = slugify(ows_url)
name = layer_representation.get("id", layer_representation.get("name", "Undefined"))
title = layer_representation.get("name", layer_representation.get("title", "Undefined"))
workspace = "remoteWorkspace"
Expand All @@ -228,7 +226,8 @@ def _get_resource_descriptor(
),
reference_systems=[epsg_code],
additional_parameters={
"store": store,
# store is a GeoServer grouping: remote datasets have none
"store": None,
"workspace": workspace,
"alternate": alternate,
"ows_url": ows_url,
Expand Down Expand Up @@ -306,7 +305,6 @@ def _get_resource_descriptor(
_, service_name, service_type = parse_remote_url(harvestable_resource.unique_identifier)
epsg_code, spatial_extent = _parse_spatial_extent(layer_representation["extent"])
ows_url = harvestable_resource.harvester.remote_url
store = slugify(ows_url)
name = layer_representation.get("id", layer_representation.get("name", "Undefined"))
title = layer_representation.get("name", layer_representation.get("title", "Undefined"))
workspace = "remoteWorkspace"
Expand All @@ -330,7 +328,8 @@ def _get_resource_descriptor(
),
reference_systems=[epsg_code],
additional_parameters={
"store": store,
# store is a GeoServer grouping: remote datasets have none
"store": None,
"workspace": workspace,
"alternate": alternate,
"ows_url": ows_url,
Expand Down
4 changes: 4 additions & 0 deletions geonode/harvesting/harvesters/geonodeharvester.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,8 @@ def _get_resource_descriptor(
{
"alternate": resource["alternate"],
"workspace": resource["workspace"],
# store is a GeoServer grouping: remote datasets have none
"store": None,
}
)
return descriptor
Expand Down Expand Up @@ -782,6 +784,8 @@ def _get_dataset_additional_parameters(
"resource_type": "dataset",
"alternate": api_record.get("alternate", descriptor.identification.name),
"workspace": api_record.get("workspace"),
# store is a GeoServer grouping: remote datasets have none
"store": None,
}
if descriptor.identification.native_format.lower() == RemoteDatasetType.VECTOR.value:
result["subtype"] = GeoNodeDatasetType.VECTOR.value
Expand Down
4 changes: 1 addition & 3 deletions geonode/harvesting/harvesters/wms.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@

from django.conf import settings
from django.contrib.gis import geos
from django.template.defaultfilters import slugify
from geonode.layers.models import Dataset
from geonode.base.models import Link, ResourceBase
from geonode.layers.enumerations import GXP_PTYPES
Expand Down Expand Up @@ -309,7 +308,6 @@ def get_resource(
# WMS does not provide the date of the resource.
# Use current time for the date stamp and resource time.
time = datetime.now()
service_name = slugify(self.remote_url)[:255]
contact = resourcedescriptor.RecordDescriptionContact(**data["contact"])
result = base.HarvestedResourceInfo(
resource_descriptor=resourcedescriptor.RecordDescription(
Expand All @@ -335,7 +333,7 @@ def get_resource(
reference_systems=[relevant_layer["crs"]],
additional_parameters={
"alternate": relevant_layer["name"],
"store": service_name,
"store": None,
"workspace": "remoteWorkspace",
"ows_url": relevant_layer["wms_url"],
"ptype": GXP_PTYPES["WMS"],
Expand Down
24 changes: 24 additions & 0 deletions geonode/harvesting/tests/test_harvester_worker_wms.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
#########################################################################
from unittest import mock

from geonode.harvesting.harvesters import wms
from geonode.tests.base import GeoNodeBaseSimpleTestSupport

Expand All @@ -29,3 +31,25 @@ def test_get_nsmap(self):
for original, expected in fixtures:
result = wms._get_nsmap(original)
self.assertEqual(result, expected)

def test_harvested_resource_has_no_store(self):
# store is a GeoServer grouping, so harvested datasets must not claim one (#14381)
data = {
"contact": {"role": "", "name": "someone"},
"layers": [
{
"name": "bahra",
"title": "Bahra",
"abstract": "",
"keywords": [],
"spatial_extent": None,
"crs": "EPSG:4326",
"wms_url": "https://wms.example.org/geoserver/wms?layers=bahra",
}
],
}
worker = wms.OgcWmsHarvester("https://wms.example.org/geoserver/wms", 1)
harvestable_resource = mock.MagicMock(unique_identifier="bahra", geonode_resource=None)
with mock.patch.object(worker, "_get_data", return_value=data):
harvested = worker.get_resource(harvestable_resource)
self.assertIsNone(harvested.resource_descriptor.additional_parameters["store"])
11 changes: 6 additions & 5 deletions geonode/layers/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ def test_filter_dirty_state(self):
self.assertEqual(response.status_code, 200)
dataset_list = response.data["datasets"]

# dirty resource is now included in count
resource_count_clean = Dataset.objects.count()
# dirty resource is now included in count; the listing excludes metadata-only datasets
resource_count_clean = Dataset.objects.filter(metadata_only=False).count()
self.assertEqual(len(dataset_list), resource_count_clean)
# ensure that the updated dirty dataset is included in the response
self.assertTrue(dirty_dataset.pk in [int(dataset["pk"]) for dataset in dataset_list])
Expand All @@ -157,10 +157,11 @@ def test_filter_dirty_state_include_dirty(self):
dirty_dataset.dirty_state = True
dirty_dataset.save()

# the listing excludes metadata-only datasets
# clean resources
resource_count_clean = Dataset.objects.filter(dirty_state=False).count()
resource_count_clean = Dataset.objects.filter(dirty_state=False, metadata_only=False).count()
# dirty resources
resource_count_dirty = Dataset.objects.filter(dirty_state=True).count()
resource_count_dirty = Dataset.objects.filter(dirty_state=True, metadata_only=False).count()

resource_count_all = resource_count_clean + resource_count_dirty

Expand All @@ -187,7 +188,7 @@ def test_dataset_listing_advertised(self):

prev_count = payload.json().get("total")
# the user can see only the advertised resources
self.assertEqual(Dataset.objects.filter(advertised=True).count(), prev_count)
self.assertEqual(Dataset.objects.filter(advertised=True, metadata_only=False).count(), prev_count)

payload = self.client.get(f"{url}?advertised=True")
# so if advertised is True, we dont see the advertised=False resource
Expand Down
17 changes: 17 additions & 0 deletions geonode/layers/migrations/0047_alter_dataset_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.2.13 on 2026-08-14 10:00

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("layers", "0046_remove_modeltranslation"),
]

operations = [
migrations.AlterField(
model_name="dataset",
name="store",
field=models.CharField(blank=True, max_length=255, null=True, verbose_name="Store"),
),
]
3 changes: 2 additions & 1 deletion geonode/layers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ class Dataset(ResourceBase):
# internal fields
objects = DatasetManager()
workspace = models.CharField(_("Workspace"), max_length=255)
store = models.CharField(_("Store"), max_length=255)
# Store is nullable for remote layers
store = models.CharField(_("Store"), max_length=255, null=True, blank=True)
name = models.CharField(_("Name"), max_length=255)
typename = models.CharField(_("Typename"), max_length=255, null=True, blank=True)
ows_url = models.URLField(
Expand Down
55 changes: 54 additions & 1 deletion geonode/layers/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,22 @@ def test_dataset_attribute_config(self):
attribute_config = lyr.attribute_config()
self.assertTrue("ftInfoTemplate" not in attribute_config)

def test_remote_datasets_are_exempt_from_store_workspace_name_uniqueness(self):
# store is NULL for remote datasets, and PostgreSQL does not consider two NULLs equal,
# so the (store, workspace, name) uniqueness no longer applies to them: the same remote
# endpoint can be registered by more than one service (#14381).
common = dict(
owner=get_user_model().objects.get(username="admin"),
workspace="remoteWorkspace",
name="bahra",
subtype="remote",
store=None,
)
first = Dataset.objects.create(uuid=str(uuid4()), title="bahra", alternate="bahra", **common)
second = Dataset.objects.create(uuid=str(uuid4()), title="bahra", alternate="bahra", **common)
self.assertNotEqual(first.pk, second.pk)
self.assertEqual(Dataset.objects.filter(workspace="remoteWorkspace", name="bahra").count(), 2)

def test_dataset_styles(self):
lyr = Dataset.objects.all().first()
# There should be a total of 3 styles
Expand All @@ -296,7 +312,7 @@ def test_dataset_styles(self):
except UnicodeEncodeError:
self.fail("str of the Style model throws a UnicodeEncodeError with special characters.")

def test_dataset_links(self):
def test_vector_links(self):
lyr = Dataset.objects.filter(subtype="vector").first()
self.assertEqual(lyr.subtype, "vector")

Expand Down Expand Up @@ -324,6 +340,7 @@ def test_dataset_links(self):
links = Link.objects.filter(resource=lyr.resourcebase_ptr, link_type="image")
self.assertIsNotNone(links)

def test_raster_links(self):
lyr = Dataset.objects.filter(subtype="raster").first()
self.assertEqual(lyr.subtype, "raster")
if check_ogc_backend(geoserver.BACKEND_PACKAGE):
Expand All @@ -350,6 +367,42 @@ def test_dataset_links(self):
links = Link.objects.filter(resource=lyr.resourcebase_ptr, link_type="image")
self.assertIsNotNone(links)

def test_set_resource_default_links_passes_auth_config_to_service_handler(self):
remote_auth_config = object()
instance = MagicMock()
instance.resourcebase_ptr = MagicMock()
instance.srid = "EPSG:4326"
instance.bbox_polygon = True
instance.bbox_string = "0,0,1,1"
instance.ows_url = "http://example.com/ows"
instance.alternate = "remote:layer"
instance.can_have_wfs_links = False
instance.subtype = "vector"
instance.can_have_style = False
instance.prepare_wms_links.return_value = []
instance.get_thumbnail_url.return_value = None
instance.get_real_instance.return_value = MagicMock(ptype="NOT_WMS")
instance.remote_service = MagicMock(
service_url="http://example.com/ows?service=WMS",
type="WMS",
id=42,
auth_config=remote_auth_config,
)

with (
patch("geonode.utils.check_ogc_backend", return_value=True),
patch("geonode.resource.utils.is_remote_resource", return_value=False),
patch("geonode.base.models.Link.objects") as mock_link_objects,
patch("geonode.services.serviceprocessors.get_service_handler") as mock_get_service_handler,
):
mock_link_objects.filter.return_value.count.return_value = 0
mock_get_service_handler.return_value = MagicMock(_create_dataset_legend_link=MagicMock())

set_resource_default_links(instance, instance)

_, kwargs = mock_get_service_handler.call_args
self.assertIs(kwargs.get("auth_config"), remote_auth_config)

def test_get_valid_user(self):
# Verify it accepts an admin user
adminuser = get_user_model().objects.get(is_superuser=True)
Expand Down
6 changes: 4 additions & 2 deletions geonode/security/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1438,7 +1438,8 @@ def test_get_visible_resources_should_return_resource_with_metadata_only_true(se
actual = get_visible_resources(
queryset=layers, metadata_only=True, user=get_user_model().objects.get(username=self.user)
)
self.assertEqual(1, actual.count())
# plus "dataset metadata true" from the fixtures
self.assertEqual(2, actual.count())
finally:
if dataset:
dataset.delete()
Expand Down Expand Up @@ -1470,7 +1471,8 @@ def test_get_visible_resources_advanced_workflow(self):
self.assertIsNotNone(standard_user)
admin_user.is_superuser = True
admin_user.save()
layers = Dataset.objects.all()
# get_visible_resources only returns metadata_only=False resources
layers = Dataset.objects.filter(metadata_only=False)

actual = get_visible_resources(
queryset=Dataset.objects.all(),
Expand Down
14 changes: 5 additions & 9 deletions geonode/services/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
import taggit
from taggit.forms import TagField

from geonode.security.auth_handlers import BasicAuthHandler
from geonode.security.auth_registry import auth_handler_registry
from geonode.security.models import AuthConfig

from . import enumerations
from .models import Service, get_service_type_choices
from .serviceprocessors import get_service_handler
from geonode.services import enumerations
from geonode.services.models import Service, get_service_type_choices
from geonode.services.serviceprocessors import get_service_handler
from geonode.utils import is_safe_url

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -72,10 +72,6 @@ def clean_url(self):

if not is_safe_url(proposed_url):
raise ValidationError(_("Invalid URL provided"))

existing = Service.objects.filter(base_url=proposed_url).exists()
if existing:
raise ValidationError(_("Service %(url)s is already registered"), params={"url": proposed_url})
return proposed_url

def clean(self):
Expand Down Expand Up @@ -129,7 +125,7 @@ class ServiceForm(forms.ModelForm):
)
description = forms.CharField(label=_("Description"), widget=forms.Textarea(attrs={"cols": 60}))
abstract = forms.CharField(label=_("Abstract"), widget=forms.Textarea(attrs={"cols": 60}))
keywords = taggit.forms.TagField(required=False)
keywords = TagField(required=False)

class Meta:
model = Service
Expand Down
Loading
Loading