Skip to content
16 changes: 12 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,22 @@ 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:
logger.error(
"Ambiguous dataset lookup for map layer '%s' (store=%s, ows_url=%s)",
instance.name,
instance.store,
instance.ows_url,
)
raise
Comment thread
etj marked this conversation as resolved.


@deprecated(version="3.2.1", reason="Use direct calls to the ReourceManager.")
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
19 changes: 19 additions & 0 deletions geonode/services/migrations/0061_alter_service_base_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 5.2.13 on 2026-07-02 10:00

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("services", "0060_alter_service_type"),
]

operations = [
migrations.AlterField(
model_name="service",
name="base_url",
field=models.URLField(db_index=True),
),
]

7 changes: 4 additions & 3 deletions geonode/services/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from geonode.layers.enumerations import GXP_PTYPES
from geonode.people.enumerations import ROLE_VALUES
from geonode.services.serviceprocessors import get_available_service_types
from . import enumerations
from geonode.services import enumerations


def get_service_type_choices():
Expand All @@ -56,8 +56,9 @@ class Service(ResourceBase):
(enumerations.OPENGEOPORTAL, _("OpenGeoPortal")),
),
)
# with service, version and request etc stripped off
base_url = models.URLField(unique=True, db_index=True)
# URL with service, version and request etc stripped off
# Intentionally non-unique: the same endpoint can be registered by different owners/use-cases.
base_url = models.URLField(db_index=True)
version = models.CharField(max_length=100, null=True, blank=True)
# Should force to slug?
name = models.CharField(max_length=255, unique=True, db_index=True)
Expand Down
30 changes: 21 additions & 9 deletions geonode/services/serviceprocessors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,43 @@
#########################################################################
import logging

from django.conf import settings
from geonode.services import enumerations
from django.core.cache import caches
from geonode.services.serviceprocessors.registry import service_type_registry
from geonode.services.serviceprocessors.cache import service_handler_cache
from geonode.services.serviceprocessors.registry import service_type_registry

service_cache = caches["services"]
logger = logging.getLogger(__name__)


def get_available_service_types():
return service_type_registry.get_available_service_types()
def get_service_cache_key(base_url, service_type=enumerations.AUTO, service_id=None, auth=None, auth_config=None):
return service_handler_cache.get_key(
base_url, service_type=service_type, service_id=service_id, auth=auth, auth_config=auth_config
)


def get_available_service_types():
return service_type_registry.get_available_service_types()


def get_service_handler(base_url, service_type=enumerations.AUTO, service_id=None, *args, **kwargs):
"""Return the appropriate remote service handler for the input URL.
If the service type is not explicitly passed in it will be guessed from
"""

if entry := service_cache.get(base_url):
cache_key = get_service_cache_key(
base_url,
service_type=service_type,
service_id=service_id,
auth=kwargs.get("auth"),
auth_config=kwargs.get("auth_config"),
)

if entry := service_handler_cache.get(cache_key):
return entry

handler = service_type_registry.get_handler_class(service_type)
handler = service_type_registry.get_handler_class(service_type)
try:
service_handler = handler(base_url, service_id, *args, **kwargs)
service_cache.set(service_handler.url, service_handler, settings.SERVICE_CACHE_EXPIRATION_TIME)
service_handler_cache.set(cache_key, service_handler)
Comment on lines +43 to +57
except Exception as e:
logger.exception(e)
logger.exception(msg=f"Could not parse service {base_url}")
Expand Down
88 changes: 44 additions & 44 deletions geonode/services/serviceprocessors/arcgis.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
import os
import logging
import traceback

from collections import namedtuple
from uuid import uuid4

from arcrest import MapService as ArcMapService, ImageService as ArcImageService

from django.conf import settings
from django.db import transaction
from django.utils.translation import gettext_lazy as _
Expand All @@ -31,16 +33,9 @@
from geonode import GeoNodeException
from geonode.base.bbox_utils import BBOXHelper
from geonode.harvesting.models import Harvester
from geonode.services import enumerations, models, utils
from geonode.services.serviceprocessors import base

from arcrest import MapService as ArcMapService, ImageService as ArcImageService

from .. import enumerations
from ..enumerations import INDEXED
from .. import models
from .. import utils
from . import base

from collections import namedtuple

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -86,7 +81,7 @@ def __init__(self, url, geonode_service_id=None, *args, **kwargs):
# extent['ymax']
# ])

self.indexing_method = INDEXED
self.indexing_method = enumerations.INDEXED
self.name = slugify(self.url)[:255]
self.title = str(_title).encode("utf-8", "ignore").decode("utf-8")

Expand All @@ -110,39 +105,44 @@ def create_geonode_service(self, owner, parent=None):
:type owner: geonode.people.models.Profile

"""
with transaction.atomic():
instance = models.Service.objects.create(
uuid=str(uuid4()),
base_url=self.url,
type=self.service_type,
method=self.indexing_method,
owner=owner,
metadata_only=True,
version=str(self.parsed_service._json_struct.get("currentVersion", 0.0))
.encode("utf-8", "ignore")
.decode("utf-8"),
name=self.name,
title=self.title,
abstract=str(self.parsed_service._json_struct.get("serviceDescription"))
.encode("utf-8", "ignore")
.decode("utf-8")
or _("Not provided"),
)
service_harvester = Harvester.objects.create(
name=self.name,
default_owner=owner,
scheduling_enabled=False,
remote_url=instance.service_url,
delete_orphan_resources_automatically=True,
harvester_type=enumerations.HARVESTER_TYPES[self.service_type],
harvester_type_specific_configuration=self.get_harvester_configuration_options(),
)
if service_harvester.update_availability():
service_harvester.initiate_update_harvestable_resources()
else:
logger.exception(GeoNodeException("Could not reach remote endpoint."))
instance.harvester = service_harvester

def _create(unique_name):
with transaction.atomic():
new_instance = models.Service.objects.create(
uuid=str(uuid4()),
base_url=self.url,
type=self.service_type,
method=self.indexing_method,
owner=owner,
metadata_only=True,
version=str(self.parsed_service._json_struct.get("currentVersion", 0.0))
.encode("utf-8", "ignore")
.decode("utf-8"),
name=unique_name,
title=self.title,
abstract=str(self.parsed_service._json_struct.get("serviceDescription"))
.encode("utf-8", "ignore")
.decode("utf-8")
or _("Not provided"),
)
new_harvester = Harvester.objects.create(
name=unique_name,
default_owner=owner,
scheduling_enabled=False,
remote_url=new_instance.service_url,
delete_orphan_resources_automatically=True,
harvester_type=enumerations.HARVESTER_TYPES[self.service_type],
harvester_type_specific_configuration=self.get_harvester_configuration_options(),
)
if new_harvester.update_availability():
new_harvester.initiate_update_harvestable_resources()
else:
logger.exception(GeoNodeException("Could not reach remote endpoint."))
new_instance.harvester = new_harvester
return new_instance

instance = base.create_with_unique_name(self.name, _create)
self.name = instance.name
self.geonode_service_id = instance.id
return instance

Expand Down Expand Up @@ -235,7 +235,7 @@ def __init__(self, url, geonode_service_id=None, *args, **kwargs):
# extent['ymax']
# ])

self.indexing_method = INDEXED
self.indexing_method = enumerations.INDEXED
self.name = slugify(self.url)[:255]
self.title = _title

Expand Down
47 changes: 44 additions & 3 deletions geonode/services/serviceprocessors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@
import logging

from django.conf import settings
from django.db import IntegrityError

from geonode.utils import check_ogc_backend
from geonode import GeoNodeException, geoserver
from geonode.harvesting.tasks import harvest_resources
from geonode.harvesting.models import AsynchronousHarvestingSession
from geonode.harvesting.models import AsynchronousHarvestingSession, Harvester

from .. import models
from .. import enumerations
from geonode.services import models, enumerations

if check_ogc_backend(geoserver.BACKEND_PACKAGE):
from geonode.geoserver.helpers import gs_catalog as catalog
Expand All @@ -51,6 +51,47 @@ def get_geoserver_cascading_workspace(create=True):
return workspace


def build_unique_resource_name(name, max_length=255):
"""Return a Service/Harvester name that is unique in both models."""
max_length = max(1, int(max_length))
candidate = (name or "service")[:max_length]
base_name = candidate
idx = 1
while models.Service.objects.filter(name=candidate).exists() or Harvester.objects.filter(name=candidate).exists():
suffix = f"-{idx}"
if len(suffix) >= max_length:
# When max_length is tiny, keep the most specific part of the suffix.
candidate = suffix[-max_length:]
else:
prefix_len = max_length - len(suffix)
candidate = f"{base_name[:prefix_len]}{suffix}"
idx += 1
return candidate
Comment thread
etj marked this conversation as resolved.
Comment thread
etj marked this conversation as resolved.


def create_with_unique_name(name, create_fn, max_length=255, max_attempts=3):
"""Call create_fn(unique_name) with a freshly generated unique candidate name,
retrying under a new candidate if a concurrent registration raced us to the
same name and tripped the DB's uniqueness constraint on Service.name.

build_unique_resource_name's own existence check is not atomic with the
caller's actual create(), so two concurrent registrations can still land
on the same candidate; this retries the whole attempt with a fresh name
instead of surfacing the resulting IntegrityError to the caller.

:arg create_fn: callable invoked with the candidate name; must perform the
actual object creation(s) and raise IntegrityError if the name turned
out to already be taken.
"""
for attempt in range(max_attempts):
candidate = build_unique_resource_name(name, max_length=max_length)
try:
return create_fn(candidate)
except IntegrityError:
if attempt == max_attempts - 1:
raise


class ServiceHandlerBase(object): # LGTM: @property will not work in old-style classes
"""Base class for remote service handlers

Expand Down
Loading
Loading