diff --git a/geonode/geoserver/helpers.py b/geonode/geoserver/helpers.py index a152e4ab1bb..d28439f7854 100755 --- a/geonode/geoserver/helpers.py +++ b/geonode/geoserver/helpers.py @@ -1808,6 +1808,37 @@ def set_time_info(layer, attribute, end_attribute, presentation, precision_value gs_catalog.save(resource) +def get_time_info(layer): + """ + Get the time configuration for a layer + """ + time_info = {} + gs_layer = gs_catalog.get_layer(name=layer.name) + if gs_layer is not None: + gs_time_info = gs_layer.resource.metadata.get("time") + if gs_time_info.enabled: + _attr = layer.attributes.filter(attribute=gs_time_info.attribute).first() + time_info["attribute"] = _attr.pk if _attr else None + if gs_time_info.end_attribute is not None: + end_attr = layer.attributes.filter(attribute=gs_time_info.end_attribute).first() + time_info["end_attribute"] = end_attr.pk if end_attr else None + time_info["presentation"] = gs_time_info.presentation + lookup_value = sorted(list(gs_time_info._lookup), key=lambda x: x[1], reverse=True) + if gs_time_info.resolution is not None: + res = gs_time_info.resolution // 1000 + for el in lookup_value: + if res % el[1] == 0: + time_info["precision_value"] = res // el[1] + time_info["precision_step"] = el[0] + break + else: + time_info["precision_value"] = gs_time_info.resolution + time_info["precision_step"] = "seconds" + return time_info + else: + return None + + ogc_server_settings = OGC_Servers_Handler(settings.OGC_SERVER)["default"] _wms = None diff --git a/geonode/geoserver/tests/test_helpers.py b/geonode/geoserver/tests/test_helpers.py index 2fb1b3c6754..1c32898d6b1 100644 --- a/geonode/geoserver/tests/test_helpers.py +++ b/geonode/geoserver/tests/test_helpers.py @@ -31,6 +31,10 @@ from geonode.layers.populate_datasets_data import create_dataset_data from geonode.tests.base import GeoNodeBaseTestSupport from geonode.geoserver.views import _response_callback +from geonode.layers.models import Dataset, Attribute +from uuid import uuid4 +from django.contrib.auth import get_user_model + from geonode.geoserver.helpers import ( gs_catalog, ows_endpoint_in_path, @@ -38,8 +42,10 @@ extract_name_from_sld, get_dataset_capabilities_url, get_layer_ows_url, + get_time_info, ) from geonode.geoserver.ows import _wcs_link, _wfs_link, _wms_link +from unittest.mock import patch, Mock logger = logging.getLogger(__name__) @@ -267,7 +273,6 @@ def test_dataset_capabilties_url(self): @on_ogc_backend(geoserver.BACKEND_PACKAGE) def test_layer_ows_url(self): - from geonode.layers.models import Dataset ows_url = settings.GEOSERVER_PUBLIC_LOCATION identifier = "geonode:CA" @@ -275,3 +280,83 @@ def test_layer_ows_url(self): expected_url = f"{ows_url}geonode/CA/ows" capabilities_url = get_layer_ows_url(dataset) self.assertEqual(capabilities_url, expected_url, capabilities_url) + + # Tests for geonode.geoserver.helpers.get_time_info + @patch("geonode.geoserver.helpers.gs_catalog") + def test_get_time_info_valid_layer(self, mock_gs_catalog): + + mock_dataset = Dataset.objects.create( + uuid=str(uuid4()), + owner=get_user_model().objects.get(username=self.user), + name="geonode:states", + store="httpfooremoteservce", + subtype="remote", + alternate="geonode:states", + ) + + Attribute.objects.create(pk=5, attribute="begin", dataset_id=mock_dataset.pk) + + Attribute.objects.create(pk=6, attribute="end", dataset_id=mock_dataset.pk) + + # Build mock GeoServer's time info + mock_gs_time_info = Mock() + mock_gs_time_info.enabled = True + mock_gs_time_info.attribute = "begin" + mock_gs_time_info.end_attribute = "end" + mock_gs_time_info.presentation = "DISCRETE_INTERVAL" + mock_gs_time_info.resolution = 5000 + mock_gs_time_info._lookup = [("seconds", 1), ("minutes", 60)] + + mock_gs_layer = Mock() + mock_gs_layer.resource.metadata.get.return_value = mock_gs_time_info + mock_gs_catalog.get_layer.return_value = mock_gs_layer + + result = get_time_info(mock_dataset) + + self.assertEqual(result["attribute"], 5) + self.assertEqual(result["end_attribute"], 6) + self.assertEqual(result["presentation"], "DISCRETE_INTERVAL") + self.assertEqual(result["precision_value"], 5) + self.assertEqual(result["precision_step"], "seconds") + + @patch("geonode.geoserver.helpers.gs_catalog") + def test_get_time_info_with_time_disabled(self, mock_gs_catalog): + + mock_dataset = Dataset.objects.create( + uuid=str(uuid4()), + owner=get_user_model().objects.get(username=self.user), + name="geonode:states", + store="httpfooremoteservce", + subtype="remote", + alternate="geonode:states", + ) + + Attribute.objects.create(pk=5, attribute="begin", dataset_id=mock_dataset.pk) + + Attribute.objects.create(pk=6, attribute="end", dataset_id=mock_dataset.pk) + + mock_gs_time_info = Mock() + mock_gs_time_info.enabled = False + mock_gs_time_info.attribute = "begin" + mock_gs_time_info.end_attribute = "end" + mock_gs_time_info.presentation = "DISCRETE_INTERVAL" + mock_gs_time_info.resolution = 10000 + mock_gs_time_info._lookup = [("seconds", 1), ("minutes", 60)] + + mock_gs_layer = Mock() + mock_gs_layer.resource.metadata.get.return_value = mock_gs_time_info + mock_gs_catalog.get_layer.return_value = mock_gs_layer + + result = get_time_info(mock_dataset) + self.assertEqual(result, {}) + + @patch("geonode.geoserver.helpers.gs_catalog") + def test_get_time_info_no_layer(self, mock_gs_catalog): + + mock_gs_catalog.get_layer.return_value = None + + mock_layer = Mock() + mock_layer.name = "nonexistent_layer" + + result = get_time_info(mock_layer) + self.assertIsNone(result) diff --git a/geonode/layers/api/serializers.py b/geonode/layers/api/serializers.py index ab5a2bc2e00..9df1f8c57bb 100644 --- a/geonode/layers/api/serializers.py +++ b/geonode/layers/api/serializers.py @@ -213,3 +213,40 @@ class DatasetMetadataSerializer(serializers.Serializer): class Meta: fields = "metadata_file" + + +class DatasetTimeSeriesSerializer(serializers.Serializer): + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + layer = self.context.get("layer") + + if layer: + # use the get_choices method of the Dataset model + choices = [(None, "-----")] + layer.get_choices + self.fields["attribute"].choices = choices + self.fields["end_attribute"].choices = choices + else: + choices = [(None, "-----")] + + has_time = serializers.BooleanField(default=False) + attribute = serializers.ChoiceField(choices=[], required=False) + end_attribute = serializers.ChoiceField(choices=[], required=False) + presentation = serializers.ChoiceField( + required=False, + choices=[ + ("LIST", "List of all the distinct time values"), + ("DISCRETE_INTERVAL", "Intervals defined by the resolution"), + ( + "CONTINUOUS_INTERVAL", + "Continuous Intervals for data that is frequently updated, resolution describes the frequency of updates", + ), + ], + ) + precision_value = serializers.IntegerField(required=False) + precision_step = serializers.ChoiceField( + required=False, + choices=[("years",) * 2, ("months",) * 2, ("days",) * 2, ("hours",) * 2, ("minutes",) * 2, ("seconds",) * 2], + ) diff --git a/geonode/layers/api/views.py b/geonode/layers/api/views.py index 35fdbc2f18b..51c918a9c4e 100644 --- a/geonode/layers/api/views.py +++ b/geonode/layers/api/views.py @@ -40,6 +40,8 @@ from geonode.resource.utils import update_resource from geonode.resource.manager import resource_manager from rest_framework.exceptions import NotFound +from django.shortcuts import get_object_or_404 +from django.http import JsonResponse from geonode.storage.manager import StorageManager @@ -47,9 +49,16 @@ DatasetSerializer, DatasetListSerializer, DatasetMetadataSerializer, + DatasetTimeSeriesSerializer, ) from .permissions import DatasetPermissionsFilter +from geonode import geoserver +from geonode.utils import check_ogc_backend + +if check_ogc_backend(geoserver.BACKEND_PACKAGE): + from geonode.geoserver.helpers import get_time_info + import logging logger = logging.getLogger(__name__) @@ -80,6 +89,8 @@ class DatasetViewSet(ApiPresetsInitializer, DynamicModelViewSet, AdvertisedListM def get_serializer_class(self): if self.action == "list": return DatasetListSerializer + if self.action == "timeseries_info": + return DatasetTimeSeriesSerializer return DatasetSerializer def partial_update(self, request, *args, **kwargs): @@ -187,3 +198,98 @@ def maps(self, request, pk=None, *args, **kwargs): dataset = self.get_object() resources = dataset.maps return Response(SimpleMapSerializer(many=True).to_representation(resources)) + + @action( + detail=True, + url_path="timeseries", + url_name="timeseries", + methods=["get", "put"], + permission_classes=[IsAuthenticated], + ) + def timeseries_info(self, request, pk, *args, **kwards): + """ + Endpoint for timeseries information + + url = "http://localhost:8080/api/v2/datasets/{dataset_id}/timeseries" + + cURL examples: + GET method + curl -X GET http://localhost:8000/api/v2/datasets/1/timeseries -u : + + PUT method + curl -X PUT http://localhost:8000/api/v2/datasets/1/timeseries -u : + -H "Content-Type: application/json" -d '{"has_time": true, "attribute": 4, "end_attribute": 5, + "presentation": "DISCRETE_INTERVAL", "precision_value": 2, "precision_step": "months"}' + """ + + layer = get_object_or_404(Dataset, id=pk) + + if layer.supports_time is False: + return JsonResponse({"message": "The time dimension is not supported for this dataset."}, status=200) + + if request.method == "GET": + + time_info = get_time_info(layer) + serializer = DatasetTimeSeriesSerializer(data=time_info, context={"layer": layer}) + serializer.is_valid(raise_exception=True) + serialized_time_info = serializer.data + + if layer.has_time is True and time_info is not None: + serialized_time_info["has_time"] = layer.has_time + return JsonResponse(serialized_time_info, status=200) + else: + return JsonResponse({"message": "No time information available."}, status=404) + + if request.method == "PUT": + + serializer = DatasetTimeSeriesSerializer(data=request.data, context={"layer": layer}) + serializer.is_valid(raise_exception=True) + serialized_time_info = serializer.validated_data + + if serialized_time_info.get("has_time") is True: + + start_attr = ( + layer.attributes.get(pk=serialized_time_info.get("attribute")).attribute + if serialized_time_info.get("attribute") + else None + ) + end_attr = ( + layer.attributes.get(pk=serialized_time_info.get("end_attribute")).attribute + if serialized_time_info.get("end_attribute") + else None + ) + + # Save the has_time value to the database + layer.has_time = True + layer.save() + + resource_manager.exec( + "set_time_info", + None, + instance=layer, + time_info={ + "attribute": start_attr, + "end_attribute": end_attr, + "presentation": serialized_time_info.get("presentation", None), + "precision_value": serialized_time_info.get("precision_value", None), + "precision_step": serialized_time_info.get("precision_step", None), + "enabled": serialized_time_info.get("has_time", False), + }, + ) + + resource_manager.update( + layer.uuid, + instance=layer, + notify=True, + ) + return JsonResponse({"message": "the time information data was updated successfully"}, status=200) + else: + # Save the has_time value to the database + layer.has_time = False + layer.save() + + return JsonResponse( + { + "message": "The time information was not updated since the time dimension is disabled for this layer" + } + ) diff --git a/geonode/layers/models.py b/geonode/layers/models.py index 016597dcb44..2a5b4dc7baa 100644 --- a/geonode/layers/models.py +++ b/geonode/layers/models.py @@ -164,6 +164,21 @@ def is_vector(self): def is_raster(self): return self.subtype == "raster" + @property + def supports_time(self): + valid_attributes = self.get_choices + # check if the layer object if a vector and + # includes valid_attributes + if self.is_vector() and valid_attributes: + return True + return False + + @property + def get_choices(self): + + attributes = Attribute.objects.filter(dataset_id=self.pk) + return [(_a.pk, _a.attribute) for _a in attributes if _a.attribute_type in ["xsd:dateTime", "xsd:date"]] + @property def display_type(self): if self.subtype in ["vector", "vector_time"]: diff --git a/geonode/layers/tests.py b/geonode/layers/tests.py index d11ad69ec5f..ba94558e5b4 100644 --- a/geonode/layers/tests.py +++ b/geonode/layers/tests.py @@ -23,7 +23,7 @@ import logging from uuid import uuid4 -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch, PropertyMock from collections import namedtuple from django.urls import reverse @@ -937,6 +937,42 @@ def test_dataset_download_call_the_catalog_work_for_vector(self, pathed_template ({"alternate": layer.alternate, "download_format": "application/zip"},), pathed_template.mock_calls[1].args ) + @patch.object(Dataset, "get_choices", new_callable=PropertyMock) + def test_supports_time_with_vector_time_subtype(self, mock_get_choices): + + # set valid attributes + mock_get_choices.return_value = [(4, "timestamp"), (5, "begin"), (6, "end")] + + mock_dataset = Dataset(subtype="vector") + self.assertTrue(mock_dataset.supports_time) + + @patch.object(Dataset, "get_choices", new_callable=PropertyMock) + def test_supports_time_with_non_vector_subtype(self, mock_get_choices): + + # set valid attributes + mock_get_choices.return_value = [(4, "timestamp"), (5, "begin"), (6, "end")] + + mock_dataset = Dataset(subtype="raster") + self.assertFalse(mock_dataset.supports_time) + + @patch.object(Dataset, "get_choices", new_callable=PropertyMock) + def test_supports_time_with_vector_subtype_and_invalid_attributes(self, mock_get_choices): + + # Get an empty list from get_choices method due to invalid attributes + mock_get_choices.return_value = [] + + mock_dataset = Dataset(subtype="vector") + self.assertFalse(mock_dataset.supports_time) + + @patch.object(Dataset, "get_choices", new_callable=PropertyMock) + def test_supports_time_with_raster_subtype_and_invalid_attributes(self, mock_get_choices): + + # Get an empty list from get_choices method due to invalid attributes + mock_get_choices.return_value = [] + + mock_dataset = Dataset(subtype="raster") + self.assertFalse(mock_dataset.supports_time) + class TestLayerDetailMapViewRights(GeoNodeBaseTestSupport): fixtures = ["initial_data.json", "group_test_data.json", "default_oauth_apps.json"] diff --git a/geonode/layers/views.py b/geonode/layers/views.py index f7907326936..7012e797af7 100644 --- a/geonode/layers/views.py +++ b/geonode/layers/views.py @@ -63,7 +63,7 @@ from geonode.geoserver.helpers import ogc_server_settings if check_ogc_backend(geoserver.BACKEND_PACKAGE): - from geonode.geoserver.helpers import gs_catalog + from geonode.geoserver.helpers import gs_catalog, get_time_info CONTEXT_LOG_FILE = ogc_server_settings.LOG_FILE @@ -332,28 +332,9 @@ def dataset_metadata( prefix="category_choice_field", initial=topic_category.id if topic_category else None ) - gs_layer = gs_catalog.get_layer(name=layer.name) initial = {} - if gs_layer is not None and layer.has_time: - gs_time_info = gs_layer.resource.metadata.get("time") - if gs_time_info.enabled: - _attr = layer.attributes.filter(attribute=gs_time_info.attribute).first() - initial["attribute"] = _attr.pk if _attr else None - if gs_time_info.end_attribute is not None: - end_attr = layer.attributes.filter(attribute=gs_time_info.end_attribute).first() - initial["end_attribute"] = end_attr.pk if end_attr else None - initial["presentation"] = gs_time_info.presentation - lookup_value = sorted(list(gs_time_info._lookup), key=lambda x: x[1], reverse=True) - if gs_time_info.resolution is not None: - res = gs_time_info.resolution // 1000 - for el in lookup_value: - if res % el[1] == 0: - initial["precision_value"] = res // el[1] - initial["precision_step"] = el[0] - break - else: - initial["precision_value"] = gs_time_info.resolution - initial["precision_step"] = "seconds" + if layer.supports_time and layer.has_time: + initial = get_time_info(layer) timeseries_form = DatasetTimeSerieForm(instance=layer, prefix="timeseries", initial=initial) @@ -465,7 +446,7 @@ def dataset_metadata( layer.has_time = dataset_form.cleaned_data.get("has_time", layer.has_time) if ( - layer.is_vector() + layer.supports_time and timeseries_form.cleaned_data and ("has_time" in dataset_form.changed_data or timeseries_form.changed_data) ): diff --git a/geonode/upload/migrations/0050_alter_uploadsizelimit_max_size.py b/geonode/upload/migrations/0050_alter_uploadsizelimit_max_size.py new file mode 100644 index 00000000000..686590ff132 --- /dev/null +++ b/geonode/upload/migrations/0050_alter_uploadsizelimit_max_size.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.16 on 2024-12-06 08:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("upload", "0049_move_data_from_importer_to_upload"), + ] + + operations = [ + migrations.AlterField( + model_name="uploadsizelimit", + name="max_size", + field=models.PositiveBigIntegerField( + default=104857600, help_text="The maximum file size allowed for upload (bytes)." + ), + ), + ]