Repository: ResonantGeoData/ResonantGeoData

Issue:
Add search endpoints where an arbitrary geojson object can be used for the search area
We may also want an option on whether the data needs to intersect the object or be strictly within it.

This would be similar to the bounding box search for time handling/searching.


Discussion:

Resolving patch:
diff --git a/rgd/geodata/search.py b/rgd/geodata/search.py
--- a/rgd/geodata/search.py
+++ b/rgd/geodata/search.py
@@ -3,7 +3,7 @@
 
 import dateutil.parser
 from django.contrib.gis.db.models import Collect, Extent
-from django.contrib.gis.geos import Point, Polygon
+from django.contrib.gis.geos import GEOSGeometry, Point, Polygon
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db.models import Max, Min, Q
 from django.db.models.functions import Coalesce
@@ -66,6 +66,25 @@ class BoundingBoxSerializer(rfserializers.Serializer):
     )
 
 
+class GeoJsonSerializer(rfserializers.Serializer):
+    geojson = rfserializers.CharField(
+        required=False,
+        help_text='A URL-encoded text of a GeoJSON Geometry object describing a geometry to search.',
+    )
+    within = rfserializers.BooleanField(
+        default=False,
+        allow_null=True,
+        help_text='Return data entirely within (rather than intersecting) the provided geometry.',
+    )
+    start_time = rfserializers.DateTimeField(allow_null=True, required=False)
+    end_time = rfserializers.DateTimeField(allow_null=True, required=False)
+    timefield = rfserializers.CharField(
+        required=False,
+        default='acquisition',
+        help_text='A comma-separated list of fields to search.  This can include acquisition, created, modified.',
+    )
+
+
 def _add_time_to_query(query, timefield, starttime, endtime, has_created=False):
     starttime = make_aware(starttime)
     endtime = make_aware(endtime)
@@ -157,6 +176,37 @@ def search_bounding_box_filter(params, has_created=False):
     return query
 
 
+def search_geojson_filter(params, has_created=False):
+    """
+    Get a filter object that can be used when searching SpatialEntry models.
+
+    :param params: a dictionary of parameters, optionally including
+        geojson, within, start_time, end_time, and timefield.
+    :param has_created: if True, searching acquisition time will fallback to
+        include created times.
+    :raises ValueError: String input unrecognized as WKT EWKT, and HEXEWKB.
+    :returns: a Django query (Q) object.
+    """
+    query = Q()
+    if params.get('geojson') is not None:
+        geom = GEOSGeometry(params.get('geojson'))
+        if 'within' in params and params['within'] not in ('0', 'false', 'False', 'no', 'n'):
+            query.add(Q(footprint__within=(geom)), Q.AND)
+        else:
+            query.add(Q(footprint__intersects=(geom)), Q.AND)
+    if params.get('start_time') is not None or params.get('end_time') is not None:
+        if params.get('start_time') is not None:
+            starttime = dateutil.parser.isoparser().isoparse(params['start_time'])
+        if params.get('end_time') is not None:
+            endtime = dateutil.parser.isoparser().isoparse(params['end_time'])
+        else:
+            endtime = starttime
+        if params.get('start_time') is None:
+            starttime = endtime
+        _add_time_to_query(query, params.get('timefield', ''), starttime, endtime, has_created)
+    return query
+
+
 @swagger_auto_schema(
     method='GET',
     operation_summary='List geospatial datasets near a point',
@@ -235,6 +285,45 @@ def search_bounding_box_geometry(request, *args, **kwargs):
     return JsonResponse(serializers.GeometryEntrySerializer(results, many=True).data, safe=False)
 
 
+@swagger_auto_schema(
+    method='GET',
+    operation_summary='List geospatial datasets in a GeoJSON geometry',
+    operation_description='List geospatial datasets that intersect or lie entirely within a GeoJSON geometry',
+    query_serializer=GeoJsonSerializer,
+)
+@api_view(['GET'])
+def search_geojson(request, *args, **kwargs):
+    params = request.query_params
+    results = SpatialEntry.objects.filter(search_geojson_filter(params))
+    return JsonResponse(serializers.SpatialEntrySerializer(results, many=True).data, safe=False)
+
+
+@swagger_auto_schema(
+    method='GET',
+    operation_summary='List raster datasets in a GeoJSON geometry',
+    operation_description='List geospatial raster datasets that intersect or lie entirely within a GeoJSON geometry',
+    query_serializer=GeoJsonSerializer,
+)
+@api_view(['GET'])
+def search_geojson_raster(request, *args, **kwargs):
+    params = request.query_params
+    results = RasterEntry.objects.filter(search_geojson_filter(params, True))
+    return JsonResponse(serializers.RasterEntrySerializer(results, many=True).data, safe=False)
+
+
+@swagger_auto_schema(
+    method='GET',
+    operation_summary='List geometry datasets in a GeoJSON geometry',
+    operation_description='List geospatial geometry datasets that intersect or lie entirely within a GeoJSON geometry',
+    query_serializer=GeoJsonSerializer,
+)
+@api_view(['GET'])
+def search_geojson_geometry(request, *args, **kwargs):
+    params = request.query_params
+    results = GeometryEntry.objects.filter(search_geojson_filter(params))
+    return JsonResponse(serializers.GeometryEntrySerializer(results, many=True).data, safe=False)
+
+
 def extent_summary_spatial(found):
     """
     Given a query set of SpatialEntry, return a result dictionary with the summary.
@@ -444,3 +533,42 @@ def search_bounding_box_extent_geometry(request, *args, **kwargs):
     params = request.query_params
     found = GeometryEntry.objects.filter(search_bounding_box_filter(params))
     return extent_summary_http(found)
+
+
+@swagger_auto_schema(
+    method='GET',
+    operation_summary='Extents of geospatial datasets in a GeoJSON geometry',
+    operation_description='Get the convex hull and time range for geospatial datasets that intersect or lie entirely within a GeoJSON geometry',
+    query_serializer=GeoJsonSerializer,
+)
+@api_view(['GET'])
+def search_geojson_extent(request, *args, **kwargs):
+    params = request.query_params
+    found = SpatialEntry.objects.filter(search_geojson_filter(params))
+    return extent_summary_http(found)
+
+
+@swagger_auto_schema(
+    method='GET',
+    operation_summary='Extents of raster datasets in a GeoJSON geometry',
+    operation_description='Get the convex hull and time range for geospatial raster datasets that intersect or lie entirely within a GeoJSON geometry',
+    query_serializer=GeoJsonSerializer,
+)
+@api_view(['GET'])
+def search_geojson_extent_raster(request, *args, **kwargs):
+    params = request.query_params
+    found = RasterEntry.objects.filter(search_geojson_filter(params, True))
+    return extent_summary_http(found, True)
+
+
+@swagger_auto_schema(
+    method='GET',
+    operation_summary='Extents of geometry datasets in a GeoJSON geometry',
+    operation_description='Get the convex hull and time range for geospatial geometry datasets that intersect or lie entirely within a GeoJSON geometry',
+    query_serializer=GeoJsonSerializer,
+)
+@api_view(['GET'])
+def search_geojson_extent_geometry(request, *args, **kwargs):
+    params = request.query_params
+    found = GeometryEntry.objects.filter(search_geojson_filter(params))
+    return extent_summary_http(found)
diff --git a/rgd/geodata/urls.py b/rgd/geodata/urls.py
--- a/rgd/geodata/urls.py
+++ b/rgd/geodata/urls.py
@@ -33,4 +33,10 @@
     path('api/geodata/bounding_box/extent', search.search_bounding_box_extent),
     path('api/geodata/raster/bounding_box/extent', search.search_bounding_box_extent_raster),
     path('api/geodata/geometry/bounding_box/extent', search.search_bounding_box_extent_geometry),
+    path('api/geodata/geojson', search.search_geojson),
+    path('api/geodata/raster/geojson', search.search_geojson_raster),
+    path('api/geodata/geometry/geojson', search.search_geojson_geometry),
+    path('api/geodata/geojson/extent', search.search_geojson_extent),
+    path('api/geodata/raster/geojson/extent', search.search_geojson_extent_raster),
+    path('api/geodata/geometry/geojson/extent', search.search_geojson_extent_geometry),
 ]
diff --git a/rgd/geodata/views.py b/rgd/geodata/views.py
--- a/rgd/geodata/views.py
+++ b/rgd/geodata/views.py
@@ -36,6 +36,9 @@ def get_queryset(self):
             method = search.search_near_point_filter
         elif all(k in self.search_params for k in bbox):
             method = search.search_bounding_box_filter
+        elif 'geojson' in self.request.GET:
+            self.search_params = self.request.GET
+            method = search.search_geojson_filter
 
         return self.model.objects.filter(method(self.search_params))
 


Test patch:
diff --git a/rgd/geodata/tests/test_search.py b/rgd/geodata/tests/test_search.py
--- a/rgd/geodata/tests/test_search.py
+++ b/rgd/geodata/tests/test_search.py
@@ -188,3 +188,109 @@ def test_search_bounding_box_extent(client):
         ).content
     )
     assert results['count'] == 0
+
+
+@pytest.mark.django_db(transaction=True)
+def test_search_geojson(client):
+    _load_sample_files()
+    items = json.loads(client.get('/api/geodata/geojson').content)
+    assert len(items) == len(SampleFiles)
+    items = json.loads(
+        client.get(
+            '/api/geodata/geojson',
+            {
+                'geojson': json.dumps(
+                    {
+                        'type': 'Polygon',
+                        'coordinates': [[[-80, 43], [-80, 44], [-79, 44], [-79, 43], [-80, 43]]],
+                    }
+                )
+            },
+        ).content
+    )
+    assert len(items) == 1
+    items = json.loads(
+        client.get(
+            '/api/geodata/geojson',
+            {
+                'geojson': json.dumps(
+                    {
+                        'type': 'Polygon',
+                        'coordinates': [[[-90, 30], [-90, 50], [-70, 50], [-70, 30], [-90, 30]]],
+                    }
+                )
+            },
+        ).content
+    )
+    assert len(items) == 3
+    timestr = (datetime.datetime.now() + datetime.timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')
+    oldtimestr = (datetime.datetime.now() - datetime.timedelta(days=1)).strftime(
+        '%Y-%m-%d %H:%M:%S'
+    )
+    items = json.loads(
+        client.get(
+            '/api/geodata/raster/geojson',
+            {'start_time': oldtimestr, 'end_time': timestr, 'timefield': 'acquisition'},
+        ).content
+    )
+    assert len(items) == len(SampleFiles)
+    items = json.loads(
+        client.get(
+            '/api/geodata/raster/geojson',
+            {'start_time': timestr, 'end_time': timestr, 'timefield': 'acquisition'},
+        ).content
+    )
+    assert len(items) == 0
+
+
+@pytest.mark.django_db(transaction=True)
+def test_search_geojson_extent(client):
+    _load_sample_files()
+    results = json.loads(client.get('/api/geodata/geojson/extent').content)
+    assert results['count'] == len(SampleFiles)
+    results = json.loads(
+        client.get(
+            '/api/geodata/geojson/extent',
+            {
+                'geojson': json.dumps(
+                    {
+                        'type': 'Polygon',
+                        'coordinates': [[[-80, 43], [-80, 44], [-79, 44], [-79, 43], [-80, 43]]],
+                    }
+                )
+            },
+        ).content
+    )
+    assert results['count'] == 1
+    results = json.loads(
+        client.get(
+            '/api/geodata/geojson/extent',
+            {
+                'geojson': json.dumps(
+                    {
+                        'type': 'Polygon',
+                        'coordinates': [[[-90, 30], [-90, 50], [-70, 50], [-70, 30], [-90, 30]]],
+                    }
+                )
+            },
+        ).content
+    )
+    assert results['count'] == 3
+    timestr = (datetime.datetime.now() + datetime.timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')
+    oldtimestr = (datetime.datetime.now() - datetime.timedelta(days=1)).strftime(
+        '%Y-%m-%d %H:%M:%S'
+    )
+    results = json.loads(
+        client.get(
+            '/api/geodata/raster/geojson/extent',
+            {'start_time': oldtimestr, 'end_time': timestr, 'timefield': 'acquisition'},
+        ).content
+    )
+    assert results['count'] == len(SampleFiles)
+    results = json.loads(
+        client.get(
+            '/api/geodata/raster/geojson/extent',
+            {'start_time': timestr, 'end_time': timestr, 'timefield': 'acquisition'},
+        ).content
+    )
+    assert results['count'] == 0