From 9b0eb828f72fe5d0ae3348286d9c1917a20e2de4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 13 Apr 2026 08:55:53 +0000
Subject: [PATCH 1/5] Initial plan
From 8a8893c3a833d896dd927d8275ff2ba74e1ed11a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 13 Apr 2026 09:07:20 +0000
Subject: [PATCH 2/5] Add deprecated backward-compatible ExtraMetadata API
adapters
Add isolated deprecated adapters that re-expose the old /extra_metadata/
endpoint and metadata serializer field using SparseField as storage.
- New file: deprecated_extra_metadata.py (self-contained, easy to remove)
- DeprecatedExtraMetadataMixin: restores GET/PUT/POST/DELETE endpoint
- DeprecatedExtraMetadataField: restores metadata deferred field
- All marked @deprecated(version="4.4.0") with deprecation warnings
- Tests covering all CRUD operations and serializer field
Agent-Logs-Url: https://github.com/GeoNode/geonode/sessions/39c9c964-1dcf-4194-8452-fac2b2f27292
Co-authored-by: etj <717359+etj@users.noreply.github.com>
---
geonode/base/api/deprecated_extra_metadata.py | 311 ++++++++++++++++++
geonode/base/api/serializers.py | 4 +
geonode/base/api/tests.py | 149 +++++++++
geonode/base/api/views.py | 5 +-
4 files changed, 468 insertions(+), 1 deletion(-)
create mode 100644 geonode/base/api/deprecated_extra_metadata.py
diff --git a/geonode/base/api/deprecated_extra_metadata.py b/geonode/base/api/deprecated_extra_metadata.py
new file mode 100644
index 00000000000..8269e4d208d
--- /dev/null
+++ b/geonode/base/api/deprecated_extra_metadata.py
@@ -0,0 +1,311 @@
+# #########################################################################
+#
+# Copyright (C) 2025 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 .
+#
+# #########################################################################
+"""
+Deprecated backward-compatible adapters for the ExtraMetadata API.
+
+These adapters re-expose the old ``/extra_metadata/`` endpoint and the
+``metadata`` serializer field using :class:`SparseField` as the storage
+backend. They are marked **deprecated** and should be removed after the
+deprecation period.
+
+To remove these adapters:
+ 1. Delete this file.
+ 2. Remove the ``metadata`` field and import from ``serializers.py``.
+ 3. Remove the ``extra_metadata`` action and import from ``views.py``.
+"""
+
+import json
+import logging
+import warnings
+
+from deprecated import deprecated
+from rest_framework.decorators import action
+from rest_framework.response import Response
+from drf_spectacular.utils import extend_schema
+
+from dynamic_rest.fields.fields import DynamicComputedField
+
+from geonode.base.models import ResourceBase
+from geonode.metadata.models import SparseField
+
+logger = logging.getLogger(__name__)
+
+DEPRECATION_VERSION = "4.4.0"
+DEPRECATION_REASON = (
+ "The extra_metadata API is deprecated and will be removed in a future "
+ "version. Use the sparse fields API instead."
+)
+SPARSE_FIELD_PREFIX = "extra_metadata_"
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _sparse_fields_for_resource(resource):
+ """Return SparseField entries that represent migrated ExtraMetadata."""
+ return SparseField.objects.filter(
+ resource=resource,
+ name__startswith=SPARSE_FIELD_PREFIX,
+ )
+
+
+def _sparse_to_legacy(sparse_field):
+ """Convert a SparseField into the old ExtraMetadata representation.
+
+ Returns ``{"id": , ...metadata_dict}`` so existing consumers see the
+ same shape they used to get.
+ """
+ try:
+ metadata = json.loads(sparse_field.value) if sparse_field.value else {}
+ except (json.JSONDecodeError, TypeError):
+ metadata = {}
+ return {**{"id": sparse_field.pk}, **metadata}
+
+
+def _next_sparse_name(resource):
+ """Generate the next available ``extra_metadata_`` name."""
+ existing = (
+ SparseField.objects.filter(
+ resource=resource,
+ name__startswith=SPARSE_FIELD_PREFIX,
+ )
+ .order_by("-name")
+ .values_list("name", flat=True)
+ )
+ max_n = 0
+ for name in existing:
+ suffix = name[len(SPARSE_FIELD_PREFIX):]
+ try:
+ max_n = max(max_n, int(suffix))
+ except (ValueError, TypeError):
+ pass
+ return f"{SPARSE_FIELD_PREFIX}{max_n + 1}"
+
+
+# ---------------------------------------------------------------------------
+# Deprecated serializer field (for ``metadata`` on ResourceBaseSerializer)
+# ---------------------------------------------------------------------------
+
+
+class DeprecatedExtraMetadataField(DynamicComputedField):
+ """Deferred computed field that reconstructs the legacy ``metadata``
+ representation from :class:`SparseField` entries.
+
+ .. deprecated:: 4.4.0
+ Use the sparse fields API instead.
+ """
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ @deprecated(version=DEPRECATION_VERSION, reason=DEPRECATION_REASON)
+ def get_attribute(self, instance):
+ warnings.warn(DEPRECATION_REASON, DeprecationWarning, stacklevel=2)
+ try:
+ qs = _sparse_fields_for_resource(instance)
+ return [_sparse_to_legacy(sf) for sf in qs]
+ except Exception as e:
+ logger.exception(e)
+ return []
+
+
+# ---------------------------------------------------------------------------
+# Deprecated extra_metadata view action mixin
+# ---------------------------------------------------------------------------
+
+
+class DeprecatedExtraMetadataMixin:
+ """Mixin that adds the deprecated ``extra_metadata`` action back to a
+ ``ViewSet``.
+
+ Import this mixin and add it to your ViewSet's bases to restore the
+ old ``/{pk}/extra_metadata/`` endpoint backed by SparseFields.
+
+ .. deprecated:: 4.4.0
+ Use the sparse fields API instead.
+ """
+
+ @extend_schema(
+ methods=["get", "put", "delete", "post"],
+ description=(
+ "[DEPRECATED] Get/Update/Delete/Add extra metadata for a resource. "
+ "Use the sparse fields API instead."
+ ),
+ deprecated=True,
+ )
+ @action(
+ detail=True,
+ methods=["get", "put", "delete", "post"],
+ url_path=r"extra_metadata",
+ url_name="extra-metadata",
+ )
+ def extra_metadata(self, request, pk, *args, **kwargs):
+ """Deprecated endpoint – delegates to SparseField storage."""
+ warnings.warn(DEPRECATION_REASON, DeprecationWarning, stacklevel=2)
+ logger.warning(DEPRECATION_REASON)
+
+ resource = ResourceBase.objects.filter(pk=pk).first()
+ if resource is None:
+ return Response({"detail": "Not found."}, status=404)
+
+ if request.method == "GET":
+ return self._extra_metadata_get(request, resource)
+ elif request.method == "POST":
+ return self._extra_metadata_post(request, resource)
+ elif request.method == "PUT":
+ return self._extra_metadata_put(request, resource)
+ elif request.method == "DELETE":
+ return self._extra_metadata_delete(request, resource)
+
+ # -- GET ----------------------------------------------------------------
+
+ @staticmethod
+ def _extra_metadata_get(request, resource):
+ qs = _sparse_fields_for_resource(resource)
+ # Support the old query-param filtering (e.g. ?field_name=value)
+ for key, value in request.query_params.items():
+ # Old API used metadata__=value JSONField lookups.
+ # We approximate this by filtering on the JSON string.
+ filtered = []
+ for sf in qs:
+ try:
+ meta = json.loads(sf.value) if sf.value else {}
+ except (json.JSONDecodeError, TypeError):
+ continue
+ if str(meta.get(key)) == str(value):
+ filtered.append(sf)
+ qs = filtered
+ break # Old API only used the first filter pair
+
+ if isinstance(qs, list):
+ return Response([_sparse_to_legacy(sf) for sf in qs])
+ return Response([_sparse_to_legacy(sf) for sf in qs])
+
+ # -- POST ---------------------------------------------------------------
+
+ @staticmethod
+ def _extra_metadata_post(request, resource):
+ data = request.data
+ if isinstance(data, str):
+ try:
+ data = json.loads(data)
+ except (json.JSONDecodeError, TypeError):
+ return Response(
+ {"detail": "Invalid JSON payload."},
+ status=400,
+ )
+
+ if not isinstance(data, list):
+ return Response(
+ {"detail": "Expected a JSON list of metadata objects."},
+ status=400,
+ )
+
+ for meta_dict in data:
+ if not isinstance(meta_dict, dict):
+ continue
+ meta_dict.pop("id", None)
+ value = json.dumps(meta_dict)
+ if len(value) > 1024:
+ logger.warning(
+ "extra_metadata entry skipped: serialized value exceeds "
+ "1024 characters"
+ )
+ continue
+ name = _next_sparse_name(resource)
+ SparseField.objects.create(
+ resource=resource, name=name, value=value
+ )
+
+ result = [_sparse_to_legacy(sf) for sf in _sparse_fields_for_resource(resource)]
+ return Response(result, status=201)
+
+ # -- PUT ----------------------------------------------------------------
+
+ @staticmethod
+ def _extra_metadata_put(request, resource):
+ data = request.data
+ if isinstance(data, str):
+ try:
+ data = json.loads(data)
+ except (json.JSONDecodeError, TypeError):
+ return Response(
+ {"detail": "Invalid JSON payload."},
+ status=400,
+ )
+
+ if not isinstance(data, list):
+ return Response(
+ {"detail": "Expected a JSON list of metadata objects."},
+ status=400,
+ )
+
+ for meta_dict in data:
+ if not isinstance(meta_dict, dict):
+ continue
+ sf_id = meta_dict.pop("id", None)
+ if sf_id is None:
+ continue
+ value = json.dumps(meta_dict)
+ if len(value) > 1024:
+ logger.warning(
+ "extra_metadata entry skipped: serialized value exceeds "
+ "1024 characters"
+ )
+ continue
+ SparseField.objects.filter(
+ pk=sf_id,
+ resource=resource,
+ name__startswith=SPARSE_FIELD_PREFIX,
+ ).update(value=value)
+
+ result = [_sparse_to_legacy(sf) for sf in _sparse_fields_for_resource(resource)]
+ return Response(result)
+
+ # -- DELETE -------------------------------------------------------------
+
+ @staticmethod
+ def _extra_metadata_delete(request, resource):
+ data = request.data
+ if isinstance(data, str):
+ try:
+ data = json.loads(data)
+ except (json.JSONDecodeError, TypeError):
+ return Response(
+ {"detail": "Invalid JSON payload."},
+ status=400,
+ )
+
+ if not isinstance(data, list):
+ return Response(
+ {"detail": "Expected a JSON list of IDs."},
+ status=400,
+ )
+
+ ids = [int(i) for i in data if isinstance(i, (int, str)) and str(i).isdigit()]
+ SparseField.objects.filter(
+ pk__in=ids,
+ resource=resource,
+ name__startswith=SPARSE_FIELD_PREFIX,
+ ).delete()
+
+ result = [_sparse_to_legacy(sf) for sf in _sparse_fields_for_resource(resource)]
+ return Response(result)
diff --git a/geonode/base/api/serializers.py b/geonode/base/api/serializers.py
index 812b7e98ac3..6d8094518d0 100644
--- a/geonode/base/api/serializers.py
+++ b/geonode/base/api/serializers.py
@@ -67,6 +67,7 @@
from geonode.assets.handlers import asset_handler_registry
from geonode.utils import build_absolute_uri
from geonode.security.utils import get_resources_with_perms, get_geoapp_subtypes
+from geonode.base.api.deprecated_extra_metadata import DeprecatedExtraMetadataField
from geonode.resource.models import ExecutionRequest
from django.contrib.gis.geos import Polygon
from geonode.security.registry import permissions_registry
@@ -654,6 +655,8 @@ class ResourceBaseSerializer(MultiLangOutputMixin, DynamicModelSerializer):
links = DynamicRelationField(LinksSerializer, source="id", read_only=True)
# Deferred fields
+ # Deprecated: use the sparse fields API instead of ``metadata``.
+ metadata = DeprecatedExtraMetadataField(deferred=True, read_only=True)
data = DataBlobField(DataBlobSerializer, source="id", deferred=True, required=False)
executions = DynamicRelationField(
ResourceExecutionRequestSerializer, source="id", deferred=True, required=False, read_only=True
@@ -735,6 +738,7 @@ class Meta:
"sourcetype",
"is_copyable",
"blob",
+ "metadata", # Deprecated: use sparse fields API instead
"executions",
"linked_resources",
"download_url",
diff --git a/geonode/base/api/tests.py b/geonode/base/api/tests.py
index f2bc56fb1bb..d52a096d4de 100644
--- a/geonode/base/api/tests.py
+++ b/geonode/base/api/tests.py
@@ -4175,3 +4175,152 @@ def test_map_layer_permission_caching(self):
# Check that the permissions in the layers are the same
for layer1, layer2 in zip(data1["map"]["maplayers"], data2["map"]["maplayers"]):
self.assertEqual(layer1["dataset"]["perms"], layer2["dataset"]["perms"])
+
+
+class DeprecatedExtraMetadataApiTest(GeoNodeBaseTestSupport):
+ """Tests for the deprecated backward-compatible ExtraMetadata API adapters.
+
+ These adapters re-expose the old ``/extra_metadata/`` endpoint and the
+ ``metadata`` serializer field using SparseField as the storage backend.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.admin = get_user_model().objects.get(username="admin")
+ self.dataset = create_single_dataset("deprecated_em_test")
+
+ def _url(self, pk=None):
+ pk = pk or self.dataset.pk
+ return urljoin(
+ f"{reverse('base-resources-list')}/",
+ f"{pk}/extra_metadata",
+ )
+
+ def test_get_empty_extra_metadata(self):
+ """GET should return an empty list when no extra metadata exists."""
+ self.client.login(username="admin", password="admin")
+ response = self.client.get(self._url())
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), [])
+
+ def test_post_extra_metadata(self):
+ """POST should create new extra metadata entries."""
+ from geonode.metadata.models import SparseField
+
+ self.client.login(username="admin", password="admin")
+ payload = [
+ {"field_name": "test_field", "field_value": "test_value"},
+ {"field_name": "another", "field_value": "value2"},
+ ]
+ response = self.client.post(
+ self._url(),
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 201)
+ result = response.json()
+ self.assertEqual(len(result), 2)
+ # Each entry should have an id and the metadata fields
+ for item in result:
+ self.assertIn("id", item)
+ self.assertIn("field_name", item)
+ self.assertIn("field_value", item)
+
+ # Verify SparseField entries were created
+ sf_count = SparseField.objects.filter(
+ resource=self.dataset.resourcebase_ptr,
+ name__startswith="extra_metadata_",
+ ).count()
+ self.assertEqual(sf_count, 2)
+
+ def test_get_returns_posted_metadata(self):
+ """GET after POST should return the created metadata."""
+ self.client.login(username="admin", password="admin")
+ payload = [{"field_name": "myfield", "field_value": "myvalue"}]
+ self.client.post(
+ self._url(),
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+ response = self.client.get(self._url())
+ self.assertEqual(response.status_code, 200)
+ result = response.json()
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["field_name"], "myfield")
+ self.assertEqual(result[0]["field_value"], "myvalue")
+
+ def test_put_updates_existing_metadata(self):
+ """PUT should update an existing entry by id."""
+ self.client.login(username="admin", password="admin")
+ # Create first
+ payload = [{"field_name": "original", "field_value": "v1"}]
+ response = self.client.post(
+ self._url(),
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+ created_id = response.json()[0]["id"]
+
+ # Update
+ update_payload = [
+ {"id": created_id, "field_name": "updated", "field_value": "v2"}
+ ]
+ response = self.client.put(
+ self._url(),
+ data=json.dumps(update_payload),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 200)
+ result = response.json()
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["field_name"], "updated")
+ self.assertEqual(result[0]["field_value"], "v2")
+
+ def test_delete_removes_metadata(self):
+ """DELETE should remove entries by id."""
+ self.client.login(username="admin", password="admin")
+ # Create
+ payload = [
+ {"field_name": "to_delete", "field_value": "val"},
+ {"field_name": "to_keep", "field_value": "keep"},
+ ]
+ response = self.client.post(
+ self._url(),
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+ items = response.json()
+ delete_id = items[0]["id"]
+
+ # Delete one
+ response = self.client.delete(
+ self._url(),
+ data=json.dumps([delete_id]),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 200)
+ result = response.json()
+ self.assertEqual(len(result), 1)
+ self.assertNotEqual(result[0]["id"], delete_id)
+
+ def test_metadata_field_in_serializer(self):
+ """The deprecated ``metadata`` field should appear when requested
+ via include[] and return data from SparseField entries."""
+ from geonode.metadata.models import SparseField
+
+ self.client.login(username="admin", password="admin")
+ SparseField.objects.create(
+ resource=self.dataset.resourcebase_ptr,
+ name="extra_metadata_1",
+ value=json.dumps({"field_name": "test", "field_value": "val"}),
+ )
+ url = f"{reverse('base-resources-list')}/{self.dataset.pk}?include[]=metadata"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ data = response.json().get("resource", response.json())
+ self.assertIn("metadata", data)
+ metadata = data["metadata"]
+ self.assertIsInstance(metadata, list)
+ if metadata:
+ self.assertIn("id", metadata[0])
+ self.assertIn("field_name", metadata[0])
diff --git a/geonode/base/api/views.py b/geonode/base/api/views.py
index 6ba8bfcf463..253d27f3083 100644
--- a/geonode/base/api/views.py
+++ b/geonode/base/api/views.py
@@ -110,6 +110,7 @@
from geonode.people.api.serializers import UserSerializer
from .pagination import GeoNodeApiPagination
from geonode.base.utils import patch_perms
+from geonode.base.api.deprecated_extra_metadata import DeprecatedExtraMetadataMixin
from geonode.assets.models import Asset
from geonode.assets.utils import create_asset_and_link, unlink_asset
from geonode.assets.handlers import asset_handler_registry
@@ -303,7 +304,9 @@ def replace_presets(self, request):
request.GET._mutable = False
-class ResourceBaseViewSet(ApiPresetsInitializer, MultiLangViewMixin, DynamicModelViewSet):
+class ResourceBaseViewSet(
+ ApiPresetsInitializer, MultiLangViewMixin, DeprecatedExtraMetadataMixin, DynamicModelViewSet
+):
"""
API endpoint that allows base resources to be viewed or edited.
"""
From d54241999d0b0f2d5a97cda20088af1ef4b43d0d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 13 Apr 2026 09:10:20 +0000
Subject: [PATCH 3/5] Address code review: extract value limit constant, fix
redundant branch
Agent-Logs-Url: https://github.com/GeoNode/geonode/sessions/39c9c964-1dcf-4194-8452-fac2b2f27292
Co-authored-by: etj <717359+etj@users.noreply.github.com>
---
geonode/base/api/deprecated_extra_metadata.py | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/geonode/base/api/deprecated_extra_metadata.py b/geonode/base/api/deprecated_extra_metadata.py
index 8269e4d208d..9544bd53100 100644
--- a/geonode/base/api/deprecated_extra_metadata.py
+++ b/geonode/base/api/deprecated_extra_metadata.py
@@ -52,6 +52,9 @@
"version. Use the sparse fields API instead."
)
SPARSE_FIELD_PREFIX = "extra_metadata_"
+# SparseField.value is CharField(max_length=1024); entries exceeding this
+# limit cannot be stored and are silently skipped with a log warning.
+SPARSE_FIELD_VALUE_MAX_LENGTH = 1024
# ---------------------------------------------------------------------------
@@ -197,7 +200,7 @@ def _extra_metadata_get(request, resource):
if isinstance(qs, list):
return Response([_sparse_to_legacy(sf) for sf in qs])
- return Response([_sparse_to_legacy(sf) for sf in qs])
+ return Response([_sparse_to_legacy(sf) for sf in qs.iterator()])
# -- POST ---------------------------------------------------------------
@@ -224,10 +227,10 @@ def _extra_metadata_post(request, resource):
continue
meta_dict.pop("id", None)
value = json.dumps(meta_dict)
- if len(value) > 1024:
+ if len(value) > SPARSE_FIELD_VALUE_MAX_LENGTH:
logger.warning(
"extra_metadata entry skipped: serialized value exceeds "
- "1024 characters"
+ f"{SPARSE_FIELD_VALUE_MAX_LENGTH} characters"
)
continue
name = _next_sparse_name(resource)
@@ -265,10 +268,10 @@ def _extra_metadata_put(request, resource):
if sf_id is None:
continue
value = json.dumps(meta_dict)
- if len(value) > 1024:
+ if len(value) > SPARSE_FIELD_VALUE_MAX_LENGTH:
logger.warning(
"extra_metadata entry skipped: serialized value exceeds "
- "1024 characters"
+ f"{SPARSE_FIELD_VALUE_MAX_LENGTH} characters"
)
continue
SparseField.objects.filter(
From 9dc0a3a985b9eee3426af09b7b19ae9b3c53c09a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 13 Apr 2026 10:26:19 +0000
Subject: [PATCH 4/5] Restore deprecated EXTRA_METADATA_SCHEMA settings and
address review comments
- Restore EXTRA_METADATA_SCHEMA, DEFAULT_EXTRA_METADATA_SCHEMA,
CUSTOM_METADATA_SCHEMA settings with deprecation markers (required by
mapstore-client)
- Restore 'from schema import Optional' import in settings.py
- Fix docstring removal checklist (step 3 + add step 4 for settings)
- Add logger.warning to DeprecatedExtraMetadataField.get_attribute()
- Fix query param filtering to skip known non-filter params
- Handle IntegrityError on concurrent POST via UUID fallback
- Fix trailing slash in test URL helper
Agent-Logs-Url: https://github.com/GeoNode/geonode/sessions/b6b2de77-7b42-4630-995a-d2c46d014e84
Co-authored-by: etj <717359+etj@users.noreply.github.com>
---
geonode/base/api/deprecated_extra_metadata.py | 43 ++++++++++++++++---
geonode/base/api/tests.py | 2 +-
geonode/settings.py | 27 ++++++++++++
3 files changed, 65 insertions(+), 7 deletions(-)
diff --git a/geonode/base/api/deprecated_extra_metadata.py b/geonode/base/api/deprecated_extra_metadata.py
index 9544bd53100..ccf8b33fabc 100644
--- a/geonode/base/api/deprecated_extra_metadata.py
+++ b/geonode/base/api/deprecated_extra_metadata.py
@@ -27,11 +27,15 @@
To remove these adapters:
1. Delete this file.
2. Remove the ``metadata`` field and import from ``serializers.py``.
- 3. Remove the ``extra_metadata`` action and import from ``views.py``.
+ 3. Remove ``DeprecatedExtraMetadataMixin`` and its import from ``views.py``.
+ 4. Remove deprecated settings from ``settings.py``
+ (``DEFAULT_EXTRA_METADATA_SCHEMA``, ``CUSTOM_METADATA_SCHEMA``,
+ ``EXTRA_METADATA_SCHEMA``, and the ``from schema import Optional`` import).
"""
import json
import logging
+import uuid
import warnings
from deprecated import deprecated
@@ -41,6 +45,8 @@
from dynamic_rest.fields.fields import DynamicComputedField
+from django.db import IntegrityError
+
from geonode.base.models import ResourceBase
from geonode.metadata.models import SparseField
@@ -84,7 +90,12 @@ def _sparse_to_legacy(sparse_field):
def _next_sparse_name(resource):
- """Generate the next available ``extra_metadata_`` name."""
+ """Generate the next available ``extra_metadata_`` name.
+
+ Falls back to a UUID-based suffix if a name collision occurs (e.g. under
+ concurrent requests), keeping the ``extra_metadata_`` prefix intact.
+ The numeric part is still preferred for readability.
+ """
existing = (
SparseField.objects.filter(
resource=resource,
@@ -100,7 +111,11 @@ def _next_sparse_name(resource):
max_n = max(max_n, int(suffix))
except (ValueError, TypeError):
pass
- return f"{SPARSE_FIELD_PREFIX}{max_n + 1}"
+ candidate = f"{SPARSE_FIELD_PREFIX}{max_n + 1}"
+ # Guard against a race: if the candidate already exists, use a UUID suffix
+ if SparseField.objects.filter(resource=resource, name=candidate).exists():
+ candidate = f"{SPARSE_FIELD_PREFIX}{uuid.uuid4().hex[:12]}"
+ return candidate
# ---------------------------------------------------------------------------
@@ -122,6 +137,7 @@ def __init__(self, **kwargs):
@deprecated(version=DEPRECATION_VERSION, reason=DEPRECATION_REASON)
def get_attribute(self, instance):
warnings.warn(DEPRECATION_REASON, DeprecationWarning, stacklevel=2)
+ logger.warning(DEPRECATION_REASON)
try:
qs = _sparse_fields_for_resource(instance)
return [_sparse_to_legacy(sf) for sf in qs]
@@ -183,8 +199,16 @@ def extra_metadata(self, request, pk, *args, **kwargs):
@staticmethod
def _extra_metadata_get(request, resource):
qs = _sparse_fields_for_resource(resource)
+ # Known non-filter query params that should not be treated as
+ # legacy metadata__ filters.
+ _skip_params = {
+ "api_preset", "page", "page_size", "format", "include[]",
+ "exclude[]", "filter{}", "sort[]",
+ }
# Support the old query-param filtering (e.g. ?field_name=value)
for key, value in request.query_params.items():
+ if key in _skip_params or key.startswith("filter{"):
+ continue
# Old API used metadata__=value JSONField lookups.
# We approximate this by filtering on the JSON string.
filtered = []
@@ -234,9 +258,16 @@ def _extra_metadata_post(request, resource):
)
continue
name = _next_sparse_name(resource)
- SparseField.objects.create(
- resource=resource, name=name, value=value
- )
+ try:
+ SparseField.objects.create(
+ resource=resource, name=name, value=value
+ )
+ except IntegrityError:
+ # Concurrent request created the same name; retry with UUID
+ name = f"{SPARSE_FIELD_PREFIX}{uuid.uuid4().hex[:12]}"
+ SparseField.objects.create(
+ resource=resource, name=name, value=value
+ )
result = [_sparse_to_legacy(sf) for sf in _sparse_fields_for_resource(resource)]
return Response(result, status=201)
diff --git a/geonode/base/api/tests.py b/geonode/base/api/tests.py
index d52a096d4de..db75190e591 100644
--- a/geonode/base/api/tests.py
+++ b/geonode/base/api/tests.py
@@ -4193,7 +4193,7 @@ def _url(self, pk=None):
pk = pk or self.dataset.pk
return urljoin(
f"{reverse('base-resources-list')}/",
- f"{pk}/extra_metadata",
+ f"{pk}/extra_metadata/",
)
def test_get_empty_extra_metadata(self):
diff --git a/geonode/settings.py b/geonode/settings.py
index 8fc7ff81315..0720926becd 100644
--- a/geonode/settings.py
+++ b/geonode/settings.py
@@ -25,6 +25,7 @@
import logging
import subprocess
import dj_database_url
+from schema import Optional
from urllib.parse import urlparse, urljoin
#
@@ -2068,6 +2069,32 @@ def get_geonode_catalogue_service():
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
+# ---- Deprecated ExtraMetadata settings (kept for backward compatibility) ----
+# These settings are deprecated and will be removed in a future version.
+# The ExtraMetadata model has been replaced by SparseField.
+# External clients (e.g. mapstore-client) may still reference these settings.
+
+DEFAULT_EXTRA_METADATA_SCHEMA = {
+ Optional("id"): int,
+ "filter_header": object,
+ "field_name": object,
+ "field_label": object,
+ "field_value": object,
+}
+
+CUSTOM_METADATA_SCHEMA = os.getenv("CUSTOM_METADATA_SCHEMA ", {})
+
+EXTRA_METADATA_SCHEMA = {
+ **{
+ "map": os.getenv("MAP_EXTRA_METADATA_SCHEMA", DEFAULT_EXTRA_METADATA_SCHEMA),
+ "dataset": os.getenv("DATASET_EXTRA_METADATA_SCHEMA", DEFAULT_EXTRA_METADATA_SCHEMA),
+ "document": os.getenv("DOCUMENT_EXTRA_METADATA_SCHEMA", DEFAULT_EXTRA_METADATA_SCHEMA),
+ "geoapp": os.getenv("GEOAPP_EXTRA_METADATA_SCHEMA", DEFAULT_EXTRA_METADATA_SCHEMA),
+ },
+ **CUSTOM_METADATA_SCHEMA,
+}
+# ---- End deprecated ExtraMetadata settings ----
+
"""
List of modules that implement custom metadata storers that will be called when the metadata of a resource is saved
"""
From deb75c9587991617780d3c77860548ffb107027c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 13 Apr 2026 10:28:45 +0000
Subject: [PATCH 5/5] Fix trailing space in CUSTOM_METADATA_SCHEMA env var,
extract skip params constant
Agent-Logs-Url: https://github.com/GeoNode/geonode/sessions/b6b2de77-7b42-4630-995a-d2c46d014e84
Co-authored-by: etj <717359+etj@users.noreply.github.com>
---
geonode/base/api/deprecated_extra_metadata.py | 15 ++++++++-------
geonode/settings.py | 2 +-
2 files changed, 9 insertions(+), 8 deletions(-)
diff --git a/geonode/base/api/deprecated_extra_metadata.py b/geonode/base/api/deprecated_extra_metadata.py
index ccf8b33fabc..e0d3ec942b8 100644
--- a/geonode/base/api/deprecated_extra_metadata.py
+++ b/geonode/base/api/deprecated_extra_metadata.py
@@ -62,6 +62,13 @@
# limit cannot be stored and are silently skipped with a log warning.
SPARSE_FIELD_VALUE_MAX_LENGTH = 1024
+# Query parameter names that should *not* be treated as legacy
+# ``metadata__`` filters in the deprecated GET endpoint.
+_NON_FILTER_QUERY_PARAMS = {
+ "api_preset", "page", "page_size", "format", "include[]",
+ "exclude[]", "sort[]",
+}
+
# ---------------------------------------------------------------------------
# Helpers
@@ -199,15 +206,9 @@ def extra_metadata(self, request, pk, *args, **kwargs):
@staticmethod
def _extra_metadata_get(request, resource):
qs = _sparse_fields_for_resource(resource)
- # Known non-filter query params that should not be treated as
- # legacy metadata__ filters.
- _skip_params = {
- "api_preset", "page", "page_size", "format", "include[]",
- "exclude[]", "filter{}", "sort[]",
- }
# Support the old query-param filtering (e.g. ?field_name=value)
for key, value in request.query_params.items():
- if key in _skip_params or key.startswith("filter{"):
+ if key in _NON_FILTER_QUERY_PARAMS or key.startswith("filter{"):
continue
# Old API used metadata__=value JSONField lookups.
# We approximate this by filtering on the JSON string.
diff --git a/geonode/settings.py b/geonode/settings.py
index 0720926becd..b021f2d038b 100644
--- a/geonode/settings.py
+++ b/geonode/settings.py
@@ -2082,7 +2082,7 @@ def get_geonode_catalogue_service():
"field_value": object,
}
-CUSTOM_METADATA_SCHEMA = os.getenv("CUSTOM_METADATA_SCHEMA ", {})
+CUSTOM_METADATA_SCHEMA = os.getenv("CUSTOM_METADATA_SCHEMA", {})
EXTRA_METADATA_SCHEMA = {
**{