Skip to content
Open
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
346 changes: 346 additions & 0 deletions geonode/base/api/deprecated_extra_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
# #########################################################################
#
# 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 <http://www.gnu.org/licenses/>.
#
# #########################################################################
"""
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 ``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
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 django.db import IntegrityError

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_"
# 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

# Query parameter names that should *not* be treated as legacy
# ``metadata__<key>`` filters in the deprecated GET endpoint.
_NON_FILTER_QUERY_PARAMS = {
"api_preset", "page", "page_size", "format", "include[]",
"exclude[]", "sort[]",
}


# ---------------------------------------------------------------------------
# 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": <pk>, ...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_<N>`` 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,
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
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


# ---------------------------------------------------------------------------
# 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)
logger.warning(DEPRECATION_REASON)
try:
qs = _sparse_fields_for_resource(instance)
return [_sparse_to_legacy(sf) for sf in qs]
except Exception as e:
logger.exception(e)
return []
Comment on lines +144 to +153
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says all adapter paths emit both a DeprecationWarning and a log warning, but DeprecatedExtraMetadataField.get_attribute() only calls warnings.warn(...) and never logs. If you want consistent observability with the endpoint adapter, add a logger.warning(...) here (or adjust the PR description if logging is intentionally omitted for serializer usage).

Copilot uses AI. Check for mistakes.


# ---------------------------------------------------------------------------
# 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():
if key in _NON_FILTER_QUERY_PARAMS or key.startswith("filter{"):
continue
# Old API used metadata__<key>=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

Comment on lines +208 to +225
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_extra_metadata_get applies legacy filtering to the first query param unconditionally. This can produce incorrect results if clients include unrelated params (e.g. api_preset, page_size, format, etc.), because the loop will treat them as a metadata filter and then break. Consider whitelisting filter keys (or explicitly skipping known non-filter params) before applying the legacy filter logic.

Copilot uses AI. Check for mistakes.
if isinstance(qs, list):
return Response([_sparse_to_legacy(sf) for sf in qs])
return Response([_sparse_to_legacy(sf) for sf in qs.iterator()])

# -- 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) > SPARSE_FIELD_VALUE_MAX_LENGTH:
logger.warning(
"extra_metadata entry skipped: serialized value exceeds "
f"{SPARSE_FIELD_VALUE_MAX_LENGTH} characters"
)
continue
name = _next_sparse_name(resource)
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)

# -- 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) > SPARSE_FIELD_VALUE_MAX_LENGTH:
logger.warning(
"extra_metadata entry skipped: serialized value exceeds "
f"{SPARSE_FIELD_VALUE_MAX_LENGTH} 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)
4 changes: 4 additions & 0 deletions geonode/base/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -735,6 +738,7 @@ class Meta:
"sourcetype",
"is_copyable",
"blob",
"metadata", # Deprecated: use sparse fields API instead
"executions",
"linked_resources",
"download_url",
Expand Down
Loading
Loading