Repository: ResonantGeoData/ResonantGeoData (language: python)

Issue:
STAC serializer (renderer) for RasterMetaEntry
It describes the raster's 

- footprint
- bounding box
- related images
- ancillary files
- projection
- cloud cover
- band metadata per asset

E.g.:
```json
{
   "type":"Feature",
   "stac_version":"1.0.0-beta.2",
   "id":7,
   "properties":{
      "proj:transform":[
         -84.14505029585798,
         0.00010059171597632816,
         0.0,
         39.808049541284404,
         0.0,
         -9.908256880734321e-05
      ],
      "eo:bands":[
         
      ],
      "datetime":"2021-05-06T18:05:42.743338Z"
   },
   "geometry":{
      "type":"Polygon",
      "coordinates":[
         [
            [
               -84.14505029585798,
               39.808049541284404
            ],
            [
               -84.07694970414201,
               39.808049541284404
            ],
            [
               -84.07694970414201,
               39.75395045871559
            ],
            [
               -84.14505029585798,
               39.75395045871559
            ],
            [
               -84.14505029585798,
               39.808049541284404
            ]
         ]
      ]
   },
   "links":[
      
   ],
   "assets":{
      "image-3":{
         "href":"http://localhost:9000/rgd-storage/6e5b1132-c150-4506-8e61-8df10283908a/20091021202517-01000100-VIS_0001.ntf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=mcovalt%2F20210511%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20210511T161513Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=ff74e8012fdfe9ea854275a2f766394e6166cd58fd1ab67e404c98f0e27481e9",
         "title":"20091021202517-01000100-VIS_0001.ntf",
         "eo:bands":[
            {
               "name":"band1",
               "description":""
            }
         ]
      }
   },
   "bbox":[
      -84.14505029585798,
      39.75395045871559,
      -84.07694970414201,
      39.808049541284404
   ],
   "stac_extensions":[
      "projection",
      "eo"
   ]
}
```

Clean up endpoints and RGDC client
- [x] Make separate search endpoints for each spatial type
- [x] Clean up "_entry" occurrences in Python client, etc.
`as_stac` option for `Rgdc.search`
Before https://github.com/ResonantGeoData/ResonantGeoData/issues/327 is complete, one thing we can do in the meantime is add an option for `Rgdc.search` results to conform to the fields of a [STAC item](https://github.com/radiantearth/stac-spec/blob/master/item-spec/item-spec.md#item-fields). This will be useful for downstream tasks that expect to ingest a STAC catalog.

In this case, there will not be a true catalog, but one can be created based on known information in the search result (`instrumentation`, tile, date).


Discussion / hints:
we will need link to downloadable asset, relationship to category or collection probably not at this stage. 

@matt-bernstein could you test this / provide feedback?
> Do we want to link to a downloadable assets?

Yes, I think we do. For rasters, we want to link all related `ImageFile`s and all `ChecksumFile`s in the `ancillary_files` field.
This is working great for me! I just added a new endpoint to get the STAC JSON for any raster entry and added a method to the Python client to fetch it as well.

We may also want to add the `cloud_cover` field to these STAC item properties. What might be the best naming convention for that?
The `cloud_cover` field can be handled by the [eo extension](https://github.com/stac-extensions/eo). I'll add that now.
We should also try to resolve #374 in this effort. @mcovalt, I don't think we can dynamically set the serializer on the `SearchSpatialEntryView`, can we? Perhaps it would be easiest/best to create a new `SearchRasterMetaEntryView` that uses this serializer and a different version of the filter specifically for rasters.

After all, #352 tasked me with adding separate search endpoints for each data type. I could jump on this to create a new filter and endpoint specifically for rasters that will use this newserializer.
I say once we get cloud coverage in this PR, let's merge it. Then I will follow up with a separate PR to add a new search endpoint that will resolve both #374 and #352



Fix patch:
diff --git a/rgd/geodata/api/search.py b/rgd/geodata/api/search.py
index b265271ae..82d7dff72 100644
--- a/rgd/geodata/api/search.py
+++ b/rgd/geodata/api/search.py
@@ -16,7 +16,7 @@
 from rest_framework.response import Response
 
 from rgd.geodata import serializers
-from rgd.geodata.filters import SpatialEntryFilter
+from rgd.geodata.filters import RasterMetaEntryFilter, SpatialEntryFilter
 from rgd.geodata.models import GeometryEntry, RasterMetaEntry, SpatialEntry
 from rgd.geodata.permissions import filter_read_perm
 
@@ -605,3 +605,13 @@ class SearchSpatialEntryView(ListAPIView):
 
     def get_queryset(self):
         return filter_read_perm(self.request.user, super().get_queryset())
+
+
+class SearchRasterMetaEntrySTACView(ListAPIView):
+    queryset = RasterMetaEntry.objects.all()
+    serializer_class = serializers.STACRasterSerializer
+    filter_backends = [DjangoFilterBackend]
+    filterset_class = RasterMetaEntryFilter
+
+    def get_queryset(self):
+        return filter_read_perm(self.request.user, super().get_queryset())
diff --git a/rgd/geodata/filters.py b/rgd/geodata/filters.py
index e6452348a..b83c109a7 100644
--- a/rgd/geodata/filters.py
+++ b/rgd/geodata/filters.py
@@ -5,7 +5,8 @@
 from django.db.models import Q, Sum
 from django_filters import rest_framework as filters
 
-from rgd.geodata.models.common import SpatialEntry
+from rgd.geodata.models.common import ModifiableEntry, SpatialEntry
+from rgd.geodata.models.imagery import RasterMetaEntry
 
 
 class GeometryFilter(filters.Filter):
@@ -14,6 +15,27 @@ class GeometryFilter(filters.Filter):
     field_class.widget.map_srid = 4326
 
 
+class ModifiableEntryFilterMixin(filters.FilterSet):
+
+    created = filters.IsoDateTimeFromToRangeFilter(
+        field_name='created',
+        help_text='The ISO 8601 formatted date and time when data was created.',
+        label='Created',
+    )
+    modified = filters.IsoDateTimeFromToRangeFilter(
+        field_name='modified',
+        help_text='The ISO 8601 formatted date and time when data was modified.',
+        label='Modified',
+    )
+
+    class Meta:
+        model = ModifiableEntry
+        fields = [
+            'created',
+            'modified',
+        ]
+
+
 class SpatialEntryFilter(filters.FilterSet):
 
     q = GeometryFilter(
@@ -62,58 +84,12 @@ class SpatialEntryFilter(filters.FilterSet):
         help_text='The ISO 8601 formatted date and time when data was acquired.',
         label='Acquired',
     )
-    created = filters.IsoDateTimeFromToRangeFilter(
-        field_name='created',
-        help_text='The ISO 8601 formatted date and time when data was created.',
-        label='Created',
-        method='filter_created',
-    )
-    modified = filters.IsoDateTimeFromToRangeFilter(
-        field_name='modified',
-        help_text='The ISO 8601 formatted date and time when data was modified.',
-        label='Modified',
-        method='filter_modified',
-    )
-    datatype = filters.ChoiceFilter(
-        choices=(
-            ('raster', 'raster'),
-            ('fmv', 'fmv'),
-            ('geometry', 'geometry'),
-        ),
-        help_text='The datatype to provide.',
-        method='filter_datatype',
-        label='Datatype',
-    )
     instrumentation = filters.CharFilter(
         field_name='instrumentation',
         help_text='The instrumentation used to acquire at least one of these data.',
         label='Instrumentation',
         lookup_expr='icontains',
     )
-    num_bands = filters.RangeFilter(
-        fields=(forms.IntegerField(), forms.IntegerField()),
-        help_text='The number of bands in the raster.',
-        method='filter_bands',
-        label='Number of bands',
-    )
-    resolution = filters.RangeFilter(
-        fields=(forms.IntegerField(), forms.IntegerField()),
-        help_text='The resolution of the raster.',
-        label='Resolution',
-        method='filter_resolution',
-    )
-    cloud_cover = filters.RangeFilter(
-        field_name='rastermetaentry__cloud_cover',
-        fields=(forms.FloatField(), forms.FloatField()),
-        help_text='The cloud coverage of the raster.',
-        label='Cloud cover',
-    )
-    frame_rate = filters.RangeFilter(
-        field_name='fmventry__fmv_file',
-        fields=(forms.IntegerField(), forms.IntegerField()),
-        help_text='The frame rate of the video.',
-        label='Frame rate',
-    )
 
     def filter_q(self, queryset, name, value):
         """Sort the queryset by distance to queried geometry.
@@ -154,57 +130,38 @@ def filter_distance(self, queryset, name, value):
                 queryset = queryset.filter(footprint__distance_lte=(geom, D(m=value.stop)))
             return queryset
 
-    def filter_datatype(self, queryset, name, value):
-        """Filter the `SpatialEntry`s to a specific datatype."""
-        if value == 'geometry':
-            return queryset.filter(geometryentry__isnull=False)
-        if value == 'raster':
-            return queryset.filter(rastermetaentry__isnull=False)
-        if value == 'fmv':
-            return queryset.filter(fmventry__isnull=False)
-        return queryset
+    class Meta:
+        model = SpatialEntry
+        fields = [
+            'q',
+            'predicate',
+            'relates',
+            'distance',
+            'acquired',
+            'instrumentation',
+        ]
 
-    def filter_created(self, queryset, name, value):
-        """Filter by when the data was created."""
-        if value:
-            if value.start is not None:
-                queryset = queryset.filter(
-                    (
-                        Q(geometryentry__created__gte=str(value.start))
-                        | Q(fmventry__created__gte=str(value.start))
-                        | Q(rastermetaentry__parent_raster__created__gte=str(value.start))
-                    )
-                )
-            if value.stop is not None:
-                queryset = queryset.filter(
-                    (
-                        Q(geometryentry__created__lte=str(value.stop))
-                        | Q(fmventry__created__lte=str(value.stop))
-                        | Q(rastermetaentry__parent_raster__created__lte=str(value.stop))
-                    )
-                )
-        return queryset
 
-    def filter_modified(self, queryset, name, value):
-        """Filter by when the data was modified."""
-        if value:
-            if value.start is not None:
-                queryset = queryset.filter(
-                    (
-                        Q(geometryentry__modified__gte=str(value.start))
-                        | Q(fmventry__modified__gte=str(value.start))
-                        | Q(rastermetaentry__parent_raster__modified__gte=str(value.start))
-                    )
-                )
-            if value.stop is not None:
-                queryset = queryset.filter(
-                    (
-                        Q(geometryentry__modified__lte=str(value.stop))
-                        | Q(fmventry__modified__lte=str(value.stop))
-                        | Q(rastermetaentry__parent_raster__modified__lte=str(value.stop))
-                    )
-                )
-        return queryset
+class RasterMetaEntryFilter(SpatialEntryFilter):
+
+    num_bands = filters.RangeFilter(
+        fields=(forms.IntegerField(), forms.IntegerField()),
+        help_text='The number of bands in the raster.',
+        method='filter_bands',
+        label='Number of bands',
+    )
+    resolution = filters.RangeFilter(
+        fields=(forms.IntegerField(), forms.IntegerField()),
+        help_text='The resolution of the raster.',
+        label='Resolution',
+        method='filter_resolution',
+    )
+    cloud_cover = filters.RangeFilter(
+        field_name='cloud_cover',
+        fields=(forms.FloatField(), forms.FloatField()),
+        help_text='The cloud coverage of the raster.',
+        label='Cloud cover',
+    )
 
     def filter_bands(self, queryset, name, value):
         """Filter by the total number of bands in the raster data.
@@ -213,7 +170,7 @@ def filter_bands(self, queryset, name, value):
         """
         if value is not None:
             queryset = queryset.annotate(
-                num_bands=Sum('rastermetaentry__parent_raster__image_set__images__number_of_bands')
+                num_bands=Sum('parent_raster__image_set__images__number_of_bands')
             )
             if value.start is not None:
                 queryset = queryset.filter(num_bands__gte=value.start)
@@ -226,30 +183,18 @@ def filter_resolution(self, queryset, name, value):
         if value is not None:
             if value.start is not None:
                 queryset = queryset.filter(
-                    Q(rastermetaentry__resolution__0__gte=value.start)
-                    & Q(rastermetaentry__resolution__1__gte=value.start)
+                    Q(resolution__0__gte=value.start) & Q(resolution__1__gte=value.start)
                 )
             if value.stop is not None:
                 queryset = queryset.filter(
-                    Q(rastermetaentry__resolution__0__lte=value.stop)
-                    & Q(rastermetaentry__resolution__1__lte=value.stop)
+                    Q(resolution__0__lte=value.stop) & Q(resolution__1__lte=value.stop)
                 )
         return queryset
 
     class Meta:
-        model = SpatialEntry
+        model = RasterMetaEntry
         fields = [
-            'q',
-            'predicate',
-            'relates',
-            'distance',
-            'acquired',
-            'created',
-            'modified',
-            'datatype',
-            'instrumentation',
             'num_bands',
             'resolution',
-            'frame_rate',
             'cloud_cover',
         ]
diff --git a/rgd/geodata/templates/geodata/_base/search_view.html b/rgd/geodata/templates/geodata/_base/search_view.html
index c6ea340b7..34569f153 100644
--- a/rgd/geodata/templates/geodata/_base/search_view.html
+++ b/rgd/geodata/templates/geodata/_base/search_view.html
@@ -26,7 +26,132 @@
 
       <div class="column is-sidebar-content">
         <div id="searchTab" class="tabcontent" style="display: block;">
-          {% include 'geodata/_includes/search_form.html' %}
+
+          <div class="columns is-multiline mx-0">
+            <div class="column is-full mb-2 pb-5">
+              <div class="field">
+                <div class="field-label is-normal mb-1">
+                  <label class="label">Map Tool:</label>
+                </div>
+                <div class="field-body">
+                  <div class="field">
+                    <div class="control">
+                      <div class="select is-halfwidth">
+                        <select id="mapTool">
+                          <option value="stJSON">GeoJSON</option>
+                          <option value="stPoint">Point</option>
+                          <option value="stBounding">Bounding Box</option>
+                        </select>
+                      </div>
+                      <div>
+                        <div id="stJSON" class="selection" style="display: none;">
+                          <div class="column is-full field is-grouped">
+                            <div class="control">
+                              <input class="button is-secondary" type="button" value="Draw on Map" name="drawOnMapButton" onclick="enableDrawOnMap()">
+                            </div>
+                          </div>
+                        </div>
+                        <div id="stPoint" class="selection" style="display: none;">
+                          <div class="column is-full field is-grouped">
+                            <div class="control">
+                              <input class="button is-secondary" type="button" value="Use Map" name="button" onclick="populatePoint()">
+                            </div>
+                          </div>
+                        </div>
+                        <div id="stBounding" class="selection" style="display: none;">
+                          <div class="column is-full field is-grouped">
+                            <div class="control">
+                              <input class="button is-secondary" type="button" value="Use Map" name="button" onclick="populateBounds()">
+                            </div>
+                          </div>
+                        </div>
+                      </div>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <div class="column is-full">
+              <form method="GET">
+                <div class="columns is-multiline mx-0">
+                  {% block search_fields %}{% endblock %}
+
+                  <div id="submitButton" class="column is-full" style="left: 0; bottom: 0; width: 100%;">
+                    <div class="field">
+                      <div class="control">
+                        <input class="button is-accent" type="submit" value="Filter">
+                      </div>
+                    </div>
+                  </div>
+                </div>
+              </form>
+            </div>
+          </div>
+
+          <script>
+            var geojsonField = document.getElementsByName("q")[0]
+
+            function populateBounds() {
+              var bounds = map.bounds()
+              var boxjson = {
+                "type": "Polygon",
+                "coordinates": [
+                  [
+                    [bounds.left, bounds.bottom],
+                    [bounds.left, bounds.top],
+                    [bounds.right, bounds.top],
+                    [bounds.right, bounds.bottom],
+                    [bounds.left, bounds.bottom]
+                  ]
+                ]
+              };
+              geojsonField.value = JSON.stringify(boxjson);
+            }
+
+            function populatePoint() {
+              var center = map.center()
+              var pointjson = {
+                "type": "Point",
+                "coordinates": [center.x, center.y]
+              }
+              geojsonField.value = JSON.stringify(pointjson)
+            }
+
+
+            function enableDrawOnMap() {
+              // Disable button until completed
+              var button = document.getElementsByName("drawOnMapButton")[0]
+              button.disabled = true
+              // Clear any previous annotations
+              annotationLayer.removeAllAnnotations()
+              // Start new annotation
+              annotationLayer.mode('polygon');
+              annotationLayer.geoOn(geo.event.annotation.state, (e) => {
+                if (e.annotation.state() === "done") {
+                  geojsonField.value = JSON.stringify(e.annotation.geojson().geometry)
+                  // Re-enable button
+                  button.disabled = false
+                }
+              });
+            }
+
+            var mapTool = document.getElementById("mapTool")
+            $(function() {
+              $('#mapTool').change(function() {
+                $('.selection').hide();
+                $('#' + $(this).val()).show();
+                setCookie('mapToolType', $(this)[0].value)
+              });
+              mapTool.value = getCookie('mapToolType', 'stPoint')
+              $('#mapTool').change()
+            });
+          </script>
+
+
+
+
+
         </div>
 
         <div id="resultsTab" class="tabcontent" style="display: none;">
diff --git a/rgd/geodata/templates/geodata/_includes/navbar.html b/rgd/geodata/templates/geodata/_includes/navbar.html
index 59905ccc2..124c42b44 100644
--- a/rgd/geodata/templates/geodata/_includes/navbar.html
+++ b/rgd/geodata/templates/geodata/_includes/navbar.html
@@ -13,6 +13,9 @@
     </div>
 
     <div id="navbarBasicExample" class="navbar-menu">
+      <a href="{% url 'raster-search' %}" class="navbar-item">
+        Rasters
+      </a>
       <div class="navbar-end user-menu">
         {% if user.is_authenticated %}
           <div class="navbar-item">{{ request.user.email }}</div>
diff --git a/rgd/geodata/templates/geodata/_includes/point_search.html b/rgd/geodata/templates/geodata/_includes/point_search.html
deleted file mode 100644
index 20f0cbddf..000000000
--- a/rgd/geodata/templates/geodata/_includes/point_search.html
+++ /dev/null
@@ -1,12 +0,0 @@
-{% block content %}
-  <form method="GET">
-    Longitude (deg): <input type="text" name="longitude" value="{{ request.GET.longitude }}">
-    Latitude (deg): <input type="text" name="latitude" value="{{ request.GET.latitude }}">
-    <br>
-    Proximity (m): <input type="text" name="radius" value="{{ request.GET.radius }}">
-    <input class="button" type="submit" value="Filter">
-  </form>
-
-  {% include 'geodata/_includes/viewer.html' %}
-
-{% endblock %}
diff --git a/rgd/geodata/templates/geodata/_includes/search_fields.html b/rgd/geodata/templates/geodata/_includes/search_fields.html
new file mode 100644
index 000000000..d62b3a46d
--- /dev/null
+++ b/rgd/geodata/templates/geodata/_includes/search_fields.html
@@ -0,0 +1,97 @@
+{% block basic_search_fields %}
+
+  <div class="column is-full field">
+    <div class="field-label is-normal">
+      <label class="label">GeoJSON geometry <button onclick="javascript:q.value=''" type="button">X</button></label>
+    </div>
+    <div class="field-body">
+      <div class="field">
+        <div class="control">
+          <input class="input" type="text" name="q" value="{{ request.GET.q }}" required>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="column is-full">
+    <div class="field">
+      <div class="field-label is-normal">
+        <label class="label">Predicate</label>
+      </div>
+      <div class="field-body">
+        <div class="field">
+          <div class="control">
+            <div class="select is-fullwidth">
+              <select name="predicate" id="predicate">
+                <option selected value> -- select an option -- </option>
+                <option value="within">within</option>
+                <option value="contains">contains</option>
+                <option value="crosses">crosses</option>
+                <option value="disjoint">disjoint</option>
+                <option value="equals">equals</option>
+                <option value="intersects">intersects</option>
+                <option value="overlaps">overlaps</option>
+                <option value="touches">touches</option>
+              </select>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <script>
+    // automatically select the predicate field based on form
+    $("document").ready(function() {
+      var selValue = "{{ request.GET.predicate }}";
+      if (selValue) {
+        $("#predicate").val(selValue).attr("selected", "selected")
+      }
+    });
+  </script>
+
+  <div class="column is-full field mb-0">
+    <div class="field-label is-normal">
+      <label class="label">Distance (Min/Max)</label>
+    </div>
+    <div class="field-body">
+      <div class="field">
+        <div class="control">
+          <input class="input" type="text" name="distance_min" value="{{ request.GET.distance_min }}">
+          <input class="input" type="text" name="distance_max" value="{{ request.GET.distance_max }}">
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="column is-full field mb-0">
+    <div class="field-label is-normal">
+      <label class="label">Instrumentation</label>
+    </div>
+    <div class="field-body">
+      <div class="field">
+        <div class="control">
+          <input class="input" type="text" name="instrumentation" value="{{ request.GET.instrumentation }}">
+        </div>
+      </div>
+    </div>
+  </div>
+
+
+  <div class="column is-full field mb-0">
+    <div class="field-label is-normal">
+      <label class="label">Acquired</label>
+    </div>
+    <div class="field-body">
+      <div class="field">
+        <div class="control">
+          <input type="datetime-local" name="acquired_before" value="{{ request.GET.acquired_before }}">
+          <button onclick="javascript:acquired_before.value=''" type="button">X</button>
+          <input type="datetime-local" name="acquired_after" value="{{ request.GET.acquired_after }}">
+          <button onclick="javascript:acquired_after.value=''" type="button">X</button>
+        </div>
+      </div>
+    </div>
+  </div>
+
+{% endblock %}
diff --git a/rgd/geodata/templates/geodata/_includes/search_fields_modifiable.html b/rgd/geodata/templates/geodata/_includes/search_fields_modifiable.html
new file mode 100644
index 000000000..e0447a1a8
--- /dev/null
+++ b/rgd/geodata/templates/geodata/_includes/search_fields_modifiable.html
@@ -0,0 +1,35 @@
+{% block modifiable_search_fields %}
+
+  <div class="column is-full field mb-0">
+    <div class="field-label is-normal">
+      <label class="label">Created</label>
+    </div>
+    <div class="field-body">
+      <div class="field">
+        <div class="control">
+          <input type="datetime-local" name="created_before" value="{{ request.GET.created_before }}">
+          <button onclick="javascript:created_before.value=''" type="button">X</button>
+          <input type="datetime-local" name="created_after" value="{{ request.GET.created_after }}">
+          <button onclick="javascript:created_after.value=''" type="button">X</button>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="column is-full field mb-0">
+    <div class="field-label is-normal">
+      <label class="label">Modified</label>
+    </div>
+    <div class="field-body">
+      <div class="field">
+        <div class="control">
+          <input type="datetime-local" name="modified_before" value="{{ request.GET.modified_before }}">
+          <button onclick="javascript:modified_before.value=''" type="button">X</button>
+          <input type="datetime-local" name="modified_after" value="{{ request.GET.modified_after }}">
+          <button onclick="javascript:modified_after.value=''" type="button">X</button>
+        </div>
+      </div>
+    </div>
+  </div>
+
+{% endblock %}
diff --git a/rgd/geodata/templates/geodata/_includes/search_fields_raster.html b/rgd/geodata/templates/geodata/_includes/search_fields_raster.html
new file mode 100644
index 000000000..a782a80a4
--- /dev/null
+++ b/rgd/geodata/templates/geodata/_includes/search_fields_raster.html
@@ -0,0 +1,43 @@
+{% block raster_search_fields %}
+  <div class="column is-full field mb-0">
+    <div class="field-label is-normal">
+      <label class="label">Number of Bands (Min/Max)</label>
+    </div>
+    <div class="field-body">
+      <div class="field">
+        <div class="control">
+          <input class="input" type="text" name="num_bands_min" value="{{ request.GET.num_bands_min }}">
+          <input class="input" type="text" name="num_bands_max" value="{{ request.GET.num_bands_max }}">
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="column is-full field mb-0">
+    <div class="field-label is-normal">
+      <label class="label">Resolution (Min/Max)</label>
+    </div>
+    <div class="field-body">
+      <div class="field">
+        <div class="control">
+          <input class="input" type="text" name="resolution_min" value="{{ request.GET.resolution_min }}">
+          <input class="input" type="text" name="resolution_max" value="{{ request.GET.resolution_max }}">
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="column is-full field mb-0">
+    <div class="field-label is-normal">
+      <label class="label">Cloud Cover (Min/Max)</label>
+    </div>
+    <div class="field-body">
+      <div class="field">
+        <div class="control">
+          <input class="input" type="text" name="cloud_cover_min" value="{{ request.GET.cloud_cover_min }}">
+          <input class="input" type="text" name="cloud_cover_max" value="{{ request.GET.cloud_cover_max }}">
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock %}
diff --git a/rgd/geodata/templates/geodata/_includes/search_form.html b/rgd/geodata/templates/geodata/_includes/search_form.html
deleted file mode 100644
index d511f0eef..000000000
--- a/rgd/geodata/templates/geodata/_includes/search_form.html
+++ /dev/null
@@ -1,328 +0,0 @@
-{% block search_form %}
-
-<div class="columns is-multiline mx-0">
-  <div class="column is-full mb-2 pb-5">
-    <div class="field">
-      <div class="field-label is-normal mb-1">
-        <label class="label">Map Tool:</label>
-      </div>
-      <div class="field-body">
-        <div class="field">
-          <div class="control">
-            <div class="select is-halfwidth">
-              <select id="mapTool">
-                <option value="stJSON">GeoJSON</option>
-                <option value="stPoint">Point</option>
-                <option value="stBounding">Bounding Box</option>
-              </select>
-            </div>
-            <div>
-              <div id="stJSON" class="selection" style="display: none;">
-                <div class="column is-full field is-grouped">
-                  <div class="control">
-                    <input class="button is-secondary" type="button" value="Draw on Map" name="drawOnMapButton" onclick="enableDrawOnMap()">
-                  </div>
-                </div>
-              </div>
-              <div id="stPoint" class="selection" style="display: none;">
-                <div class="column is-full field is-grouped">
-                  <div class="control">
-                    <input class="button is-secondary" type="button" value="Use Map" name="button" onclick="populatePoint()">
-                  </div>
-                </div>
-              </div>
-              <div id="stBounding" class="selection" style="display: none;">
-                <div class="column is-full field is-grouped">
-                  <div class="control">
-                    <input class="button is-secondary" type="button" value="Use Map" name="button" onclick="populateBounds()">
-                  </div>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-
-  <div class="column is-full">
-    <form method="GET">
-      <div class="columns is-multiline mx-0">
-
-        <div class="column is-full field mb-0">
-          <div class="field">
-            <div class="field-label is-normal">
-              <label class="label">Data Type</label>
-            </div>
-            <div class="field-body">
-              <div class="field">
-                <div class="control">
-                  <div class="select is-fullwidth">
-                    <select name="datatype" id="datatype">
-                      <option selected value> -- select an option -- </option>
-                      <option value="raster">raster</option>
-                      <option value="fmv">fmv</option>
-                      <option value="geometry">geometry</option>
-                    </select>
-                  </div>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <script>
-          // automatically select the data type field based on form
-          $("document").ready(function() {
-            var selValue = "{{ request.GET.datatype }}";
-            if (selValue) {
-              $("#datatype").val(selValue).attr("selected", "selected")
-            }
-          });
-        </script>
-
-
-        <div class="column is-full field">
-          <div class="field-label is-normal">
-            <label class="label">GeoJSON geometry <button onclick="javascript:q.value=''" type="button">X</button></label>
-          </div>
-          <div class="field-body">
-            <div class="field">
-              <div class="control">
-                <input class="input" type="text" name="q" value="{{ request.GET.q }}" required>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <div class="column is-full">
-          <div class="field">
-            <div class="field-label is-normal">
-              <label class="label">Predicate</label>
-            </div>
-            <div class="field-body">
-              <div class="field">
-                <div class="control">
-                  <div class="select is-fullwidth">
-                    <select name="predicate" id="predicate">
-                      <option selected value> -- select an option -- </option>
-                      <option value="within">within</option>
-                      <option value="contains">contains</option>
-                      <option value="crosses">crosses</option>
-                      <option value="disjoint">disjoint</option>
-                      <option value="equals">equals</option>
-                      <option value="intersects">intersects</option>
-                      <option value="overlaps">overlaps</option>
-                      <option value="touches">touches</option>
-                    </select>
-                  </div>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <script>
-          // automatically select the predicate field based on form
-          $("document").ready(function() {
-            var selValue = "{{ request.GET.predicate }}";
-            if (selValue) {
-              $("#predicate").val(selValue).attr("selected", "selected")
-            }
-          });
-        </script>
-
-        <div class="column is-full field mb-0">
-          <div class="field-label is-normal">
-            <label class="label">Distance (Min/Max)</label>
-          </div>
-          <div class="field-body">
-            <div class="field">
-              <div class="control">
-                <input class="input" type="text" name="distance_min" value="{{ request.GET.distance_min }}">
-                <input class="input" type="text" name="distance_max" value="{{ request.GET.distance_max }}">
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <div class="column is-full field mb-0">
-          <div class="field-label is-normal">
-            <label class="label">Number of Bands (Min/Max)</label>
-          </div>
-          <div class="field-body">
-            <div class="field">
-              <div class="control">
-                <input class="input" type="text" name="num_bands_min" value="{{ request.GET.num_bands_min }}">
-                <input class="input" type="text" name="num_bands_max" value="{{ request.GET.num_bands_max }}">
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <div class="column is-full field mb-0">
-          <div class="field-label is-normal">
-            <label class="label">Resolution (Min/Max)</label>
-          </div>
-          <div class="field-body">
-            <div class="field">
-              <div class="control">
-                <input class="input" type="text" name="resolution_min" value="{{ request.GET.resolution_min }}">
-                <input class="input" type="text" name="resolution_max" value="{{ request.GET.resolution_max }}">
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <div class="column is-full field mb-0">
-          <div class="field-label is-normal">
-            <label class="label">Cloud Cover (Min/Max)</label>
-          </div>
-          <div class="field-body">
-            <div class="field">
-              <div class="control">
-                <input class="input" type="text" name="cloud_cover_min" value="{{ request.GET.cloud_cover_min }}">
-                <input class="input" type="text" name="cloud_cover_max" value="{{ request.GET.cloud_cover_max }}">
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <div class="column is-full field mb-0">
-          <div class="field-label is-normal">
-            <label class="label">Instrumentation</label>
-          </div>
-          <div class="field-body">
-            <div class="field">
-              <div class="control">
-                <input class="input" type="text" name="instrumentation" value="{{ request.GET.instrumentation }}">
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <div class="column is-full field mb-0">
-          <div class="field-label is-normal">
-            <label class="label">Created</label>
-          </div>
-          <div class="field-body">
-            <div class="field">
-              <div class="control">
-                <input type="datetime-local" name="created_before" value="{{ request.GET.created_before }}">
-                <button onclick="javascript:created_before.value=''" type="button">X</button>
-                <input type="datetime-local" name="created_after" value="{{ request.GET.created_after }}">
-                <button onclick="javascript:created_after.value=''" type="button">X</button>
-              </div>
-            </div>
-          </div>
-        </div>
-
-
-        <div class="column is-full field mb-0">
-          <div class="field-label is-normal">
-            <label class="label">Modified</label>
-          </div>
-          <div class="field-body">
-            <div class="field">
-              <div class="control">
-                <input type="datetime-local" name="modified_before" value="{{ request.GET.modified_before }}">
-                <button onclick="javascript:modified_before.value=''" type="button">X</button>
-                <input type="datetime-local" name="modified_after" value="{{ request.GET.modified_after }}">
-                <button onclick="javascript:modified_after.value=''" type="button">X</button>
-              </div>
-            </div>
-          </div>
-        </div>
-
-
-        <div class="column is-full field mb-0">
-          <div class="field-label is-normal">
-            <label class="label">Acquired</label>
-          </div>
-          <div class="field-body">
-            <div class="field">
-              <div class="control">
-                <input type="datetime-local" name="acquired_before" value="{{ request.GET.acquired_before }}">
-                <button onclick="javascript:acquired_before.value=''" type="button">X</button>
-                <input type="datetime-local" name="acquired_after" value="{{ request.GET.acquired_after }}">
-                <button onclick="javascript:acquired_after.value=''" type="button">X</button>
-              </div>
-            </div>
-          </div>
-        </div>
-
-
-
-        <div id="submitButton" class="column is-full" style="left: 0; bottom: 0; width: 100%;">
-          <div class="field">
-            <div class="control">
-              <input class="button is-accent" type="submit" value="Filter">
-            </div>
-          </div>
-        </div>
-      </div>
-    </form>
-
-  </div>
-</div>
-
-<script>
-  var geojsonField = document.getElementsByName("q")[0]
-
-  function populateBounds() {
-    var bounds = map.bounds()
-    var boxjson = {
-      "type": "Polygon",
-      "coordinates": [
-        [
-          [bounds.left, bounds.bottom],
-          [bounds.left, bounds.top],
-          [bounds.right, bounds.top],
-          [bounds.right, bounds.bottom],
-          [bounds.left, bounds.bottom]
-        ]
-      ]
-    };
-    geojsonField.value = JSON.stringify(boxjson);
-  }
-
-  function populatePoint() {
-    var center = map.center()
-    var pointjson = {
-      "type": "Point",
-      "coordinates": [center.x, center.y]
-    }
-    geojsonField.value = JSON.stringify(pointjson)
-  }
-
-
-  function enableDrawOnMap() {
-    // Disable button until completed
-    var button = document.getElementsByName("drawOnMapButton")[0]
-    button.disabled = true
-    // Clear any previous annotations
-    annotationLayer.removeAllAnnotations()
-    // Start new annotation
-    annotationLayer.mode('polygon');
-    annotationLayer.geoOn(geo.event.annotation.state, (e) => {
-      if (e.annotation.state() === "done") {
-        geojsonField.value = JSON.stringify(e.annotation.geojson().geometry)
-        // Re-enable button
-        button.disabled = false
-      }
-    });
-  }
-
-  var mapTool = document.getElementById("mapTool")
-  $(function() {
-    $('#mapTool').change(function() {
-      $('.selection').hide();
-      $('#' + $(this).val()).show();
-      setCookie('mapToolType', $(this)[0].value)
-    });
-    mapTool.value = getCookie('mapToolType', 'stPoint')
-    $('#mapTool').change()
-  });
-</script>
-
-{% endblock %}
diff --git a/rgd/geodata/templates/geodata/raster_entries.html b/rgd/geodata/templates/geodata/raster_entries.html
new file mode 100644
index 000000000..012514e25
--- /dev/null
+++ b/rgd/geodata/templates/geodata/raster_entries.html
@@ -0,0 +1,34 @@
+{% extends "geodata/_base/search_view.html" %}
+{% block title %}
+  Raster Entries
+{% endblock title %}
+
+{% block search_fields %}
+  {% include 'geodata/_includes/search_fields.html' %}
+  {% include 'geodata/_includes/search_fields_raster.html' %}
+{% endblock search_fields %}
+
+{% block list_view %}
+
+  <div style="overflow-x:auto;">
+    <table class="table">
+      <thead class="thead-dark">
+        <tr>
+          <th><span class="text">ID - Name</span></th>
+          <th><span class="text">Area</span></th>
+          <th><span class="text">Data Type</span></th>
+        </tr>
+      </thead>
+      <tbody>
+        {% for entry in spatial_entries %}
+          <tr>
+            <td><a href="{% url 'raster-entry-detail' entry.pk %}">{{ entry.spatial_id }} - {{ entry.subentry_name | truncatechars:12 }}</a></td>
+            <td>{{ entry.footprint.area | floatformat:-2 }}</td>
+            <td>{{ entry.subentry_type }}</td>
+          </tr>
+        {% endfor %}
+      </tbody>
+    </table>
+  </div>
+
+{% endblock list_view %}
diff --git a/rgd/geodata/templates/geodata/spatial_entries.html b/rgd/geodata/templates/geodata/spatial_entries.html
index 38e82b236..1569c57b8 100644
--- a/rgd/geodata/templates/geodata/spatial_entries.html
+++ b/rgd/geodata/templates/geodata/spatial_entries.html
@@ -3,6 +3,10 @@
   Spatial Entries
 {% endblock title %}
 
+{% block search_fields %}
+  {% include 'geodata/_includes/search_fields.html' %}
+{% endblock search_fields %}
+
 {% block list_view %}
 
   <div style="overflow-x:auto;">
diff --git a/rgd/geodata/urls.py b/rgd/geodata/urls.py
index 0a1f176df..3a92a47b3 100644
--- a/rgd/geodata/urls.py
+++ b/rgd/geodata/urls.py
@@ -5,6 +5,7 @@
 urlpatterns = [
     # Pages
     path(r'', views.SpatialEntriesListView.as_view(), name='index'),
+    path(r'geodata/raster/', views.RasterMetaEntriesListView.as_view(), name='raster-search'),
     path(
         'geodata/spatial_entries/<int:pk>/',
         views.spatial_entry_redirect_view,
@@ -48,6 +49,7 @@
     path('api/geosearch/raster/geojson/extent', api.search.search_geojson_extent_raster),
     path('api/geosearch/geometry/geojson/extent', api.search.search_geojson_extent_geometry),
     path('api/geosearch', api.search.SearchSpatialEntryView.as_view()),
+    path('api/geosearch/raster', api.search.SearchRasterMetaEntrySTACView.as_view()),
     #############
     # Other
     path(
diff --git a/rgd/geodata/views.py b/rgd/geodata/views.py
index efe839ba6..142695778 100644
--- a/rgd/geodata/views.py
+++ b/rgd/geodata/views.py
@@ -8,7 +8,7 @@
 from rgd.geodata import permissions
 
 from .api import search
-from .filters import SpatialEntryFilter
+from .filters import RasterMetaEntryFilter, SpatialEntryFilter
 from .models.common import SpatialEntry
 from .models.fmv.base import FMVEntry
 from .models.geometry import GeometryEntry
@@ -28,7 +28,7 @@ class _SpatialListView(generic.ListView):
     paginate_by = 15
 
     def get_queryset(self):
-        filterset = SpatialEntryFilter(data=self.request.GET)
+        filterset = self.filter(data=self.request.GET)
         assert filterset.is_valid()
         queryset = filterset.filter_queryset(self.model.objects.all())
         return permissions.filter_read_perm(self.request.user, queryset)
@@ -54,10 +54,18 @@ def get_context_data(self, *args, **kwargs):
 
 class SpatialEntriesListView(_SpatialListView):
     model = SpatialEntry
+    filter = SpatialEntryFilter
     context_object_name = 'spatial_entries'
     template_name = 'geodata/spatial_entries.html'
 
 
+class RasterMetaEntriesListView(_SpatialListView):
+    model = RasterMetaEntry
+    filter = RasterMetaEntryFilter
+    context_object_name = 'spatial_entries'
+    template_name = 'geodata/raster_entries.html'
+
+
 class _SpatialDetailView(DetailView):
     def get_object(self):
         obj = super().get_object()
diff --git a/rgdc/README.md b/rgdc/README.md
index b6e53c690..645e2dc36 100644
--- a/rgdc/README.md
+++ b/rgdc/README.md
@@ -35,7 +35,7 @@ bbox = {
     ]
 }
 
-q = client.search(query=json.dumps(bbox), predicate='intersects', datatype='raster')
+q = client.search(query=json.dumps(bbox), predicate='intersects')
 
 for s in q:
     print(s['subentry_name'])
diff --git a/rgdc/rgdc/rgdc.py b/rgdc/rgdc/rgdc.py
index 41398185d..0c3366381 100644
--- a/rgdc/rgdc/rgdc.py
+++ b/rgdc/rgdc/rgdc.py
@@ -1,22 +1,19 @@
 from base64 import b64encode
 from dataclasses import dataclass
 import getpass
-import json
-from json.decoder import JSONDecodeError
 from pathlib import Path
 import tempfile
 from typing import Dict, Iterator, List, Optional, Tuple, Union
 
-from geomet import wkt
 from tqdm import tqdm
 
 from .session import RgdcSession
-from .types import DATETIME_OR_STR_TUPLE, SEARCH_DATATYPE_CHOICE, SEARCH_PREDICATE_CHOICE
+from .types import DATETIME_OR_STR_TUPLE, SEARCH_PREDICATE_CHOICE
 from .utils import (
     DEFAULT_RGD_API,
-    datetime_to_str,
     download_checksum_file_to_path,
     limit_offset_pager,
+    spatial_search_params,
     spatial_subentry_id,
 )
 
@@ -206,19 +203,59 @@ def search(
         relates: Optional[str] = None,
         distance: Optional[Tuple[float, float]] = None,
         acquired: Optional[DATETIME_OR_STR_TUPLE] = None,
-        created: Optional[DATETIME_OR_STR_TUPLE] = None,
-        modified: Optional[DATETIME_OR_STR_TUPLE] = None,
-        datatype: Optional[SEARCH_DATATYPE_CHOICE] = None,
+        instrumentation: Optional[str] = None,
+        limit: Optional[int] = None,
+        offset: Optional[int] = None,
+    ) -> List[Dict]:
+        """
+        Search for geospatial entries based on various criteria.
+
+        For Ranges (Tuples), an entry of `None` means that side of the range is unbounded.
+        E.g. a range of (2, None) is 2 or more, (None, 5) is at most 5, (2, 5) is between 2 and 5.
+
+        Args:
+            query: Either a WKT GeoJSON representation, a GeoJSON string, or a GeoJSON dict.
+            predicate: A named spatial predicate based on the DE-9IM. This spatial predicate will
+                be used to filter data such that predicate(a, b) where b is the queried geometry.
+            relates: Specify exactly how the queried geometry should relate to the data using a
+                DE-9IM string code.
+            distance: The min/max distance around the queried geometry in meters.
+            acquired: The min/max date and time (ISO 8601) when data was acquired.
+            instrumentation: The instrumentation used to acquire at least one of these data.
+            limit: The maximum number of results to return.
+            offset: The number of results to skip.
+
+        Returns:
+            A list of Spatial Entries.
+        """
+        params = spatial_search_params(
+            query=query,
+            predicate=predicate,
+            relates=relates,
+            distance=distance,
+            acquired=acquired,
+            instrumentation=instrumentation,
+            limit=limit,
+            offset=offset,
+        )
+        return list(limit_offset_pager(self.session, 'geosearch', params=params))
+
+    def search_raster_stac(
+        self,
+        query: Optional[Union[Dict, str]] = None,
+        predicate: Optional[SEARCH_PREDICATE_CHOICE] = None,
+        relates: Optional[str] = None,
+        distance: Optional[Tuple[float, float]] = None,
+        acquired: Optional[DATETIME_OR_STR_TUPLE] = None,
         instrumentation: Optional[str] = None,
         num_bands: Optional[Tuple[int, int]] = None,
         resolution: Optional[Tuple[int, int]] = None,
         cloud_cover: Optional[Tuple[float, float]] = None,
-        frame_rate: Optional[Tuple[int, int]] = None,
         limit: Optional[int] = None,
         offset: Optional[int] = None,
     ) -> List[Dict]:
         """
-        Search for geospatial entries based on various criteria.
+        Search for raster entries based on various criteria.
 
         For Ranges (Tuples), an entry of `None` means that side of the range is unbounded.
         E.g. a range of (2, None) is 2 or more, (None, 5) is at most 5, (2, 5) is between 2 and 5.
@@ -231,66 +268,26 @@ def search(
                 DE-9IM string code.
             distance: The min/max distance around the queried geometry in meters.
             acquired: The min/max date and time (ISO 8601) when data was acquired.
-            created: The min/max date and time (ISO 8601) when data was created.
-            modified: The min/max date and time (ISO 8601) when data was modified.
-            datatype: The datatype to provide.
             instrumentation: The instrumentation used to acquire at least one of these data.
             num_bands: The min/max number of bands in the raster.
             resolution: The min/max resolution of the raster.
             cloud_cover: The min/max cloud coverage of the raster.
-            frame_rate: The min/max frame rate of the video.
             limit: The maximum number of results to return.
             offset: The number of results to skip.
 
         Returns:
-            A list of Spatial Entries.
+            A list of Spatial Entries in STAC Item format.
         """
-        # The dict that will be used to store params.
-        # Initialize with queries that won't be additionally processed.
-        params = {
-            'predicate': predicate,
-            'relates': relates,
-            'datatype': datatype,
-            'instrumentation': instrumentation,
-            'limit': limit,
-            'offset': offset,
-        }
-
-        if query:
-            if isinstance(query, str):
-                try:
-                    query = json.loads(query)
-                except JSONDecodeError:
-                    pass
-
-            if isinstance(query, dict):
-                # Allow failure on invalid format
-                query = wkt.dumps(query)
-
-            params['q'] = query
-
-        # Process range params
-
-        if distance and len(distance) == 2:
-            dmin, dmax = distance
-            params['distance_min'] = dmin
-            params['distance_max'] = dmax
-
-        # TODO: Determine if the before/after param order needs to be swapped?
-        if acquired and len(acquired) == 2:
-            amin, amax = acquired
-            params['acquired_before'] = datetime_to_str(amax)
-            params['acquired_after'] = datetime_to_str(amin)
-
-        if created and len(created) == 2:
-            cmin, cmax = created
-            params['created_before'] = datetime_to_str(cmax)
-            params['created_after'] = datetime_to_str(cmin)
-
-        if modified and len(modified) == 2:
-            mmin, mmax = modified
-            params['modified_before'] = datetime_to_str(mmax)
-            params['modified_after'] = datetime_to_str(mmin)
+        params = spatial_search_params(
+            query=query,
+            predicate=predicate,
+            relates=relates,
+            distance=distance,
+            acquired=acquired,
+            instrumentation=instrumentation,
+            limit=limit,
+            offset=offset,
+        )
 
         if num_bands and len(num_bands) == 2:
             nbmin, nbmax = num_bands
@@ -307,9 +304,4 @@ def search(
             params['cloud_cover_min'] = ccmin
             params['cloud_cover_max'] = ccmax
 
-        if frame_rate and len(frame_rate) == 2:
-            frmin, frmax = frame_rate
-            params['frame_rate_min'] = frmin
-            params['frame_rate_max'] = frmax
-
-        return list(limit_offset_pager(self.session, 'geosearch', params=params))
+        return list(limit_offset_pager(self.session, 'geosearch/raster', params=params))
diff --git a/rgdc/rgdc/types.py b/rgdc/rgdc/types.py
index f0c950ba1..b819f3aad 100644
--- a/rgdc/rgdc/types.py
+++ b/rgdc/rgdc/types.py
@@ -2,7 +2,6 @@
 from typing import Literal, Tuple, Union
 
 DATETIME_OR_STR_TUPLE = Tuple[Union[datetime, str], Union[datetime, str]]
-SEARCH_DATATYPE_CHOICE = Literal['raster', 'fmv', 'geometry']
 SEARCH_PREDICATE_CHOICE = Literal[
     'contains',
     'crosses',
diff --git a/rgdc/rgdc/utils.py b/rgdc/rgdc/utils.py
index 3b7b61f55..a0603cafd 100644
--- a/rgdc/rgdc/utils.py
+++ b/rgdc/rgdc/utils.py
@@ -1,10 +1,15 @@
 from datetime import datetime
+import json
+from json.decoder import JSONDecodeError
 from pathlib import Path
-from typing import Dict, Generator, Iterator, List, Optional
+from typing import Dict, Generator, Iterator, List, Optional, Tuple, Union
 
+from geomet import wkt
 import requests
 from requests import Response, Session
 
+from .types import DATETIME_OR_STR_TUPLE, SEARCH_PREDICATE_CHOICE
+
 DEFAULT_RGD_API = 'https://www.resonantgeodata.com/api'
 
 
@@ -122,3 +127,52 @@ def download_checksum_file_to_path(file: Dict, path: Path, keep_existing: bool):
 def spatial_subentry_id(search_result):
     """Get the id of a returned SpatialEntry."""
     return search_result['subentry_pk']
+
+
+def spatial_search_params(
+    query: Optional[Union[Dict, str]] = None,
+    predicate: Optional[SEARCH_PREDICATE_CHOICE] = None,
+    relates: Optional[str] = None,
+    distance: Optional[Tuple[float, float]] = None,
+    acquired: Optional[DATETIME_OR_STR_TUPLE] = None,
+    instrumentation: Optional[str] = None,
+    limit: Optional[int] = None,
+    offset: Optional[int] = None,
+) -> Dict:
+    # The dict that will be used to store params.
+    # Initialize with queries that won't be additionally processed.
+    params = {
+        'predicate': predicate,
+        'relates': relates,
+        'instrumentation': instrumentation,
+        'limit': limit,
+        'offset': offset,
+    }
+
+    if query:
+        if isinstance(query, str):
+            try:
+                query = json.loads(query)
+            except JSONDecodeError:
+                pass
+
+        if isinstance(query, dict):
+            # Allow failure on invalid format
+            query = wkt.dumps(query)
+
+        params['q'] = query
+
+    # Process range params
+
+    if distance and len(distance) == 2:
+        dmin, dmax = distance
+        params['distance_min'] = dmin
+        params['distance_max'] = dmax
+
+    # TODO: Determine if the before/after param order needs to be swapped?
+    if acquired and len(acquired) == 2:
+        amin, amax = acquired
+        params['acquired_before'] = datetime_to_str(amax)
+        params['acquired_after'] = datetime_to_str(amin)
+
+    return params


Test patch:
diff --git a/rgd/geodata/tests/data_fixtures.py b/rgd/geodata/tests/data_fixtures.py
index e02a8399e..114f76c13 100644
--- a/rgd/geodata/tests/data_fixtures.py
+++ b/rgd/geodata/tests/data_fixtures.py
@@ -71,6 +71,22 @@ def sample_raster_b():
     return raster.rastermetaentry
 
 
+@pytest.fixture
+def sample_raster_c():
+    imagefile = factories.ImageFileFactory(
+        file__file__filename='RomanColosseum_WV2mulitband_10.tif',
+        file__file__from_path=datastore.fetch('RomanColosseum_WV2mulitband_10.tif'),
+    )
+    image_set = factories.ImageSetFactory(
+        images=[imagefile.imageentry.id],
+    )
+    raster = factories.RasterEntryFactory(
+        name='RomanColosseum_WV2mulitband_10.tif',
+        image_set=image_set,
+    )
+    return raster.rastermetaentry
+
+
 @pytest.fixture
 def sample_raster_multi():
     # These test files are dramatically downsampled for rapid testing
diff --git a/rgd/geodata/tests/test_filters.py b/rgd/geodata/tests/test_filters.py
index 226a513f0..812bcd7f1 100644
--- a/rgd/geodata/tests/test_filters.py
+++ b/rgd/geodata/tests/test_filters.py
@@ -1,7 +1,7 @@
 import pytest
 
 from rgd.geodata import models
-from rgd.geodata.filters import SpatialEntryFilter
+from rgd.geodata.filters import RasterMetaEntryFilter, SpatialEntryFilter
 
 
 @pytest.mark.django_db(transaction=True)
@@ -38,7 +38,21 @@ def test_raster_intersects(sample_raster_a, sample_raster_b):
         }
     )
     assert filterset.is_valid()
-    qs = filterset.filter_queryset(models.SpatialEntry.objects.all())
+    qs = filterset.filter_queryset(models.RasterMetaEntry.objects.all())
+    assert qs.count() == 1
+
+
+@pytest.mark.django_db(transaction=True)
+def test_raster_num_bands(sample_raster_b, sample_raster_c):
+    # b has many bands and c has 1 band
+    assert models.SpatialEntry.objects.count() == 2
+    filterset = RasterMetaEntryFilter(
+        data={
+            'num_bands_max': 2,
+        }
+    )
+    assert filterset.is_valid()
+    qs = filterset.filter_queryset(models.RasterMetaEntry.objects.all())
     assert qs.count() == 1
 
 
diff --git a/rgd/geodata/tests/test_stac.py b/rgd/geodata/tests/test_stac.py
new file mode 100644
index 000000000..6a1a13598
--- /dev/null
+++ b/rgd/geodata/tests/test_stac.py
@@ -0,0 +1,31 @@
+import pytest
+
+
+def _check_conforms_stac(data):
+    assert 'stac_version' in data
+    assert 'assets' in data
+    # TODO: make this better
+
+
+@pytest.mark.django_db(transaction=True)
+def test_raster_stac_serializer_simple(admin_api_client, sample_raster_a):
+    id = sample_raster_a.id
+    response = admin_api_client.get(f'/api/geodata/imagery/raster/{id}/stac')
+    assert response.status_code == 200
+    data = response.data
+    assert data
+    _check_conforms_stac(data)
+    assets = data['assets']
+    assert len(assets) == 1
+
+
+@pytest.mark.django_db(transaction=True)
+def test_raster_stac_serializer_multi_file_bands(admin_api_client, sample_raster_multi):
+    id = sample_raster_multi.id
+    response = admin_api_client.get(f'/api/geodata/imagery/raster/{id}/stac')
+    assert response.status_code == 200
+    data = response.data
+    assert data
+    _check_conforms_stac(data)
+    assets = data['assets']
+    assert len(assets) == 3