Repository: ResonantGeoData/ResonantGeoData (language: python)

Issue:
Make Geometry a non-required field in search form
We should be able to search against facets without the geometry.

e.g. find all data between 2 dates
DateTime field in HTML form not formatting properly
The DateTime field on the HTML search form is broken (at least for the acquisition field). I can search against the acquisition field just fine via the Python client, but the format of the DateTime from the search form is different and not returning any results.


Discussion / hints:

Further, I think we should diable/hide the modified and created DateTime fields in the HTML search form as they are rarely used and add a lot of clutter

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a77ee48f6..72531066f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,4 +1,4 @@
-name: ci
+name: RGD Module Tests
 on:
   pull_request:
   push:
@@ -7,6 +7,58 @@ on:
   schedule:
     - cron: "0 0 * * *"
 jobs:
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: "3.8"
+      - name: Install tox
+        run: |
+          pip install --upgrade pip
+          pip install tox
+      - name: Run linting
+        run: |
+          tox -e lint
+  check-migrations:
+    runs-on: ubuntu-latest
+    services:
+      postgres:
+        image: postgis/postgis:latest
+        env:
+          POSTGRES_DB: django
+          POSTGRES_PASSWORD: postgres
+        ports:
+          - 5432:5432
+      rabbitmq:
+        image: rabbitmq:management
+        ports:
+          - 5672:5672
+      minio:
+        # This image does not require any command arguments (which GitHub Actions don't support)
+        image: bitnami/minio:latest
+        env:
+          MINIO_ACCESS_KEY: minioAccessKey
+          MINIO_SECRET_KEY: minioSecretKey
+        ports:
+          - 9000:9000
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: "3.8"
+      - name: Install tox
+        run: |
+          pip install --upgrade pip
+          pip install tox
+      - name: Run check migrations
+        run: |
+          tox -e check-migrations
+        env:
+          MINIO_STORAGE_ENDPOINT: localhost:9000
   test:
     runs-on: ubuntu-latest
     services:
@@ -29,6 +81,10 @@ jobs:
           MINIO_SECRET_KEY: minioSecretKey
         ports:
           - 9000:9000
+    strategy:
+      fail-fast: false
+      matrix:
+        rgd-module: ['rgd', 'rgd-3d', 'rgd-fmv', 'rgd-geometry', 'rgd-imagery']
     steps:
       - uses: actions/checkout@v2
       - name: Set up Python
@@ -36,36 +92,23 @@ jobs:
         with:
           python-version: "3.8"
       - name: Install ffmpeg
+        if: ${{ matrix.rgd-module=='rgd-fmv' }}
         run: |
+          sudo apt update
           sudo apt-get install --no-install-recommends --yes ffmpeg
       - name: Install tox and codecov
         run: |
           pip install --upgrade pip
           pip install tox codecov
-      - name: Run linting
+      - name: Run RGD module tests
         run: |
-          tox -e lint
+          tox -e test-${{ matrix.rgd-module }}
         env:
-          DJANGO_DATABASE_URL: postgres://postgres:postgres@localhost:5432/django
-          DJANGO_MINIO_STORAGE_ENDPOINT: localhost:9000
-          DJANGO_MINIO_STORAGE_ACCESS_KEY: minioAccessKey
-          DJANGO_MINIO_STORAGE_SECRET_KEY: minioSecretKey
-      - name: Run check migrations
-        run: |
-          tox -e check-migrations
-        env:
-          DJANGO_DATABASE_URL: postgres://postgres:postgres@localhost:5432/django
-          DJANGO_MINIO_STORAGE_ENDPOINT: localhost:9000
-          DJANGO_MINIO_STORAGE_ACCESS_KEY: minioAccessKey
-          DJANGO_MINIO_STORAGE_SECRET_KEY: minioSecretKey
-      - name: Run tests
-        run: |
-          tox -e test
-        env:
-          DJANGO_DATABASE_URL: postgres://postgres:postgres@localhost:5432/django
-          DJANGO_MINIO_STORAGE_ENDPOINT: localhost:9000
-          DJANGO_MINIO_STORAGE_ACCESS_KEY: minioAccessKey
-          DJANGO_MINIO_STORAGE_SECRET_KEY: minioSecretKey
-      - name: Upload Coverage
-        run: |
-          codecov
+          MINIO_STORAGE_ENDPOINT: localhost:9000
+          MINIO_STORAGE_ACCESS_KEY: minioAccessKey
+          MINIO_STORAGE_SECRET_KEY: minioSecretKey
+          CELERY_BROKER_URL: amqp://localhost:5672/
+          DATABASE_HOST: localhost
+      # - name: Upload Coverage
+      #   run: |
+      #     codecov
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6c3760712..fc74fca65 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,12 +1,29 @@
-name: RGDC PyPI Workflow
+name: RGD Package Release
 on:
   push:
-    tags: "v*"
+    tags: "*"
   release:
     types: [released]
 jobs:
   publish:
     runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        rgd-module: ['django-rgd', 'django-rgd-3d', 'django-rgd-fmv', 'django-rgd-geometry', 'django-rgd-imagery', 'rgd-client']
+        include:
+          - rgd-module: django-rgd
+            TWINE_PASSWORD_VAR: PYPI_API_TOKEN_RGD
+          - rgd-module: django-rgd-3d
+            TWINE_PASSWORD_VAR: PYPI_API_TOKEN_RGD_3D
+          - rgd-module: django-rgd-fmv
+            TWINE_PASSWORD_VAR: PYPI_API_TOKEN_RGD_FMV
+          - rgd-module: django-rgd-geometry
+            TWINE_PASSWORD_VAR: PYPI_API_TOKEN_RGD_GEOMETRY
+          - rgd-module: django-rgd-imagery
+            TWINE_PASSWORD_VAR: PYPI_API_TOKEN_RGD_IMAGERY
+          - rgd-module: rgd-client
+            TWINE_PASSWORD_VAR: PYPI_API_TOKEN_RGD_CLIENT
     steps:
       - uses: actions/checkout@v2
       - name: Set up Python
@@ -20,10 +37,10 @@ jobs:
           pip install setuptools wheel twine
 
       - name: Build and Publish to PyPI
-        working-directory: ./rgdc
+        working-directory: ${{ matrix.rgd-module }}
         env:
           TWINE_USERNAME: __token__
-          TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
+          TWINE_PASSWORD: ${{ secrets[matrix.TWINE_PASSWORD_VAR] }}
         run: |
           python setup.py sdist bdist_wheel
           twine upload --skip-existing dist/*
diff --git a/.github/workflows/rgdc.yml b/.github/workflows/rgd-client.yml
similarity index 50%
rename from .github/workflows/rgdc.yml
rename to .github/workflows/rgd-client.yml
index 21b6dc0b2..adec6aa96 100644
--- a/.github/workflows/rgdc.yml
+++ b/.github/workflows/rgd-client.yml
@@ -1,4 +1,4 @@
-name: RGDC Tests
+name: RGD Client Tests
 on:
   push:
     branches: "*"
@@ -16,10 +16,14 @@ jobs:
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
-          pip install setuptools wheel
-      - name: Install rgdc
-        working-directory: ./rgdc
+          pip install setuptools pytest
+      - name: Install rgd-client
+        working-directory: ./rgd-client
         run: pip install -e .
-      - name: Test (not implemented)
-        working-directory: ./rgdc
-        run: python -c "from rgdc import Rgdc; print('Import went okay!')"
+      - name: Test import
+        working-directory: ./rgd-client
+        run: |
+          python -c "from rgd_client import Rgdc; print('Import went okay!')"
+      # - name: Test
+      #   working-directory: ./rgd-client
+      #   run: pytest . -v
diff --git a/Procfile b/Procfile
deleted file mode 100644
index 8856dbaf4..000000000
--- a/Procfile
+++ /dev/null
@@ -1,4 +0,0 @@
-release: ./manage.py migrate
-web: gunicorn --bind 0.0.0.0:$PORT rgd.wsgi
-worker: REMAP_SIGTERM=SIGQUIT celery --app rgd.celery worker --loglevel INFO --without-heartbeat
-flower: flower --broker=$CLOUDAMQP_URL
diff --git a/README.md b/README.md
index 35fbde485..4fcb681a6 100644
--- a/README.md
+++ b/README.md
@@ -3,9 +3,9 @@
 [![codecov](https://codecov.io/gh/ResonantGeoData/ResonantGeoData/branch/master/graph/badge.svg)](https://codecov.io/gh/ResonantGeoData/ResonantGeoData)
 [![ci](https://github.com/ResonantGeoData/ResonantGeoData/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/ResonantGeoData/ResonantGeoData/actions/workflows/ci.yml)
 
-2D/3D/4D Geospatial Data API and Machine Learning System for Evaluation
+2D/3D/4D Geospatial Data API in Django
 
-A publically deployed instance of this application is available at https://www.resonantgeodata.com
+A publically deployed instance of this application is available at https://www.resonantgeodata.com - find the project code for this at [ResonantGeoData/RD-OpenGeo](https://github.com/ResonantGeoData/RD-OpenGeo)
 
 ResonantGeoData is a Django application well suited for catalogging and searching annotated geospatial imagery, shapefiles, and full motion video datasets.
 
@@ -14,7 +14,7 @@ ResonantGeoData is a Django application well suited for catalogging and searchin
 ## Highlights
 
 - Faceted searching of geospatial/temporal data
-- Support for the spatial context of full motion video datasets
+- Support for the spatial context of Full Motion Video (FMV) datasets
 - Support for annotated imagery: polygons, bounding boxes, keypoints, and run-length-encoded masks
 
 
@@ -24,7 +24,7 @@ Documentation is currently a work in progress. We have created some initial usag
 
 Specifically, we have included [instructions on how to ingest data](https://github.com/ResonantGeoData/ResonantGeoData/blob/master/docs/ingest-data.md) into a deployed ResonantGeoData instance.
 
-For general questions about the project, its applications, or about software usage, please create an issue directly in this repository. You are also welcome to send us an email at kitware@kitware.com with the subject line including ResonantGeoData.
+For general questions about the project, its applications, or about software usage, please create an issue directly in this repository. You are also welcome to send us an email at [rgd@kitware.com](mailto:rgd@kitware.com) with the subject line including ResonantGeoData.
 
 ## Connections
 
diff --git a/demos/.gitignore b/demos/.gitignore
deleted file mode 100644
index 87620ac7e..000000000
--- a/demos/.gitignore
+++ /dev/null
@@ -1,1 +0,0 @@
-.ipynb_checkpoints/
diff --git a/demos/GeoData-API.ipynb b/demos/GeoData-API.ipynb
deleted file mode 100644
index cc5c18675..000000000
--- a/demos/GeoData-API.ipynb
+++ /dev/null
@@ -1,214 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "from io import BytesIO\n",
-    "import imageio\n",
-    "import rasterio\n",
-    "from rasterio.io import MemoryFile\n",
-    "from rasterio.plot import show\n",
-    "import matplotlib.pyplot as plt\n",
-    "import numpy as np\n",
-    "import json\n",
-    "import os"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def plot_geojson(gjs, *args, **kwargs):\n",
-    "    points = np.array(gjs['coordinates'][0])\n",
-    "    return plt.plot(points[:,0], points[:,1], *args, **kwargs)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "from rgdc import Rgdc\n",
-    "\n",
-    "client = Rgdc(username='username', password='password')"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Search"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "bbox = {\"type\":\"Polygon\",\n",
-    "        \"coordinates\":[[[-105.45091240368326,39.626245373878696],\n",
-    "                                         [-105.45091240368326,39.929904289147274],\n",
-    "                                         [-104.88775649170178,39.929904289147274],\n",
-    "                                         [-104.88775649170178,39.626245373878696],\n",
-    "                                         [-105.45091240368326,39.626245373878696]]]}\n",
-    "\n",
-    "q = client.search(query=json.dumps(bbox), predicate='intersects', datatype='raster')\n",
-    "len(q)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {
-    "scrolled": true
-   },
-   "outputs": [],
-   "source": [
-    "for s in q:\n",
-    "    print(s['subentry_name'])"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "plot_geojson(bbox, 'k--', label='Search Region')\n",
-    "for s in q:\n",
-    "    plot_geojson(s['footprint'], label=s['subentry_name'])\n",
-    "plt.legend()\n",
-    "plt.title(f'Count: {len(q)}')"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Inspect Raster"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "raster = client.session.get(q[0]['detail']).json()\n",
-    "raster"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "plot_geojson(bbox, 'k--')\n",
-    "plot_geojson(raster['outline'], 'r')"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "count = len(raster['parent_raster']['image_set']['images'])\n",
-    "count"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "load_image = lambda imbytes: imageio.imread(BytesIO(imbytes))\n",
-    "\n",
-    "for i in range(count):\n",
-    "    thumb_bytes = client.download_raster_entry_thumbnail(raster['id'], band=i)\n",
-    "    thumb = load_image(thumb_bytes)\n",
-    "    plt.subplot(1, count, i+1)\n",
-    "    plt.imshow(thumb)\n",
-    "plt.tight_layout()\n",
-    "plt.show()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Download Raster"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "paths = client.download_raster_entry(raster['id'])\n",
-    "paths"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "rasters = [rasterio.open(im) for im in paths['images']]"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "for i, src in enumerate(rasters):\n",
-    "    plt.subplot(1, count, i+1)\n",
-    "    ax = plt.gca()\n",
-    "    show(src, ax=ax)\n",
-    "plt.tight_layout()\n",
-    "plt.show()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": []
-  }
- ],
- "metadata": {
-  "kernelspec": {
-   "display_name": "Python [conda env:django] *",
-   "language": "python",
-   "name": "conda-env-django-py"
-  },
-  "language_info": {
-   "codemirror_mode": {
-    "name": "ipython",
-    "version": 3
-   },
-   "file_extension": ".py",
-   "mimetype": "text/x-python",
-   "name": "python",
-   "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.8.3"
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 4
-}
diff --git a/dev/.env.docker-compose b/dev/.env.docker-compose
index 2eddc3634..d4d8c33c4 100644
--- a/dev/.env.docker-compose
+++ b/dev/.env.docker-compose
@@ -1,8 +1,7 @@
-DJANGO_CONFIGURATION=DevelopmentConfiguration
-DJANGO_DATABASE_URL=postgres://postgres:postgres@postgres:5432/django
-DJANGO_CELERY_BROKER_URL=amqp://rabbitmq:5672/
-DJANGO_MINIO_STORAGE_ENDPOINT=minio:9000
-DJANGO_MINIO_STORAGE_ACCESS_KEY=minioAccessKey
-DJANGO_MINIO_STORAGE_SECRET_KEY=minioSecretKey
-DJANGO_STORAGE_BUCKET_NAME=django-storage
-DJANGO_MINIO_STORAGE_MEDIA_URL=http://localhost:9000/django-storage
+DATABASE_URL=postgres://postgres:postgres@postgres:5432/django
+CELERY_BROKER_URL=amqp://rabbitmq:5672/
+MINIO_STORAGE_ENDPOINT=minio:9000
+MINIO_STORAGE_ACCESS_KEY=minioAccessKey
+MINIO_STORAGE_SECRET_KEY=minioSecretKey
+STORAGE_BUCKET_NAME=django-storage
+MINIO_STORAGE_MEDIA_URL=http://localhost:9000/django-storage
diff --git a/dev/.env.docker-compose-native b/dev/.env.docker-compose-native
deleted file mode 100644
index 7c57700a2..000000000
--- a/dev/.env.docker-compose-native
+++ /dev/null
@@ -1,7 +0,0 @@
-DJANGO_CONFIGURATION=DevelopmentConfiguration
-DJANGO_DATABASE_URL=postgres://postgres:postgres@localhost:5432/django
-DJANGO_CELERY_BROKER_URL=amqp://localhost:5672/
-DJANGO_MINIO_STORAGE_ENDPOINT=localhost:9000
-DJANGO_MINIO_STORAGE_ACCESS_KEY=minioAccessKey
-DJANGO_MINIO_STORAGE_SECRET_KEY=minioSecretKey
-DJANGO_STORAGE_BUCKET_NAME=django-storage
diff --git a/dev/README.md b/dev/README.md
new file mode 100644
index 000000000..4eae98045
--- /dev/null
+++ b/dev/README.md
@@ -0,0 +1,8 @@
+# Published Images
+
+```
+docker build -t rgd_django -f dev/django-pub.Dockerfile .
+docker build -t rgd_celery -f dev/celery-pub.Dockerfile .
+```
+
+Use these images when doing local development of RD-* projects
diff --git a/dev/celery.Dockerfile b/dev/celery.Dockerfile
index c466e7a45..11731435d 100644
--- a/dev/celery.Dockerfile
+++ b/dev/celery.Dockerfile
@@ -8,6 +8,9 @@ RUN apt-get update && \
         gcc \
         libc6-dev \
         libmagic1 \
+        graphviz \
+        libgraphviz-dev \
+        pkg-config \
         libgl1-mesa-glx \
         libglib2.0-0 \
         ffmpeg \
@@ -18,16 +21,35 @@ RUN apt-get update && \
 ENV PYTHONDONTWRITEBYTECODE 1
 ENV PYTHONUNBUFFERED 1
 
+COPY ./dev/fuse.sh /opt/django-project-services/fuse.sh
+
 # Only copy the setup.py, it will still force all install_requires to be installed,
 # but find_packages() will find nothing (which is fine). When Docker Compose mounts the real source
 # over top of this directory, the .egg-link in site-packages resolves to the mounted directory
 # and all package modules are importable.
-COPY ./setup.py /opt/django-project/setup.py
-COPY ./fuse.sh /opt/django-project/fuse.sh
+COPY ./version.py /opt/django-project/
+COPY ./django-rgd/setup.py /opt/django-project/django-rgd/
+COPY ./django-rgd-3d/setup.py /opt/django-project/django-rgd-3d/
+COPY ./django-rgd-fmv/setup.py /opt/django-project/django-rgd-fmv/
+COPY ./django-rgd-geometry/setup.py /opt/django-project/django-rgd-geometry/
+COPY ./django-rgd-imagery/setup.py /opt/django-project/django-rgd-imagery/
+COPY ./example_project/setup.py /opt/django-project/example_project/
+COPY ./testing-utils/setup.py /opt/django-project/testing-utils/
 # Use a directory name which will never be an import name, as isort considers this as first-party.
 WORKDIR /opt/django-project
 RUN pip install \
     --find-links https://girder.github.io/large_image_wheels \
-    -e .[dev,worker,fuse,fmv]
+    -e ./django-rgd[fuse] \
+    -e ./testing-utils \
+    -e ./example_project
+RUN pip install \
+    --find-links https://girder.github.io/large_image_wheels \
+    -e ./django-rgd-3d[worker] \
+    -e ./django-rgd-fmv[worker] \
+    -e ./django-rgd-geometry[worker] \
+    -e ./django-rgd-imagery[worker] \
+    ipython \
+    tox
 
-ENTRYPOINT ["/opt/django-project/fuse.sh"]
+WORKDIR /opt/django-project/
+ENTRYPOINT ["/opt/django-project-services/fuse.sh"]
diff --git a/dev/django.Dockerfile b/dev/django.Dockerfile
index 76372bbb1..3938915e6 100644
--- a/dev/django.Dockerfile
+++ b/dev/django.Dockerfile
@@ -8,6 +8,9 @@ RUN apt-get update && \
         gcc \
         libc6-dev \
         libmagic1 \
+        graphviz \
+        libgraphviz-dev \
+        pkg-config \
         && \
     rm -rf /var/lib/apt/lists/*
 
@@ -18,9 +21,28 @@ ENV PYTHONUNBUFFERED 1
 # but find_packages() will find nothing (which is fine). When Docker Compose mounts the real source
 # over top of this directory, the .egg-link in site-packages resolves to the mounted directory
 # and all package modules are importable.
-COPY ./setup.py /opt/django-project/setup.py
+COPY ./version.py /opt/django-project/
+COPY ./django-rgd/setup.py /opt/django-project/django-rgd/
+COPY ./django-rgd-3d/setup.py /opt/django-project/django-rgd-3d/
+COPY ./django-rgd-fmv/setup.py /opt/django-project/django-rgd-fmv/
+COPY ./django-rgd-geometry/setup.py /opt/django-project/django-rgd-geometry/
+COPY ./django-rgd-imagery/setup.py /opt/django-project/django-rgd-imagery/
+COPY ./example_project/setup.py /opt/django-project/example_project/
+COPY ./testing-utils/setup.py /opt/django-project/testing-utils/
 # Use a directory name which will never be an import name, as isort considers this as first-party.
 WORKDIR /opt/django-project
 RUN pip install \
     --find-links https://girder.github.io/large_image_wheels \
-    -e .[dev]
+    -e ./django-rgd \
+    -e ./testing-utils \
+    -e ./example_project[graph]
+RUN pip install \
+    --find-links https://girder.github.io/large_image_wheels \
+    -e ./django-rgd-3d \
+    -e ./django-rgd-fmv \
+    -e ./django-rgd-geometry \
+    -e ./django-rgd-imagery \
+    ipython \
+    tox
+
+WORKDIR /opt/django-project/
diff --git a/dev/export-env.sh b/dev/export-env.sh
deleted file mode 100644
index ee0e3b0e3..000000000
--- a/dev/export-env.sh
+++ /dev/null
@@ -1,29 +0,0 @@
-# Export environment variables from the .env file in the first argument.
-# If no argument is given, default to "dev/.env.docker-compose-native".
-# This file must be sourced, not run.
-
-if [ -n "$1" ]; then
-  # If an argument was provided, use it as the .env file
-  _dotenv_file="$1"
-else
-  # Otherwise, use the default .env file
-  if [ -n "$ZSH_VERSION" ]; then
-    # ZSH has a different way to get the directory of the current script
-    _dotenv_dir="$0:A:h"
-  else
-    # Assume this is Bash
-    _dotenv_dir="$( dirname "${BASH_SOURCE[0]}" )"
-  fi
-  _dotenv_file="${_dotenv_dir}/.env.docker-compose-native"
-fi
-
-# Export all assignments in the $_dotenv_file
-# Using "set -a" allows .env files with spaces or comments to work seamlessly
-# https://stackoverflow.com/a/45971167
-set -a
-. "$_dotenv_file"
-set +a
-
-# Clean up, since sourcing this leaks any shell variables
-unset _dotenv_dir
-unset _dotenv_file
diff --git a/fuse.sh b/dev/fuse.sh
similarity index 100%
rename from fuse.sh
rename to dev/fuse.sh
diff --git a/django-rgd-3d/MANIFEST.in b/django-rgd-3d/MANIFEST.in
new file mode 100644
index 000000000..aa6a97754
--- /dev/null
+++ b/django-rgd-3d/MANIFEST.in
@@ -0,0 +1,2 @@
+# Include any data files such as templates or static assets in the source distribution
+graft rgd_3d
diff --git a/django-rgd-3d/README.md b/django-rgd-3d/README.md
new file mode 100644
index 000000000..829f9348f
--- /dev/null
+++ b/django-rgd-3d/README.md
@@ -0,0 +1,3 @@
+# ResonantGeoData 3D
+
+A submodule of ResonantGeoData for storing 3D data with an optional spatial reference.
diff --git a/django-rgd-3d/rgd_3d/__init__.py b/django-rgd-3d/rgd_3d/__init__.py
new file mode 100644
index 000000000..21f306854
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/__init__.py
@@ -0,0 +1,7 @@
+from pkg_resources import DistributionNotFound, get_distribution
+
+try:
+    __version__ = get_distribution('django-rgd-3d').version
+except DistributionNotFound:
+    # package is not installed
+    __version__ = None
diff --git a/django-rgd-3d/rgd_3d/admin/__init__.py b/django-rgd-3d/rgd_3d/admin/__init__.py
new file mode 100644
index 000000000..fd6239c60
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/admin/__init__.py
@@ -0,0 +1,1 @@
+from .point_cloud import *  # noqa
diff --git a/django-rgd-3d/rgd_3d/admin/point_cloud.py b/django-rgd-3d/rgd_3d/admin/point_cloud.py
new file mode 100644
index 000000000..e1d811ec5
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/admin/point_cloud.py
@@ -0,0 +1,60 @@
+from django.contrib import admin
+from django.contrib.gis.admin import OSMGeoAdmin
+from rgd.admin.mixins import (
+    MODIFIABLE_FILTERS,
+    TASK_EVENT_FILTERS,
+    TASK_EVENT_READONLY,
+    _FileGetNameMixin,
+    reprocess,
+)
+from rgd_3d.models import PointCloud, PointCloudMeta, PointCloudSpatial
+
+
+@admin.register(PointCloud)
+class PointCloudAdmin(OSMGeoAdmin, _FileGetNameMixin):
+    list_display = (
+        'pk',
+        'get_name',
+        'status',
+        'modified',
+        'created',
+        'data_link',
+    )
+    readonly_fields = (
+        'modified',
+        'created',
+    ) + TASK_EVENT_READONLY
+    actions = (reprocess,)
+    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
+
+
+@admin.register(PointCloudSpatial)
+class PointCloudSpatialAdmin(OSMGeoAdmin):
+    list_display = (
+        'pk',
+        'modified',
+        'created',
+        'crs',
+    )
+    readonly_fields = (
+        'modified',
+        'created',
+        'source',
+    )
+    modifiable = False  # To still show the footprint and outline
+
+
+@admin.register(PointCloudMeta)
+class PointCloudMetaAdmin(OSMGeoAdmin):
+    list_display = (
+        'pk',
+        'modified',
+        'created',
+        'data_link',
+    )
+    readonly_fields = (
+        'source',
+        'modified',
+        'created',
+    )
+    list_filter = MODIFIABLE_FILTERS
diff --git a/django-rgd-3d/rgd_3d/apps.py b/django-rgd-3d/rgd_3d/apps.py
new file mode 100644
index 000000000..f80ce246a
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/apps.py
@@ -0,0 +1,9 @@
+from django.apps import AppConfig
+
+
+class RGD3DConfig(AppConfig):
+    default_auto_field = 'django.db.models.AutoField'
+    name = 'rgd_3d'
+
+    def ready(self):
+        import rgd_3d.signals  # noqa: F401
diff --git a/django-rgd-3d/rgd_3d/management/commands/rgd_3d_demo.py b/django-rgd-3d/rgd_3d/management/commands/rgd_3d_demo.py
new file mode 100644
index 000000000..2fc73ee9a
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/management/commands/rgd_3d_demo.py
@@ -0,0 +1,35 @@
+import logging
+
+from rgd.management.commands._data_helper import SynchronousTasksCommand, _get_or_create_file_model
+from rgd_3d import models
+
+logger = logging.getLogger(__name__)
+
+
+SUCCESS_MSG = 'Finished loading all demo data.'
+
+# Names of files in the datastore
+POINT_CLOUD_FILES = [
+    'topo.vtk',
+]
+
+
+def load_point_cloud_files(pc_files):
+    ids = []
+    for f in pc_files:
+        entry = _get_or_create_file_model(models.PointCloud, f)
+        ids.append(entry.pk)
+    return ids
+
+
+class Command(SynchronousTasksCommand):
+    help = 'Populate database with demo data.'
+
+    def handle(self, *args, **options):
+
+        self.set_synchronous()
+        # Run the command
+        load_point_cloud_files(POINT_CLOUD_FILES)
+
+        self.stdout.write(self.style.SUCCESS(SUCCESS_MSG))
+        self.reset_celery()
diff --git a/django-rgd-3d/rgd_3d/migrations/0001_initial.py b/django-rgd-3d/rgd_3d/migrations/0001_initial.py
new file mode 100644
index 000000000..6e70a008d
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/migrations/0001_initial.py
@@ -0,0 +1,167 @@
+# Generated by Django 3.2.4 on 2021-06-24 22:52
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+import django.db.models.deletion
+import django_extensions.db.fields
+import rgd.models.mixins
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('rgd', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PointCloud',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                ('failure_reason', models.TextField(null=True)),
+                (
+                    'status',
+                    models.CharField(
+                        choices=[
+                            ('created', 'Created but not queued'),
+                            ('queued', 'Queued for processing'),
+                            ('running', 'Processing'),
+                            ('failed', 'Failed'),
+                            ('success', 'Succeeded'),
+                        ],
+                        default='created',
+                        max_length=20,
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                (
+                    'file',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='+',
+                        to='rgd.checksumfile',
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='PointCloudSpatial',
+            fields=[
+                (
+                    'spatialentry_ptr',
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to='rgd.spatialentry',
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('crs', models.TextField(blank=True, help_text='PROJ string', null=True)),
+                (
+                    'origin',
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.FloatField(), blank=True, null=True, size=3
+                    ),
+                ),
+                (
+                    'source',
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.CASCADE, to='rgd_3d.pointcloud'
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=('rgd.spatialentry', models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='PointCloudMeta',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('name', models.CharField(blank=True, max_length=1000)),
+                ('description', models.TextField(blank=True, null=True)),
+                (
+                    'source',
+                    models.OneToOneField(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='rgd_3d.pointcloud',
+                    ),
+                ),
+                (
+                    'vtp_data',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.DO_NOTHING,
+                        related_name='+',
+                        to='rgd.checksumfile',
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(
+                models.Model,
+                rgd.models.mixins.PermissionPathMixin,
+                rgd.models.mixins.DetailViewMixin,
+            ),
+        ),
+    ]
diff --git a/rgd/geodata/__init__.py b/django-rgd-3d/rgd_3d/migrations/__init__.py
similarity index 100%
rename from rgd/geodata/__init__.py
rename to django-rgd-3d/rgd_3d/migrations/__init__.py
diff --git a/django-rgd-3d/rgd_3d/models/__init__.py b/django-rgd-3d/rgd_3d/models/__init__.py
new file mode 100644
index 000000000..01b60af23
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/models/__init__.py
@@ -0,0 +1,1 @@
+from .point_cloud import PointCloud, PointCloudMeta, PointCloudSpatial  # noqa
diff --git a/django-rgd-3d/rgd_3d/models/point_cloud.py b/django-rgd-3d/rgd_3d/models/point_cloud.py
new file mode 100644
index 000000000..f17ec796c
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/models/point_cloud.py
@@ -0,0 +1,66 @@
+from django.contrib.gis.db import models
+from django.contrib.postgres import fields
+from django_extensions.db.models import TimeStampedModel
+from rgd.models import ChecksumFile, SpatialEntry
+from rgd.models.mixins import DetailViewMixin, PermissionPathMixin, TaskEventMixin
+from rgd_3d.tasks import jobs
+
+
+class PointCloud(TimeStampedModel, TaskEventMixin, PermissionPathMixin):
+    """Container for point cloud file."""
+
+    task_funcs = (jobs.task_read_point_cloud_file,)
+    file = models.ForeignKey(ChecksumFile, on_delete=models.CASCADE, related_name='+')
+
+    def data_link(self):
+        return self.file.data_link()
+
+    data_link.allow_tags = True
+
+    permissions_paths = ['file__collection__collection_permissions']
+
+
+class PointCloudMeta(TimeStampedModel, PermissionPathMixin, DetailViewMixin):
+    """Container for converted point cloud data.
+
+    The data here must be stored in VTP format. This can be manually uploaded
+    or created automatically via a PointCloud upload.
+    """
+
+    name = models.CharField(max_length=1000, blank=True)
+    description = models.TextField(null=True, blank=True)
+
+    # Can be null if not generated from uploaded file
+    source = models.OneToOneField(PointCloud, null=True, blank=True, on_delete=models.CASCADE)
+
+    # A place to store converted file - must be in VTP format
+    vtp_data = models.ForeignKey(ChecksumFile, on_delete=models.DO_NOTHING, related_name='+')
+
+    def data_link(self):
+        return self.vtp_data.data_link()
+
+    data_link.allow_tags = True
+
+    permissions_paths = [
+        'source__file__collection__collection_permissions',
+        'vtp_data__collection__collection_permissions',
+    ]
+    detail_view_name = 'point-cloud-entry-detail'
+
+
+class PointCloudSpatial(TimeStampedModel, SpatialEntry, PermissionPathMixin):
+    """Optionally register a PointCloudMeta as a SpatialEntry."""
+
+    source = models.OneToOneField(PointCloud, on_delete=models.CASCADE)
+
+    crs = models.TextField(help_text='PROJ string', blank=True, null=True)  # PROJ String
+    # Origin point to map the 0,0,0 point of the point cloud
+    origin = fields.ArrayField(models.FloatField(), size=3, blank=True, null=True)
+
+    @property
+    def name(self):
+        return self.source.file.name
+
+    permissions_paths = [
+        'source__file__collection__collection_permissions',
+    ]
diff --git a/django-rgd-3d/rgd_3d/rest/__init__.py b/django-rgd-3d/rgd_3d/rest/__init__.py
new file mode 100644
index 000000000..dea997230
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/rest/__init__.py
@@ -0,0 +1,1 @@
+from . import get  # noqa
diff --git a/django-rgd-3d/rgd_3d/rest/get.py b/django-rgd-3d/rgd_3d/rest/get.py
new file mode 100644
index 000000000..e7da26e25
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/rest/get.py
@@ -0,0 +1,15 @@
+from rest_framework.generics import RetrieveAPIView
+from rgd.rest.get import _PermissionMixin
+from rgd_3d import models, serializers
+
+
+class GetPointCloudMeta(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.PointCloudMetaSerializer
+    lookup_field = 'pk'
+    queryset = models.PointCloudMeta.objects.all()
+
+
+class GetPointCloudMetaData(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.PointCloudMetaDataSerializer
+    lookup_field = 'pk'
+    queryset = models.PointCloudMeta.objects.all()
diff --git a/django-rgd-3d/rgd_3d/serializers.py b/django-rgd-3d/rgd_3d/serializers.py
new file mode 100644
index 000000000..8fbf40972
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/serializers.py
@@ -0,0 +1,43 @@
+import base64
+
+from rest_framework import serializers
+from rgd import utility
+from rgd.serializers import ChecksumFileSerializer
+from rgd_3d import models
+
+
+class PointCloudSerializer(serializers.ModelSerializer):
+    file = ChecksumFileSerializer()
+
+    class Meta:
+        model = models.PointCloud
+        fields = '__all__'
+
+
+class PointCloudMetaSerializer(serializers.ModelSerializer):
+    source = PointCloudSerializer()
+    vtp_data = ChecksumFileSerializer()
+
+    def to_representation(self, value):
+        ret = super().to_representation(value)
+        return ret
+
+    class Meta:
+        model = models.PointCloudMeta
+        fields = '__all__'
+
+
+class PointCloudMetaDataSerializer(PointCloudMetaSerializer):
+    def to_representation(self, value):
+        ret = super().to_representation(value)
+        # Extract data as base64
+        with value.vtp_data.yield_local_path() as path:
+            with open(path, 'rb') as data:
+                data_content = data.read()
+                base64_content = base64.b64encode(data_content)
+                base64_content = base64_content.decode().replace('\n', '')
+        ret['vtp_data'] = base64_content
+        return ret
+
+
+utility.make_serializers(globals(), models)
diff --git a/django-rgd-3d/rgd_3d/signals.py b/django-rgd-3d/rgd_3d/signals.py
new file mode 100644
index 000000000..e7f98e311
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/signals.py
@@ -0,0 +1,11 @@
+from django.db import transaction
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from rgd.utility import skip_signal
+from rgd_3d import models
+
+
+@receiver(post_save, sender=models.PointCloud)
+@skip_signal()
+def _post_save_point_cloud_file(sender, instance, *args, **kwargs):
+    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
diff --git a/django-rgd-3d/rgd_3d/tasks/__init__.py b/django-rgd-3d/rgd_3d/tasks/__init__.py
new file mode 100644
index 000000000..aa31b2404
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/tasks/__init__.py
@@ -0,0 +1,1 @@
+from .jobs import *  # noqa
diff --git a/rgd/geodata/models/threed/etl.py b/django-rgd-3d/rgd_3d/tasks/etl.py
similarity index 86%
rename from rgd/geodata/models/threed/etl.py
rename to django-rgd-3d/rgd_3d/tasks/etl.py
index 336264b86..059f20e30 100644
--- a/rgd/geodata/models/threed/etl.py
+++ b/django-rgd-3d/rgd_3d/tasks/etl.py
@@ -3,11 +3,9 @@
 
 from celery.utils.log import get_task_logger
 from django.conf import settings
-
-from rgd.geodata.models import ChecksumFile
+from rgd.models import ChecksumFile
 from rgd.utility import get_or_create_no_commit
-
-from .point_cloud import PointCloudEntry, PointCloudFile
+from rgd_3d.models import PointCloud, PointCloudMeta
 
 logger = get_task_logger(__name__)
 
@@ -58,10 +56,10 @@ def _get_readers():
 
 
 def read_point_cloud_file(pc_file):
-    """Read a PointCloudFile object and create a new PointCloudEntry."""
-    if not isinstance(pc_file, PointCloudFile):
-        pc_file = PointCloudFile.objects.get(id=pc_file)
-    pc_entry, _ = get_or_create_no_commit(PointCloudEntry, source=pc_file)
+    """Read a PointCloud object and create a new PointCloudMeta."""
+    if not isinstance(pc_file, PointCloud):
+        pc_file = PointCloud.objects.get(id=pc_file)
+    pc_entry, _ = get_or_create_no_commit(PointCloudMeta, source=pc_file)
     pc_entry.name = pc_file.file.name
     # Parse the point cloud file format and convert
     ext = pc_file.file.name.split('.')[-1].strip().lower()
diff --git a/django-rgd-3d/rgd_3d/tasks/jobs.py b/django-rgd-3d/rgd_3d/tasks/jobs.py
new file mode 100644
index 000000000..528b63e2a
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/tasks/jobs.py
@@ -0,0 +1,12 @@
+from celery import shared_task
+from rgd.tasks import helpers
+
+
+@shared_task(time_limit=86400)
+def task_read_point_cloud_file(pc_file_id):
+    from rgd_3d.models import PointCloud
+
+    from .etl import read_point_cloud_file
+
+    pc_file = PointCloud.objects.get(id=pc_file_id)
+    helpers._run_with_failure_reason(pc_file, read_point_cloud_file, pc_file_id)
diff --git a/rgd/geodata/templates/geodata/_includes/vtkjs_viewer.html b/django-rgd-3d/rgd_3d/templates/rgd_3d/_include/vtkjs_viewer.html
similarity index 100%
rename from rgd/geodata/templates/geodata/_includes/vtkjs_viewer.html
rename to django-rgd-3d/rgd_3d/templates/rgd_3d/_include/vtkjs_viewer.html
diff --git a/rgd/geodata/templates/geodata/pointcloudentry_detail.html b/django-rgd-3d/rgd_3d/templates/rgd_3d/pointcloudmeta_detail.html
similarity index 82%
rename from rgd/geodata/templates/geodata/pointcloudentry_detail.html
rename to django-rgd-3d/rgd_3d/templates/rgd_3d/pointcloudmeta_detail.html
index e8416f1f8..85f407f04 100644
--- a/rgd/geodata/templates/geodata/pointcloudentry_detail.html
+++ b/django-rgd-3d/rgd_3d/templates/rgd_3d/pointcloudmeta_detail.html
@@ -1,4 +1,4 @@
-{% extends "geodata/_base/detail.html" %}
+{% extends "rgd/_base/detail.html" %}
 
 {% block meta_table_body %}
   <tr>
@@ -14,18 +14,18 @@
     <td>{{ object.source.data_link }}</td>
   </tr>
 
-  {% if object.pointcloudmetaentry %}
+  {% if object.pointcloudspatial %}
     <tr>
       <td>Acquisition Date</td>
-      <td>{{ object.pointcloudmetaentry.acquisition_date }}</td>
+      <td>{{ object.pointcloudspatial.acquisition_date }}</td>
     </tr>
     <tr>
       <td>Area</td>
-      <td>{{ object.pointcloudmetaentry.footprint.area | floatformat:-2 }}</td>
+      <td>{{ object.pointcloudspatial.footprint.area | floatformat:-2 }}</td>
     </tr>
     <tr>
       <td>Instrumentation</td>
-      <td>{{ object.pointcloudmetaentry.instrumentation }}</td>
+      <td>{{ object.pointcloudspatial.instrumentation }}</td>
     </tr>
   {% endif %}
 
@@ -37,7 +37,7 @@
 
 {% block detail %}
 
-    {% include 'geodata/_includes/vtkjs_viewer.html' %}
+    {% include 'rgd_3d/_include/vtkjs_viewer.html' %}
 
     <style>
     .progress {
@@ -70,7 +70,7 @@ <h1>Loading...</h1>
       const progressContainer = document.getElementById('progressContainer');
       progressContainer.innerHTML = 'Loading...';
 
-      fetch(`${host}/api/geodata/point_cloud/{{ object.id }}/base64`)
+      fetch(`{% url 'point-cloud-entry-data' object.id %}`)
         .then((resp) => resp.json())
         .then(function(data) {
           const contentToLoad = data.vtp_data;
diff --git a/django-rgd-3d/rgd_3d/urls.py b/django-rgd-3d/rgd_3d/urls.py
new file mode 100644
index 000000000..19835d450
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/urls.py
@@ -0,0 +1,23 @@
+from django.urls import path
+from rgd_3d import models, rest, views
+
+urlpatterns = [
+    # Pages
+    path(
+        'rgd_3d/point_cloud/<int:pk>/',
+        views.PointCloudMetaDetailView.as_view(),
+        name=models.PointCloudMeta.detail_view_name,
+    ),
+    #############
+    # Other
+    path(
+        'api/rgd_3d/point_cloud/<int:pk>',
+        rest.get.GetPointCloudMeta.as_view(),
+        name='point-cloud-entry',
+    ),
+    path(
+        'api/rgd_3d/point_cloud/<int:pk>/base64',
+        rest.get.GetPointCloudMetaData.as_view(),
+        name='point-cloud-entry-data',
+    ),
+]
diff --git a/django-rgd-3d/rgd_3d/views.py b/django-rgd-3d/rgd_3d/views.py
new file mode 100644
index 000000000..2f28e1e93
--- /dev/null
+++ b/django-rgd-3d/rgd_3d/views.py
@@ -0,0 +1,6 @@
+from rgd.views import PermissionDetailView
+from rgd_3d import models
+
+
+class PointCloudMetaDetailView(PermissionDetailView):
+    model = models.PointCloudMeta
diff --git a/django-rgd-3d/setup.py b/django-rgd-3d/setup.py
new file mode 100644
index 000000000..ddd896895
--- /dev/null
+++ b/django-rgd-3d/setup.py
@@ -0,0 +1,56 @@
+from io import open as io_open
+import os
+from pathlib import Path
+
+from setuptools import find_packages, setup
+
+readme_file = Path(__file__).parent.parent / 'README.md'
+if readme_file.exists():
+    with readme_file.open() as f:
+        long_description = f.read()
+else:
+    # When this is first installed in development Docker, README.md is not available
+    long_description = ''
+
+__version__ = None
+filepath = os.path.dirname(__file__)
+version_file = os.path.join(filepath, '..', 'version.py')
+with io_open(version_file, mode='r') as fd:
+    exec(fd.read())
+
+setup(
+    name='django-rgd-3d',
+    version=__version__,
+    description='',
+    long_description=long_description,
+    long_description_content_type='text/markdown',
+    license='Apache 2.0',
+    author='Kitware, Inc.',
+    author_email='rgd@kitware.com',
+    url='https://github.com/ResonantGeoData/ResonantGeoData',
+    keywords='',
+    classifiers=[
+        'Development Status :: 3 - Alpha',
+        'Environment :: Web Environment',
+        'Framework :: Django :: 3.0',
+        'Framework :: Django',
+        'Intended Audience :: Developers',
+        'License :: OSI Approved :: Apache Software License',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python',
+    ],
+    python_requires='>=3.8',
+    packages=find_packages(),
+    include_package_data=True,
+    install_requires=[
+        'django-rgd',
+    ],
+    extras_require={
+        'worker': [
+            'pyntcloud[LAS]',
+            'pyvista',
+        ],
+    },
+)
diff --git a/django-rgd-fmv/MANIFEST.in b/django-rgd-fmv/MANIFEST.in
new file mode 100644
index 000000000..8a0d060be
--- /dev/null
+++ b/django-rgd-fmv/MANIFEST.in
@@ -0,0 +1,2 @@
+# Include any data files such as templates or static assets in the source distribution
+graft rgd_fmv
diff --git a/django-rgd-fmv/README.md b/django-rgd-fmv/README.md
new file mode 100644
index 000000000..303c72489
--- /dev/null
+++ b/django-rgd-fmv/README.md
@@ -0,0 +1,3 @@
+# ResonantGeoData Full Motion Video
+
+A submodule of ResonantGeoData for storing Full Motion Video (FMV) datasets
diff --git a/django-rgd-fmv/rgd_fmv/__init__.py b/django-rgd-fmv/rgd_fmv/__init__.py
new file mode 100644
index 000000000..830a52968
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/__init__.py
@@ -0,0 +1,7 @@
+from pkg_resources import DistributionNotFound, get_distribution
+
+try:
+    __version__ = get_distribution('django-rgd-fmv').version
+except DistributionNotFound:
+    # package is not installed
+    __version__ = None
diff --git a/django-rgd-fmv/rgd_fmv/admin/__init__.py b/django-rgd-fmv/rgd_fmv/admin/__init__.py
new file mode 100644
index 000000000..5b7cf56f3
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/admin/__init__.py
@@ -0,0 +1,1 @@
+from .fmv import *  # noqa
diff --git a/django-rgd-fmv/rgd_fmv/admin/fmv.py b/django-rgd-fmv/rgd_fmv/admin/fmv.py
new file mode 100644
index 000000000..d982f1e54
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/admin/fmv.py
@@ -0,0 +1,49 @@
+from django.contrib import admin
+from django.contrib.gis.admin import OSMGeoAdmin
+from rgd.admin.mixins import (
+    MODIFIABLE_FILTERS,
+    TASK_EVENT_FILTERS,
+    TASK_EVENT_READONLY,
+    _FileGetNameMixin,
+    reprocess,
+)
+from rgd_fmv.models import FMV, FMVMeta
+
+
+class FMVMetaInline(admin.StackedInline):
+    model = FMVMeta
+    fk_name = 'fmv_file'
+    list_display = (
+        'pk',
+        'fmv_file',
+        'modified',
+        'created',
+    )
+    readonly_fields = (
+        'modified',
+        'created',
+        'fmv_file',
+    )
+
+
+@admin.register(FMV)
+class FMVAdmin(OSMGeoAdmin, _FileGetNameMixin):
+    list_display = (
+        'pk',
+        'get_name',
+        'status',
+        'modified',
+        'created',
+        'fmv_data_link',
+        'klv_data_link',
+    )
+    readonly_fields = (
+        'modified',
+        'created',
+        'klv_file',
+        'web_video_file',
+        'frame_rate',
+    ) + TASK_EVENT_READONLY
+    inlines = (FMVMetaInline,)
+    actions = (reprocess,)
+    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
diff --git a/django-rgd-fmv/rgd_fmv/apps.py b/django-rgd-fmv/rgd_fmv/apps.py
new file mode 100644
index 000000000..cb09b9608
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/apps.py
@@ -0,0 +1,9 @@
+from django.apps import AppConfig
+
+
+class RGDFMVConfig(AppConfig):
+    default_auto_field = 'django.db.models.AutoField'
+    name = 'rgd_fmv'
+
+    def ready(self):
+        import rgd_fmv.signals  # noqa: F401
diff --git a/django-rgd-fmv/rgd_fmv/management/commands/_data_helper.py b/django-rgd-fmv/rgd_fmv/management/commands/_data_helper.py
new file mode 100644
index 000000000..ec8ae6ff4
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/management/commands/_data_helper.py
@@ -0,0 +1,10 @@
+import logging
+
+from rgd.management.commands._data_helper import _get_or_create_file_model
+from rgd_fmv import models
+
+logger = logging.getLogger(__name__)
+
+
+def load_fmv_files(fmv_files):
+    return [_get_or_create_file_model(models.FMV, fmv).id for fmv in fmv_files]
diff --git a/django-rgd-fmv/rgd_fmv/management/commands/rgd_fmv_demo.py b/django-rgd-fmv/rgd_fmv/management/commands/rgd_fmv_demo.py
new file mode 100644
index 000000000..5cf5a4041
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/management/commands/rgd_fmv_demo.py
@@ -0,0 +1,27 @@
+import logging
+
+from rgd.management.commands._data_helper import SynchronousTasksCommand, _get_or_create_file_model
+from rgd_fmv import models
+
+logger = logging.getLogger(__name__)
+
+SUCCESS_MSG = 'Finished loading all demo data.'
+
+# Names of files in the datastore
+FMV_FILES = []
+
+
+def load_fmv_files(fmv_files):
+    return [_get_or_create_file_model(models.FMV, fmv).id for fmv in fmv_files]
+
+
+class Command(SynchronousTasksCommand):
+    help = 'Populate database with demo data.'
+
+    def handle(self, *args, **options):
+        self.set_synchronous()
+        # Run the command
+        load_fmv_files(FMV_FILES)
+
+        self.stdout.write(self.style.SUCCESS(SUCCESS_MSG))
+        self.reset_celery()
diff --git a/rgd/geodata/management/commands/wasabi.py b/django-rgd-fmv/rgd_fmv/management/commands/rgd_fmv_wasabi.py
similarity index 70%
rename from rgd/geodata/management/commands/wasabi.py
rename to django-rgd-fmv/rgd_fmv/management/commands/rgd_fmv_wasabi.py
index 2def8a478..6f1f8a6de 100644
--- a/rgd/geodata/management/commands/wasabi.py
+++ b/django-rgd-fmv/rgd_fmv/management/commands/rgd_fmv_wasabi.py
@@ -1,14 +1,16 @@
+from rgd.management.commands._data_helper import SynchronousTasksCommand
+
 from . import _data_helper as helper
 
 SUCCESS_MSG = 'Finished loading all demo data.'
 
 FMV_FILES = [
     'https://data.kitware.com/api/v1/file/604a5a532fa25629b931c673/download',
-    'https://data.kitware.com/api/v1/file/604a547c2fa25629b93176d2/download',
+    # 'https://data.kitware.com/api/v1/file/604a547c2fa25629b93176d2/download',
 ]
 
 
-class Command(helper.SynchronousTasksCommand):
+class Command(SynchronousTasksCommand):
     help = 'Populate database with WASABI FMV demo data.'
 
     def handle(self, *args, **options):
diff --git a/django-rgd-fmv/rgd_fmv/migrations/0001_initial.py b/django-rgd-fmv/rgd_fmv/migrations/0001_initial.py
new file mode 100644
index 000000000..7ec38ed9e
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/migrations/0001_initial.py
@@ -0,0 +1,133 @@
+# Generated by Django 3.2.4 on 2021-06-24 22:52
+
+import django.contrib.gis.db.models.fields
+from django.db import migrations, models
+import django.db.models.deletion
+import django_extensions.db.fields
+import rgd.models.mixins
+import rgd.utility
+import s3_file_field.fields
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('rgd', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='FMV',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                ('failure_reason', models.TextField(null=True)),
+                (
+                    'status',
+                    models.CharField(
+                        choices=[
+                            ('created', 'Created but not queued'),
+                            ('queued', 'Queued for processing'),
+                            ('running', 'Processing'),
+                            ('failed', 'Failed'),
+                            ('success', 'Succeeded'),
+                        ],
+                        default='created',
+                        max_length=20,
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                (
+                    'klv_file',
+                    s3_file_field.fields.S3FileField(
+                        null=True, upload_to=rgd.utility.uuid_prefix_filename
+                    ),
+                ),
+                (
+                    'web_video_file',
+                    s3_file_field.fields.S3FileField(
+                        null=True, upload_to=rgd.utility.uuid_prefix_filename
+                    ),
+                ),
+                ('frame_rate', models.FloatField(null=True)),
+                (
+                    'file',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='+',
+                        to='rgd.checksumfile',
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='FMVMeta',
+            fields=[
+                (
+                    'spatialentry_ptr',
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to='rgd.spatialentry',
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('ground_frames', django.contrib.gis.db.models.fields.MultiPolygonField(srid=4326)),
+                ('ground_union', django.contrib.gis.db.models.fields.MultiPolygonField(srid=4326)),
+                ('flight_path', django.contrib.gis.db.models.fields.MultiPointField(srid=4326)),
+                ('frame_numbers', models.BinaryField()),
+                (
+                    'fmv_file',
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.CASCADE, to='rgd_fmv.fmv'
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(
+                'rgd.spatialentry',
+                models.Model,
+                rgd.models.mixins.PermissionPathMixin,
+                rgd.models.mixins.DetailViewMixin,
+            ),
+        ),
+    ]
diff --git a/django-rgd-fmv/rgd_fmv/migrations/__init__.py b/django-rgd-fmv/rgd_fmv/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/django-rgd-fmv/rgd_fmv/models/__init__.py b/django-rgd-fmv/rgd_fmv/models/__init__.py
new file mode 100644
index 000000000..416a9216c
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/models/__init__.py
@@ -0,0 +1,1 @@
+from .base import FMV, FMVMeta  # noqa
diff --git a/rgd/geodata/models/fmv/base.py b/django-rgd-fmv/rgd_fmv/models/base.py
similarity index 60%
rename from rgd/geodata/models/fmv/base.py
rename to django-rgd-fmv/rgd_fmv/models/base.py
index 60275b97b..8f5758c32 100644
--- a/rgd/geodata/models/fmv/base.py
+++ b/django-rgd-fmv/rgd_fmv/models/base.py
@@ -2,21 +2,20 @@
 import pickle
 
 from django.contrib.gis.db import models
-from s3_file_field import S3FileField
-
+from django_extensions.db.models import TimeStampedModel
+from rgd.models import ChecksumFile, SpatialEntry
+from rgd.models.constants import DB_SRID
+from rgd.models.mixins import DetailViewMixin, PermissionPathMixin, TaskEventMixin
 from rgd.utility import _link_url, uuid_prefix_filename
-
-from ... import tasks
-from ..common import ChecksumFile, ModifiableEntry, SpatialEntry
-from ..constants import DB_SRID
-from ..mixins import TaskEventMixin
+from rgd_fmv.tasks import jobs
+from s3_file_field import S3FileField
 
 
-class FMVFile(ModifiableEntry, TaskEventMixin):
+class FMV(TimeStampedModel, TaskEventMixin, PermissionPathMixin):
     """For uploading single FMV files (mp4)."""
 
-    task_funcs = (tasks.task_read_fmv_file,)
-    file = models.ForeignKey(ChecksumFile, on_delete=models.CASCADE)
+    task_funcs = (jobs.task_read_fmv_file,)
+    file = models.ForeignKey(ChecksumFile, on_delete=models.CASCADE, related_name='+')
 
     # Allow these to be null because autogenerated
     klv_file = S3FileField(null=True, upload_to=uuid_prefix_filename)
@@ -33,17 +32,13 @@ def klv_data_link(self):
 
     klv_data_link.allow_tags = True
 
+    permissions_paths = ['file__collection__collection_permissions']
 
-class FMVEntry(ModifiableEntry, SpatialEntry):
-    """Single FMV entry, tracks the original file."""
 
-    def __str__(self):
-        return f'{self.name} ({self.id})'
-
-    name = models.CharField(max_length=1000)
-    description = models.TextField(null=True, blank=True)
+class FMVMeta(TimeStampedModel, SpatialEntry, PermissionPathMixin, DetailViewMixin):
+    """Single FMV entry, tracks the original file."""
 
-    fmv_file = models.OneToOneField(FMVFile, on_delete=models.CASCADE)
+    fmv_file = models.OneToOneField(FMV, on_delete=models.CASCADE)
 
     ground_frames = models.MultiPolygonField(srid=DB_SRID)
     ground_union = models.MultiPolygonField(srid=DB_SRID)
@@ -57,3 +52,10 @@ def _array_to_blob(array):
     @staticmethod
     def _blob_to_array(blob):
         return pickle.loads(base64.b64decode(blob))
+
+    permissions_paths = ['fmv_file__file__collection__collection_permissions']
+    detail_view_name = 'fmv-entry-detail'
+
+    @property
+    def name(self):
+        return self.fmv_file.file.name
diff --git a/django-rgd-fmv/rgd_fmv/rest/__init__.py b/django-rgd-fmv/rgd_fmv/rest/__init__.py
new file mode 100644
index 000000000..dea997230
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/rest/__init__.py
@@ -0,0 +1,1 @@
+from . import get  # noqa
diff --git a/django-rgd-fmv/rgd_fmv/rest/get.py b/django-rgd-fmv/rgd_fmv/rest/get.py
new file mode 100644
index 000000000..682a1b81a
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/rest/get.py
@@ -0,0 +1,15 @@
+from rest_framework.generics import RetrieveAPIView
+from rgd.rest.get import _PermissionMixin
+from rgd_fmv import models, serializers
+
+
+class GetFMVMeta(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.FMVMetaSerializer
+    lookup_field = 'pk'
+    queryset = models.FMVMeta.objects.all()
+
+
+class GetFMVDataEntry(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.FMVMetaDataSerializer
+    lookup_field = 'pk'
+    queryset = models.FMVMeta.objects.all()
diff --git a/django-rgd-fmv/rgd_fmv/serializers.py b/django-rgd-fmv/rgd_fmv/serializers.py
new file mode 100644
index 000000000..81dd4f617
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/serializers.py
@@ -0,0 +1,46 @@
+import json
+
+from rest_framework import serializers
+from rgd import utility
+from rgd.serializers import ChecksumFileSerializer, SpatialEntrySerializer
+
+from . import models
+
+
+class FMVSerializer(serializers.ModelSerializer):
+    file = ChecksumFileSerializer()
+
+    class Meta:
+        model = models.FMV
+        fields = '__all__'
+
+
+class FMVMetaSerializer(SpatialEntrySerializer):
+    fmv_file = FMVSerializer()
+
+    class Meta:
+        model = models.FMVMeta
+        exclude = [
+            'ground_frames',
+            'ground_union',
+            'flight_path',
+            'frame_numbers',
+            'outline',
+            'footprint',
+        ]
+
+
+class FMVMetaDataSerializer(FMVMetaSerializer):
+    def to_representation(self, value):
+        ret = super().to_representation(value)
+        ret['ground_frames'] = json.loads(value.ground_frames.geojson)
+        ret['ground_union'] = json.loads(value.ground_union.geojson)
+        ret['flight_path'] = json.loads(value.flight_path.geojson)
+        return ret
+
+    class Meta:
+        model = models.FMVMeta
+        fields = '__all__'
+
+
+utility.make_serializers(globals(), models)
diff --git a/django-rgd-fmv/rgd_fmv/signals.py b/django-rgd-fmv/rgd_fmv/signals.py
new file mode 100644
index 000000000..97532dcfb
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/signals.py
@@ -0,0 +1,11 @@
+from django.db import transaction
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from rgd.utility import skip_signal
+from rgd_fmv import models
+
+
+@receiver(post_save, sender=models.FMV)
+@skip_signal()
+def _post_save_fmv_file(sender, instance, *args, **kwargs):
+    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
diff --git a/django-rgd-fmv/rgd_fmv/tasks/__init__.py b/django-rgd-fmv/rgd_fmv/tasks/__init__.py
new file mode 100644
index 000000000..aa31b2404
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/tasks/__init__.py
@@ -0,0 +1,1 @@
+from .jobs import *  # noqa
diff --git a/rgd/geodata/models/fmv/etl.py b/django-rgd-fmv/rgd_fmv/tasks/etl.py
similarity index 95%
rename from rgd/geodata/models/fmv/etl.py
rename to django-rgd-fmv/rgd_fmv/tasks/etl.py
index 3d48f225b..b00207e77 100644
--- a/rgd/geodata/models/fmv/etl.py
+++ b/django-rgd-fmv/rgd_fmv/tasks/etl.py
@@ -5,13 +5,12 @@
 import tempfile
 
 from celery.utils.log import get_task_logger
+import cv2
 from django.contrib.gis.geos import MultiPoint, MultiPolygon, Point, Polygon
 from girder_utils.files import field_file_to_local_path
 import numpy as np
-
 from rgd.utility import get_or_create_no_commit
-
-from .base import FMVEntry, FMVFile
+from rgd_fmv.models import FMV, FMVMeta
 
 logger = get_task_logger(__name__)
 
@@ -93,7 +92,8 @@ def _get_path_and_footprints(content):
     frames = [content[idxs[i] : idxs[i + 1]] for i in range(len(idxs) - 1)]
     nf = [int(c[0]) for c in re.findall(frame_regex, content)]
 
-    assert len(nf[:-1]) == len(frames)
+    if len(nf[:-1]) != len(frames):
+        raise AssertionError('number of frames mismatch.')
 
     path = []
     polys = []
@@ -136,8 +136,6 @@ def _get_path_and_footprints(content):
 
 
 def _get_frame_rate_of_video(file_path):
-    import cv2  # NOTE: in project depends from kwcoco
-
     cap = cv2.VideoCapture(os.path.abspath(file_path))
     return cap.get(cv2.CAP_PROP_FPS)
 
@@ -204,8 +202,8 @@ def _populate_fmv_entry(entry):
 
 
 def read_fmv_file(fmv_file_id):
-    fmv_file = FMVFile.objects.get(id=fmv_file_id)
-    fmv_file.skip_task = True
+    fmv_file = FMV.objects.get(id=fmv_file_id)
+    fmv_file.skip_signal = True
 
     validation = True  # TODO: use `fmv_file.file.validate()`
     # Only extraxt the KLV data if it does not exist or the checksum of the video has changed
@@ -215,8 +213,6 @@ def read_fmv_file(fmv_file_id):
         _convert_video_to_mp4(fmv_file)
 
     # create a model entry for that shapefile
-    entry, created = get_or_create_no_commit(
-        FMVEntry, defaults=dict(name=fmv_file.file.name), fmv_file=fmv_file
-    )
+    entry, created = get_or_create_no_commit(FMVMeta, fmv_file=fmv_file)
 
     _populate_fmv_entry(entry)
diff --git a/django-rgd-fmv/rgd_fmv/tasks/jobs.py b/django-rgd-fmv/rgd_fmv/tasks/jobs.py
new file mode 100644
index 000000000..6aba08865
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/tasks/jobs.py
@@ -0,0 +1,12 @@
+from celery import shared_task
+from rgd.tasks import helpers
+
+
+@shared_task(time_limit=86400)
+def task_read_fmv_file(file_id):
+    from rgd_fmv.models import FMV
+
+    from .etl import read_fmv_file
+
+    fmv_file = FMV.objects.get(id=file_id)
+    helpers._run_with_failure_reason(fmv_file, read_fmv_file, file_id)
diff --git a/rgd/geodata/templates/geodata/fmventry_detail.html b/django-rgd-fmv/rgd_fmv/templates/rgd_fmv/fmvmeta_detail.html
similarity index 95%
rename from rgd/geodata/templates/geodata/fmventry_detail.html
rename to django-rgd-fmv/rgd_fmv/templates/rgd_fmv/fmvmeta_detail.html
index 9ab6f90bd..390245191 100644
--- a/rgd/geodata/templates/geodata/fmventry_detail.html
+++ b/django-rgd-fmv/rgd_fmv/templates/rgd_fmv/fmvmeta_detail.html
@@ -1,4 +1,4 @@
-{% extends "geodata/_base/spatial_detail.html" %}
+{% extends "rgd/_base/spatial_detail.html" %}
 
 {% block extra_meta_table_body %}
   <tr>
@@ -28,7 +28,7 @@
 
 {% block detail %}
 
-    {% include 'geodata/_includes/viewer.html' %}
+    {% include 'rgd/_include/viewer.html' %}
 
     <script>
       let frameRate = JSON.parse('{{ frame_rate|escapejs }}');
diff --git a/django-rgd-fmv/rgd_fmv/urls.py b/django-rgd-fmv/rgd_fmv/urls.py
new file mode 100644
index 000000000..159d7ca08
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/urls.py
@@ -0,0 +1,24 @@
+from django.urls import path
+
+from . import models, rest, views
+
+urlpatterns = [
+    # Pages
+    path(
+        'rgd_fmv/<int:pk>/',
+        views.FMVMetaDetailView.as_view(),
+        name=models.FMVMeta.detail_view_name,
+    ),
+    #############
+    # Other
+    path(
+        'api/rgd_fmv/<int:pk>',
+        rest.get.GetFMVMeta.as_view(),
+        name='fmv-entry',
+    ),
+    path(
+        'api/rgd_fmv/<int:pk>/data',
+        rest.get.GetFMVDataEntry.as_view(),
+        name='fmv-entry-data',
+    ),
+]
diff --git a/django-rgd-fmv/rgd_fmv/views.py b/django-rgd-fmv/rgd_fmv/views.py
new file mode 100644
index 000000000..8ffa93ade
--- /dev/null
+++ b/django-rgd-fmv/rgd_fmv/views.py
@@ -0,0 +1,23 @@
+import json
+
+from rgd.views import _SpatialDetailView
+
+from . import models
+
+
+class FMVMetaDetailView(_SpatialDetailView):
+    model = models.FMVMeta
+
+    def _get_extent(self):
+        extent = super()._get_extent()
+        if self.object.ground_union is not None:
+            # All or none of these will be set, only check one
+            extent['collect'] = self.object.ground_union.json
+            extent['ground_frames'] = self.object.ground_frames.json
+            extent['frame_numbers'] = self.object._blob_to_array(self.object.frame_numbers)
+        return extent
+
+    def get_context_data(self, *args, **kwargs):
+        context = super().get_context_data(*args, **kwargs)
+        context['frame_rate'] = json.dumps(self.object.fmv_file.frame_rate)
+        return context
diff --git a/django-rgd-fmv/setup.py b/django-rgd-fmv/setup.py
new file mode 100644
index 000000000..8b76ab8fa
--- /dev/null
+++ b/django-rgd-fmv/setup.py
@@ -0,0 +1,57 @@
+from io import open as io_open
+import os
+from pathlib import Path
+
+from setuptools import find_packages, setup
+
+readme_file = Path(__file__).parent.parent / 'README.md'
+if readme_file.exists():
+    with readme_file.open() as f:
+        long_description = f.read()
+else:
+    # When this is first installed in development Docker, README.md is not available
+    long_description = ''
+
+__version__ = None
+filepath = os.path.dirname(__file__)
+version_file = os.path.join(filepath, '..', 'version.py')
+with io_open(version_file, mode='r') as fd:
+    exec(fd.read())
+
+setup(
+    name='django-rgd-fmv',
+    version=__version__,
+    description='',
+    long_description=long_description,
+    long_description_content_type='text/markdown',
+    license='Apache 2.0',
+    author='Kitware, Inc.',
+    author_email='rgd@kitware.com',
+    url='https://github.com/ResonantGeoData/ResonantGeoData',
+    keywords='',
+    classifiers=[
+        'Development Status :: 3 - Alpha',
+        'Environment :: Web Environment',
+        'Framework :: Django :: 3.0',
+        'Framework :: Django',
+        'Intended Audience :: Developers',
+        'License :: OSI Approved :: Apache Software License',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python',
+    ],
+    python_requires='>=3.8',
+    packages=find_packages(),
+    include_package_data=True,
+    install_requires=[
+        'django-rgd',
+    ],
+    extras_require={
+        'worker': [
+            'numpy',
+            'kwiver',
+            'opencv-python-headless',
+        ],
+    },
+)
diff --git a/django-rgd-geometry/MANIFEST.in b/django-rgd-geometry/MANIFEST.in
new file mode 100644
index 000000000..cf7d0f2b9
--- /dev/null
+++ b/django-rgd-geometry/MANIFEST.in
@@ -0,0 +1,2 @@
+# Include any data files such as templates or static assets in the source distribution
+graft rgd_geometry
diff --git a/django-rgd-geometry/README.md b/django-rgd-geometry/README.md
new file mode 100644
index 000000000..569c3668b
--- /dev/null
+++ b/django-rgd-geometry/README.md
@@ -0,0 +1,3 @@
+# ResonantGeoData Geometry
+
+A submodule of ResonantGeoData for storing shapefiles
diff --git a/django-rgd-geometry/rgd_geometry/__init__.py b/django-rgd-geometry/rgd_geometry/__init__.py
new file mode 100644
index 000000000..422b1edb2
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/__init__.py
@@ -0,0 +1,7 @@
+from pkg_resources import DistributionNotFound, get_distribution
+
+try:
+    __version__ = get_distribution('django-rgd-geometry').version
+except DistributionNotFound:
+    # package is not installed
+    __version__ = None
diff --git a/django-rgd-geometry/rgd_geometry/admin/__init__.py b/django-rgd-geometry/rgd_geometry/admin/__init__.py
new file mode 100644
index 000000000..f97e43011
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/admin/__init__.py
@@ -0,0 +1,1 @@
+from .geometry import *  # noqa
diff --git a/django-rgd-geometry/rgd_geometry/admin/geometry.py b/django-rgd-geometry/rgd_geometry/admin/geometry.py
new file mode 100644
index 000000000..ff06dd677
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/admin/geometry.py
@@ -0,0 +1,49 @@
+from django.contrib import admin
+from django.contrib.gis.admin import OSMGeoAdmin
+from rgd.admin.mixins import (
+    MODIFIABLE_FILTERS,
+    SPATIAL_ENTRY_FILTERS,
+    TASK_EVENT_FILTERS,
+    TASK_EVENT_READONLY,
+    _FileGetNameMixin,
+    reprocess,
+)
+from rgd_geometry.models import Geometry, GeometryArchive
+
+
+class GeometryInline(admin.StackedInline):
+    model = Geometry
+    fk_name = 'geometry_archive'
+    list_display = (
+        'pk',
+        'name',
+        'geometry_archive',
+        'modified',
+        'created',
+    )
+    list_filter = MODIFIABLE_FILTERS + SPATIAL_ENTRY_FILTERS
+    readonly_fields = (
+        'modified',
+        'created',
+        'geometry_archive',
+    )
+    modifiable = False  # To still show the footprint and outline
+
+
+@admin.register(GeometryArchive)
+class GeometryArchiveAdmin(OSMGeoAdmin, _FileGetNameMixin):
+    list_display = (
+        'pk',
+        'get_name',
+        'status',
+        'modified',
+        'created',
+        'archive_data_link',
+    )
+    readonly_fields = (
+        'modified',
+        'created',
+    ) + TASK_EVENT_READONLY
+    inlines = (GeometryInline,)
+    actions = (reprocess,)
+    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
diff --git a/django-rgd-geometry/rgd_geometry/apps.py b/django-rgd-geometry/rgd_geometry/apps.py
new file mode 100644
index 000000000..c4ebefa24
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/apps.py
@@ -0,0 +1,9 @@
+from django.apps import AppConfig
+
+
+class RGDGeometryConfig(AppConfig):
+    default_auto_field = 'django.db.models.AutoField'
+    name = 'rgd_geometry'
+
+    def ready(self):
+        import rgd_geometry.signals  # noqa: F401
diff --git a/django-rgd-geometry/rgd_geometry/management/commands/rgd_geometry_demo.py b/django-rgd-geometry/rgd_geometry/management/commands/rgd_geometry_demo.py
new file mode 100644
index 000000000..adfa9c83c
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/management/commands/rgd_geometry_demo.py
@@ -0,0 +1,46 @@
+import logging
+
+from rgd.management.commands._data_helper import SynchronousTasksCommand, _get_or_create_file_model
+from rgd_geometry import models
+
+logger = logging.getLogger(__name__)
+
+
+SUCCESS_MSG = 'Finished loading all demo data.'
+
+# Names of files in the datastore
+SHAPE_FILES = [
+    'Streams.zip',
+    'Watershedt.zip',
+    'MuniBounds.zip',
+    'lm_cnty.zip',
+    'dlwatersan.zip',
+    'dlschool.zip',
+    'dlpark.zip',
+    'dlmetro.zip',
+    'dllibrary.zip',
+    'dlhospital.zip',
+    'dlfire.zip',
+    'Solid_Mineral_lease_1.zip',
+    'AG_lease.zip',
+]
+
+
+def load_shape_files(shape_files):
+    ids = []
+    for shpfile in shape_files:
+        entry = _get_or_create_file_model(models.GeometryArchive, shpfile)
+        ids.append(entry.geometrymeta.pk)
+    return ids
+
+
+class Command(SynchronousTasksCommand):
+    help = 'Populate database with demo data.'
+
+    def handle(self, *args, **options):
+        self.set_synchronous()
+        # Run the command
+        load_shape_files(SHAPE_FILES)
+
+        self.stdout.write(self.style.SUCCESS(SUCCESS_MSG))
+        self.reset_celery()
diff --git a/django-rgd-geometry/rgd_geometry/migrations/0001_initial.py b/django-rgd-geometry/rgd_geometry/migrations/0001_initial.py
new file mode 100644
index 000000000..7614fa516
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/migrations/0001_initial.py
@@ -0,0 +1,119 @@
+# Generated by Django 3.2.4 on 2021-06-24 22:52
+
+import django.contrib.gis.db.models.fields
+from django.db import migrations, models
+import django.db.models.deletion
+import django_extensions.db.fields
+import rgd.models.mixins
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('rgd', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='GeometryArchive',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                ('failure_reason', models.TextField(null=True)),
+                (
+                    'status',
+                    models.CharField(
+                        choices=[
+                            ('created', 'Created but not queued'),
+                            ('queued', 'Queued for processing'),
+                            ('running', 'Processing'),
+                            ('failed', 'Failed'),
+                            ('success', 'Succeeded'),
+                        ],
+                        default='created',
+                        max_length=20,
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                (
+                    'file',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='+',
+                        to='rgd.checksumfile',
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='Geometry',
+            fields=[
+                (
+                    'spatialentry_ptr',
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to='rgd.spatialentry',
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('name', models.CharField(blank=True, max_length=1000)),
+                ('description', models.TextField(blank=True, null=True)),
+                ('data', django.contrib.gis.db.models.fields.GeometryCollectionField(srid=4326)),
+                (
+                    'geometry_archive',
+                    models.OneToOneField(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='rgd_geometry.geometryarchive',
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(
+                'rgd.spatialentry',
+                models.Model,
+                rgd.models.mixins.PermissionPathMixin,
+                rgd.models.mixins.DetailViewMixin,
+            ),
+        ),
+    ]
diff --git a/django-rgd-geometry/rgd_geometry/migrations/__init__.py b/django-rgd-geometry/rgd_geometry/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/django-rgd-geometry/rgd_geometry/models/__init__.py b/django-rgd-geometry/rgd_geometry/models/__init__.py
new file mode 100644
index 000000000..f03bc8f1c
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/models/__init__.py
@@ -0,0 +1,1 @@
+from .base import Geometry, GeometryArchive, validate_archive  # noqa
diff --git a/rgd/geodata/models/geometry/base.py b/django-rgd-geometry/rgd_geometry/models/base.py
similarity index 62%
rename from rgd/geodata/models/geometry/base.py
rename to django-rgd-geometry/rgd_geometry/models/base.py
index 62d2daff3..5be91b232 100644
--- a/rgd/geodata/models/geometry/base.py
+++ b/django-rgd-geometry/rgd_geometry/models/base.py
@@ -1,11 +1,11 @@
 from django.contrib.gis.db import models
 from django.core.exceptions import ValidationError
+from django_extensions.db.models import TimeStampedModel
 import magic
-
-from ... import tasks
-from ..common import ChecksumFile, ModifiableEntry, SpatialEntry
-from ..constants import DB_SRID
-from ..mixins import TaskEventMixin
+from rgd.models import ChecksumFile, SpatialEntry
+from rgd.models.constants import DB_SRID
+from rgd.models.mixins import DetailViewMixin, PermissionPathMixin, TaskEventMixin
+from rgd_geometry.tasks import jobs
 
 
 def validate_archive(field_file):
@@ -18,15 +18,15 @@ def validate_archive(field_file):
         raise ValidationError('Unsupported file archive.')
 
 
-class GeometryArchive(ModifiableEntry, TaskEventMixin):
+class GeometryArchive(TimeStampedModel, TaskEventMixin, PermissionPathMixin):
     """Container for ``zip`` archives of a shapefile.
 
     When this model is created, it loads data from an archive into
-    a single ``GeometryEntry`` that is then associated with this entry.
+    a single ``Geometry`` that is then associated with this entry.
     """
 
-    task_funcs = (tasks.task_read_geometry_archive,)
-    file = models.ForeignKey(ChecksumFile, on_delete=models.CASCADE)
+    task_funcs = (jobs.task_read_geometry_archive,)
+    file = models.ForeignKey(ChecksumFile, on_delete=models.CASCADE, related_name='+')
 
     def save(self, *args, **kwargs):
         super(GeometryArchive, self).save(*args, **kwargs)
@@ -36,8 +36,10 @@ def archive_data_link(self):
 
     archive_data_link.allow_tags = True
 
+    permissions_paths = ['file__collection__collection_permissions']
+
 
-class GeometryEntry(ModifiableEntry, SpatialEntry):
+class Geometry(TimeStampedModel, SpatialEntry, PermissionPathMixin, DetailViewMixin):
     """A holder for geometry vector data."""
 
     name = models.CharField(max_length=1000, blank=True)
@@ -48,3 +50,6 @@ class GeometryEntry(ModifiableEntry, SpatialEntry):
 
     # Can be null if not generated from uploaded ZIP file but something else
     geometry_archive = models.OneToOneField(GeometryArchive, null=True, on_delete=models.CASCADE)
+
+    permissions_paths = ['geometry_archive__file__collection__collection_permissions']
+    detail_view_name = 'geometry-entry-detail'
diff --git a/django-rgd-geometry/rgd_geometry/rest/__init__.py b/django-rgd-geometry/rgd_geometry/rest/__init__.py
new file mode 100644
index 000000000..dea997230
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/rest/__init__.py
@@ -0,0 +1,1 @@
+from . import get  # noqa
diff --git a/django-rgd-geometry/rgd_geometry/rest/get.py b/django-rgd-geometry/rgd_geometry/rest/get.py
new file mode 100644
index 000000000..91b9114c9
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/rest/get.py
@@ -0,0 +1,15 @@
+from rest_framework.generics import RetrieveAPIView
+from rgd.rest.get import _PermissionMixin
+from rgd_geometry import models, serializers
+
+
+class GetGeometry(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.GeometrySerializer
+    lookup_field = 'pk'
+    queryset = models.Geometry.objects.all()
+
+
+class GetGeometryData(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.GeometryDataSerializer
+    lookup_field = 'pk'
+    queryset = models.Geometry.objects.all()
diff --git a/django-rgd-geometry/rgd_geometry/serializers.py b/django-rgd-geometry/rgd_geometry/serializers.py
new file mode 100644
index 000000000..5af807783
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/serializers.py
@@ -0,0 +1,26 @@
+import json
+
+from rgd import utility
+from rgd.serializers import SpatialEntrySerializer
+
+from . import models
+
+
+class GeometrySerializer(SpatialEntrySerializer):
+    class Meta:
+        model = models.Geometry
+        exclude = ['data', 'footprint', 'outline']
+
+
+class GeometryDataSerializer(GeometrySerializer):
+    def to_representation(self, value):
+        ret = super().to_representation(value)
+        ret['data'] = json.loads(value.data.geojson)
+        return ret
+
+    class Meta:
+        model = models.Geometry
+        fields = '__all__'
+
+
+utility.make_serializers(globals(), models)
diff --git a/django-rgd-geometry/rgd_geometry/signals.py b/django-rgd-geometry/rgd_geometry/signals.py
new file mode 100644
index 000000000..d6ec7a3c3
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/signals.py
@@ -0,0 +1,11 @@
+from django.db import transaction
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from rgd.utility import skip_signal
+from rgd_geometry import models
+
+
+@receiver(post_save, sender=models.GeometryArchive)
+@skip_signal()
+def _post_save_geometry_archive(sender, instance, *args, **kwargs):
+    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
diff --git a/django-rgd-geometry/rgd_geometry/tasks/__init__.py b/django-rgd-geometry/rgd_geometry/tasks/__init__.py
new file mode 100644
index 000000000..aa31b2404
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/tasks/__init__.py
@@ -0,0 +1,1 @@
+from .jobs import *  # noqa
diff --git a/rgd/geodata/models/geometry/etl.py b/django-rgd-geometry/rgd_geometry/tasks/etl.py
similarity index 94%
rename from rgd/geodata/models/geometry/etl.py
rename to django-rgd-geometry/rgd_geometry/tasks/etl.py
index c37a2c5d4..1f2aeb8b7 100644
--- a/rgd/geodata/models/geometry/etl.py
+++ b/django-rgd-geometry/rgd_geometry/tasks/etl.py
@@ -10,14 +10,12 @@
 from django.contrib.gis.geos import GeometryCollection, GEOSGeometry, Polygon
 from django.core.exceptions import ValidationError
 import fiona
+from rgd.models.transform import transform_geometry
+from rgd.utility import get_or_create_no_commit
+from rgd_geometry.models import Geometry, GeometryArchive
 from shapely.geometry import shape
 from shapely.wkb import dumps
 
-from rgd.utility import get_or_create_no_commit
-
-from .base import GeometryArchive, GeometryEntry
-from .transform import transform_geometry
-
 logger = get_task_logger(__name__)
 
 
@@ -63,7 +61,7 @@ def read_geometry_archive(archive_id):
         # load each shapefile using fiona
         with fiona.open(shape_file) as shapes:
             geometry_entry, created = get_or_create_no_commit(
-                GeometryEntry, defaults=dict(name=archive.file.name), geometry_archive=archive
+                Geometry, defaults=dict(name=archive.file.name), geometry_archive=archive
             )
 
             shapes.meta  # TODO: dump this JSON into the model entry
diff --git a/django-rgd-geometry/rgd_geometry/tasks/jobs.py b/django-rgd-geometry/rgd_geometry/tasks/jobs.py
new file mode 100644
index 000000000..d8f01b348
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/tasks/jobs.py
@@ -0,0 +1,12 @@
+from celery import shared_task
+from rgd.tasks import helpers
+
+
+@shared_task(time_limit=86400)
+def task_read_geometry_archive(archive_id):
+    from rgd_geometry.models import GeometryArchive
+
+    from .etl import read_geometry_archive
+
+    archive = GeometryArchive.objects.get(id=archive_id)
+    helpers._run_with_failure_reason(archive, read_geometry_archive, archive_id)
diff --git a/rgd/geodata/templates/geodata/geometryentry_detail.html b/django-rgd-geometry/rgd_geometry/templates/rgd_geometry/geometryentry_detail.html
similarity index 88%
rename from rgd/geodata/templates/geodata/geometryentry_detail.html
rename to django-rgd-geometry/rgd_geometry/templates/rgd_geometry/geometryentry_detail.html
index 52a691ec5..1e3c90525 100644
--- a/rgd/geodata/templates/geodata/geometryentry_detail.html
+++ b/django-rgd-geometry/rgd_geometry/templates/rgd_geometry/geometryentry_detail.html
@@ -1,4 +1,4 @@
-{% extends "geodata/_base/spatial_detail.html" %}
+{% extends "rgd/_base/spatial_detail.html" %}
 
 {% block extra_meta_table_body %}
 
@@ -13,7 +13,7 @@
 {% block detail %}
 
 
-  {% include 'geodata/_includes/empty_viewer.html' %}
+  {% include 'rgd/_include/empty_viewer.html' %}
 
   <script>
     let extents = JSON.parse('{{ extents|escapejs }}');
diff --git a/django-rgd-geometry/rgd_geometry/urls.py b/django-rgd-geometry/rgd_geometry/urls.py
new file mode 100644
index 000000000..fc3a421ef
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/urls.py
@@ -0,0 +1,24 @@
+from django.urls import path
+
+from . import models, rest, views
+
+urlpatterns = [
+    # Pages
+    path(
+        'rgd_geometry/<int:pk>/',
+        views.GeometryDetailView.as_view(),
+        name=models.Geometry.detail_view_name,
+    ),
+    #############
+    # Other
+    path(
+        'api/rgd_geometry/<int:pk>',
+        rest.get.GetGeometry.as_view(),
+        name='geometry-entry',
+    ),
+    path(
+        'api/rgd_geometry/<int:pk>/data',
+        rest.get.GetGeometryData.as_view(),
+        name='geometry-entry-data',
+    ),
+]
diff --git a/django-rgd-geometry/rgd_geometry/views.py b/django-rgd-geometry/rgd_geometry/views.py
new file mode 100644
index 000000000..f9b537c09
--- /dev/null
+++ b/django-rgd-geometry/rgd_geometry/views.py
@@ -0,0 +1,12 @@
+from rgd.views import _SpatialDetailView
+
+from . import models
+
+
+class GeometryDetailView(_SpatialDetailView):
+    model = models.Geometry
+
+    def _get_extent(self):
+        extent = super()._get_extent()
+        extent['data'] = self.object.data.json
+        return extent
diff --git a/django-rgd-geometry/setup.py b/django-rgd-geometry/setup.py
new file mode 100644
index 000000000..f0c181da8
--- /dev/null
+++ b/django-rgd-geometry/setup.py
@@ -0,0 +1,56 @@
+from io import open as io_open
+import os
+from pathlib import Path
+
+from setuptools import find_packages, setup
+
+readme_file = Path(__file__).parent.parent / 'README.md'
+if readme_file.exists():
+    with readme_file.open() as f:
+        long_description = f.read()
+else:
+    # When this is first installed in development Docker, README.md is not available
+    long_description = ''
+
+__version__ = None
+filepath = os.path.dirname(__file__)
+version_file = os.path.join(filepath, '..', 'version.py')
+with io_open(version_file, mode='r') as fd:
+    exec(fd.read())
+
+setup(
+    name='django-rgd-geometry',
+    version=__version__,
+    description='',
+    long_description=long_description,
+    long_description_content_type='text/markdown',
+    license='Apache 2.0',
+    author='Kitware, Inc.',
+    author_email='rgd@kitware.com',
+    url='https://github.com/ResonantGeoData/ResonantGeoData',
+    keywords='',
+    classifiers=[
+        'Development Status :: 3 - Alpha',
+        'Environment :: Web Environment',
+        'Framework :: Django :: 3.0',
+        'Framework :: Django',
+        'Intended Audience :: Developers',
+        'License :: OSI Approved :: Apache Software License',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python',
+    ],
+    python_requires='>=3.8',
+    packages=find_packages(),
+    include_package_data=True,
+    install_requires=[
+        'django-rgd',
+    ],
+    extras_require={
+        'worker': [
+            'shapely',
+            'fiona',
+        ],
+    },
+)
diff --git a/django-rgd-imagery/MANIFEST.in b/django-rgd-imagery/MANIFEST.in
new file mode 100644
index 000000000..fedcaf3f8
--- /dev/null
+++ b/django-rgd-imagery/MANIFEST.in
@@ -0,0 +1,2 @@
+# Include any data files such as templates or static assets in the source distribution
+graft rgd_imagery
diff --git a/django-rgd-imagery/README.md b/django-rgd-imagery/README.md
new file mode 100644
index 000000000..05857292c
--- /dev/null
+++ b/django-rgd-imagery/README.md
@@ -0,0 +1,3 @@
+# ResonantGeoData Imagery
+
+A submodule of ResonantGeoData for storing imagery supporting annotations and spatial reference.
diff --git a/django-rgd-imagery/rgd_imagery/__init__.py b/django-rgd-imagery/rgd_imagery/__init__.py
new file mode 100644
index 000000000..6c5d740f7
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/__init__.py
@@ -0,0 +1,7 @@
+from pkg_resources import DistributionNotFound, get_distribution
+
+try:
+    __version__ = get_distribution('django-rgd-imagery').version
+except DistributionNotFound:
+    # package is not installed
+    __version__ = None
diff --git a/django-rgd-imagery/rgd_imagery/admin/__init__.py b/django-rgd-imagery/rgd_imagery/admin/__init__.py
new file mode 100644
index 000000000..495281721
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/admin/__init__.py
@@ -0,0 +1,5 @@
+from .annotation import *  # noqa
+from .base import *  # noqa
+from .kwcoco import *  # noqa
+from .processed import *  # noqa
+from .raster import *  # noqa
diff --git a/django-rgd-imagery/rgd_imagery/admin/annotation.py b/django-rgd-imagery/rgd_imagery/admin/annotation.py
new file mode 100644
index 000000000..547fe4bae
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/admin/annotation.py
@@ -0,0 +1,50 @@
+from django.contrib import admin
+from django.contrib.gis.admin import OSMGeoAdmin
+from rgd.admin.mixins import MODIFIABLE_FILTERS
+from rgd_imagery.models import Annotation, PolygonSegmentation, RLESegmentation, Segmentation
+
+
+class SegmentationInline(admin.StackedInline):
+    model = Segmentation
+    fk_name = 'annotation'
+    list_display = ('pk',)
+    readonly_fields = ('outline',)
+
+
+class PolygonSegmentationInline(admin.StackedInline):
+    model = PolygonSegmentation
+    fk_name = 'annotation'
+    list_display = ('pk',)
+    readonly_fields = (
+        'outline',
+        'feature',
+    )
+
+
+class RLESegmentationInline(admin.StackedInline):
+    model = RLESegmentation
+    fk_name = 'annotation'
+    list_display = ('pk',)
+    readonly_fields = ('outline', 'width', 'height', 'blob')
+
+
+@admin.register(Annotation)
+class AnnotationAdmin(OSMGeoAdmin):
+    list_display = (
+        'pk',
+        'caption',
+        'label',
+        'annotator',
+        'segmentation_type',
+        'modified',
+        'created',
+    )
+    readonly_fields = (
+        'keypoints',
+        'line',
+    )
+    inlines = (SegmentationInline, PolygonSegmentationInline, RLESegmentationInline)
+    list_filter = MODIFIABLE_FILTERS + (
+        'annotator',
+        'label',
+    )
diff --git a/django-rgd-imagery/rgd_imagery/admin/base.py b/django-rgd-imagery/rgd_imagery/admin/base.py
new file mode 100644
index 000000000..76086155b
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/admin/base.py
@@ -0,0 +1,219 @@
+from django.contrib import admin
+
+# from django.contrib.admin import SimpleListFilter
+from django.contrib.gis.admin import OSMGeoAdmin
+from rgd.admin.mixins import (
+    MODIFIABLE_FILTERS,
+    SPATIAL_ENTRY_FILTERS,
+    TASK_EVENT_FILTERS,
+    TASK_EVENT_READONLY,
+    _FileGetNameMixin,
+    reprocess,
+)
+from rgd.utility import get_or_create_no_commit
+from rgd_imagery.models import (
+    BandMeta,
+    ConvertedImage,
+    Image,
+    ImageMeta,
+    ImageSet,
+    ImageSetSpatial,
+    Raster,
+)
+
+
+def _make_image_set_from_images(images):
+    """Images should be an iterable, not a queryset."""
+    imset = ImageSet()
+    imset.save()  # Have to save before adding to ManyToManyField?
+    for image in images:
+        imset.images.add(image)
+    imset.save()
+    return imset
+
+
+def make_image_set_from_images(modeladmin, request, queryset):
+    """Make an `ImageSet` of the selected `ImageMeta`s.
+
+    This is an action on `ImageMeta`.
+    """
+    return _make_image_set_from_images(queryset.all())
+
+
+def _make_raster_from_image_set(imset):
+    raster = Raster()
+    raster.image_set = imset
+    raster.save()
+    return raster
+
+
+def make_raster_from_images(modeladmin, request, queryset):
+    """Make a raster of the selected `ImageMeta`s.
+
+    This is an action on `ImageMeta`
+    """
+    imset = make_image_set_from_images(modeladmin, request, queryset)
+    return _make_raster_from_image_set(imset)
+
+
+def make_raster_from_image_set(modeladmin, request, queryset):
+    """Make a raster of the selected `ImageSet`.
+
+    This is an action on `ImageSet`.
+    """
+    rasters = []
+    for imset in queryset.all():
+        rasters.append(_make_raster_from_image_set(imset))
+    return rasters
+
+
+def make_raster_for_each_image(modeladmin, request, queryset):
+    """Make a raster for each of the selected `ImageMeta`s.
+
+    This is an action on `ImageMeta`.
+
+    This creates one raster for each image entry.
+    """
+    rasters = []
+    for img in queryset.all():
+        imset = _make_image_set_from_images(
+            [
+                img,
+            ]
+        )
+        rasters.append(_make_raster_from_image_set(imset))
+    return rasters
+
+
+def clean_empty_image_sets(modeladmin, request, queryset):
+    """Delete empty `ImageSet`s."""
+    for imset in queryset.all():
+        if len(imset.images) < 1:
+            imset.delete()
+
+
+def convert_images(modeladmin, request, queryset):
+    for image in queryset.all():
+        entry, created = get_or_create_no_commit(ConvertedImage, source_image=image)
+        entry.save()
+
+
+# class ImageSetSpatialFilter(SimpleListFilter):
+#     title = 'Spatial'
+#     parameter_name = 'imagesetspatial'
+#
+#     def lookups(self, request, model_admin):
+#         return [True, False]
+#
+#     def queryset(self, request, queryset):
+#         if not self.value():
+#             return queryset.filter(imagesetspatial__isnull=True)
+#         if self.value():
+#             return queryset.filter(imagesetspatial__isnull=False)
+
+
+class ImageSetSpatialInline(admin.StackedInline):
+    model = ImageSetSpatial
+    fk_name = 'image_set'
+    list_display = (
+        'pk',
+        'modified',
+        'created',
+    )
+    list_filter = MODIFIABLE_FILTERS + SPATIAL_ENTRY_FILTERS
+
+
+@admin.register(ImageSet)
+class ImageSetAdmin(OSMGeoAdmin):
+    list_display = (
+        'pk',
+        'name',
+        'count',
+        'modified',
+        'created',
+    )
+    actions = (
+        make_raster_from_image_set,
+        clean_empty_image_sets,
+    )
+    list_filter = MODIFIABLE_FILTERS  # (ImageSetSpatialFilter, )
+    inlines = (ImageSetSpatialInline,)
+
+
+class BandMetaInline(admin.StackedInline):
+    model = BandMeta
+    fk_name = 'parent_image'
+
+    list_display = (
+        'pk',
+        'parent_image',
+        'modified',
+        'created',
+    )
+    readonly_fields = (
+        'mean',
+        'max',
+        'min',
+        'modified',
+        'created',
+        'parent_image',
+        'std',
+        'nodata_value',
+        'dtype',
+        'band_number',
+    )
+
+    def has_add_permission(self, request, obj=None):
+        """Prevent user from adding more."""
+        return False
+
+
+class ImageMetaInline(admin.StackedInline):
+    model = ImageMeta
+    fk_name = 'parent_image'
+    list_display = (
+        'pk',
+        'modified',
+        'created',
+    )
+    readonly_fields = (
+        'number_of_bands',
+        'parent_image',
+        'height',
+        'width',
+        'driver',
+        'modified',
+        'created',
+    )
+    list_filter = MODIFIABLE_FILTERS + (
+        'number_of_bands',
+        'driver',
+    )
+
+
+@admin.register(Image)
+class ImageAdmin(OSMGeoAdmin, _FileGetNameMixin):
+    list_display = (
+        'pk',
+        'get_name',
+        'status',
+        'modified',
+        'created',
+        'image_data_link',
+    )
+    readonly_fields = (
+        'modified',
+        'created',
+    ) + TASK_EVENT_READONLY
+    actions = (
+        reprocess,
+        make_image_set_from_images,
+        make_raster_from_images,
+        make_raster_for_each_image,
+        convert_images,
+    )
+    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
+    inlines = (
+        ImageMetaInline,
+        BandMetaInline,
+    )
diff --git a/django-rgd-imagery/rgd_imagery/admin/kwcoco.py b/django-rgd-imagery/rgd_imagery/admin/kwcoco.py
new file mode 100644
index 000000000..dd15454e6
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/admin/kwcoco.py
@@ -0,0 +1,18 @@
+from django.contrib import admin
+from django.contrib.gis.admin import OSMGeoAdmin
+from rgd.admin.mixins import MODIFIABLE_FILTERS, TASK_EVENT_FILTERS, TASK_EVENT_READONLY, reprocess
+from rgd_imagery.models import KWCOCOArchive
+
+
+@admin.register(KWCOCOArchive)
+class KWCOCOArchiveAdmin(OSMGeoAdmin):
+    list_display = (
+        'pk',
+        'name',
+        'status',
+        'modified',
+        'created',
+    )
+    readonly_fields = ('image_set',) + TASK_EVENT_READONLY
+    actions = (reprocess,)
+    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
diff --git a/django-rgd-imagery/rgd_imagery/admin/processed.py b/django-rgd-imagery/rgd_imagery/admin/processed.py
new file mode 100644
index 000000000..7f3be9013
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/admin/processed.py
@@ -0,0 +1,33 @@
+from django.contrib import admin
+from django.contrib.gis.admin import OSMGeoAdmin
+from rgd.admin.mixins import MODIFIABLE_FILTERS, TASK_EVENT_FILTERS, TASK_EVENT_READONLY, reprocess
+from rgd_imagery.models import ConvertedImage, RegionImage
+
+
+@admin.register(ConvertedImage)
+class ConvertedImageAdmin(OSMGeoAdmin):
+    list_display = (
+        'pk',
+        'source_image',
+        'status',
+        'modified',
+        'created',
+    )
+    readonly_fields = ('processed_image',) + TASK_EVENT_READONLY
+    actions = (reprocess,)
+    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
+
+
+@admin.register(RegionImage)
+class RegionImageAdmin(OSMGeoAdmin):
+    list_display = (
+        'pk',
+        'source_image',
+        'sample_type',
+        'status',
+        'modified',
+        'created',
+    )
+    readonly_fields = ('processed_image',) + TASK_EVENT_READONLY
+    actions = (reprocess,)
+    list_filter = ('sample_type',) + MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
diff --git a/django-rgd-imagery/rgd_imagery/admin/raster.py b/django-rgd-imagery/rgd_imagery/admin/raster.py
new file mode 100644
index 000000000..255c95e94
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/admin/raster.py
@@ -0,0 +1,92 @@
+from django.contrib import admin
+from django.contrib.gis.admin import OSMGeoAdmin
+from rgd.admin.mixins import (
+    MODIFIABLE_FILTERS,
+    SPATIAL_ENTRY_FILTERS,
+    TASK_EVENT_FILTERS,
+    TASK_EVENT_READONLY,
+    reprocess,
+)
+from rgd_imagery.models import Raster, RasterMeta
+from rgd_imagery.tasks import jobs
+
+
+def reprocess_rastermeta(modeladmin, request, queryset):
+    """Trigger the save event task for each entry."""
+    for entry in queryset.all():
+        entry.parent_raster.save()
+
+
+def generate_valid_data_footprint(modeladmin, request, queryset):
+    """Generate a valid data footprint for each raster."""
+    for rast in queryset.all():
+        jobs.task_populate_raster_footprint.delay(rast.id)
+
+
+def generate_valid_data_footprint_rastermeta(modeladmin, request, queryset):
+    """Generate a valid data footprint for each raster."""
+    for entry in queryset.all():
+        jobs.task_populate_raster_footprint.delay(entry.parent_raster.id)
+
+
+def generate_outline(modeladmin, request, queryset):
+    """Generate a the outline for each raster."""
+    for rast in queryset.all():
+        jobs.task_populate_raster_outline.delay(rast.id)
+
+
+def clean_empty_rasters(modeladmin, request, queryset):
+    """Delete if associated `ImageSet` is empty."""
+    for raster in queryset.all():
+        if len(raster.image_set.images) < 1:
+            raster.image_set.delete()
+
+
+@admin.register(RasterMeta)
+class RasterMetaAdmin(OSMGeoAdmin):
+    list_display = (
+        'pk',
+        'name',
+        'acquisition_date',
+        'modified',
+        'created',
+    )
+    readonly_fields = (
+        'crs',
+        'origin',
+        'extent',
+        'resolution',
+        'transform',
+        'parent_raster',
+        'modified',
+        'created',
+    )
+    actions = (
+        reprocess_rastermeta,
+        generate_valid_data_footprint_rastermeta,
+    )
+    list_filter = SPATIAL_ENTRY_FILTERS + MODIFIABLE_FILTERS
+
+    modifiable = False  # To still show the footprint and outline
+
+
+@admin.register(Raster)
+class RasterAdmin(OSMGeoAdmin):
+    list_display = (
+        'pk',
+        'name',
+        'status',
+        'count',
+        'modified',
+        'created',
+    )
+    readonly_fields = (
+        'modified',
+        'created',
+    ) + TASK_EVENT_READONLY
+    actions = (
+        reprocess,
+        generate_valid_data_footprint,
+        clean_empty_rasters,
+    )
+    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
diff --git a/django-rgd-imagery/rgd_imagery/apps.py b/django-rgd-imagery/rgd_imagery/apps.py
new file mode 100644
index 000000000..2432e4bb0
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/apps.py
@@ -0,0 +1,9 @@
+from django.apps import AppConfig
+
+
+class RGDImageryConfig(AppConfig):
+    default_auto_field = 'django.db.models.AutoField'
+    name = 'rgd_imagery'
+
+    def ready(self):
+        import rgd_imagery.signals  # noqa: F401
diff --git a/django-rgd-imagery/rgd_imagery/filters.py b/django-rgd-imagery/rgd_imagery/filters.py
new file mode 100644
index 000000000..bb9fb368d
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/filters.py
@@ -0,0 +1,63 @@
+from django.contrib.gis import forms
+from django.db.models import Q, Sum
+from django_filters import rest_framework as filters
+from rgd.filters import SpatialEntryFilter
+from rgd_imagery.models import RasterMeta
+
+
+class RasterMetaFilter(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.
+
+        Annotates the queryset with `num_bands`.
+        """
+        if value is not None:
+            queryset = queryset.annotate(
+                num_bands=Sum('parent_raster__image_set__images__imagemeta__number_of_bands')
+            )
+            if value.start is not None:
+                queryset = queryset.filter(num_bands__gte=value.start)
+            if value.stop is not None:
+                queryset = queryset.filter(num_bands__lte=value.stop)
+        return queryset
+
+    def filter_resolution(self, queryset, name, value):
+        """Filter by the resolution in the raster data."""
+        if value is not None:
+            if value.start is not None:
+                queryset = queryset.filter(
+                    Q(resolution__0__gte=value.start) & Q(resolution__1__gte=value.start)
+                )
+            if value.stop is not None:
+                queryset = queryset.filter(
+                    Q(resolution__0__lte=value.stop) & Q(resolution__1__lte=value.stop)
+                )
+        return queryset
+
+    class Meta:
+        model = RasterMeta
+        fields = [
+            'num_bands',
+            'resolution',
+            'cloud_cover',
+        ]
diff --git a/rgd/geodata/models/imagery/large_image_utilities.py b/django-rgd-imagery/rgd_imagery/large_image_utilities.py
similarity index 81%
rename from rgd/geodata/models/imagery/large_image_utilities.py
rename to django-rgd-imagery/rgd_imagery/large_image_utilities.py
index 4af81f986..24fceabde 100644
--- a/rgd/geodata/models/imagery/large_image_utilities.py
+++ b/django-rgd-imagery/rgd_imagery/large_image_utilities.py
@@ -2,19 +2,16 @@
 from large_image.tilesource import FileTileSource
 from large_image_source_gdal import GDALFileTileSource
 from large_image_source_pil import PILFileTileSource
+from rgd_imagery.models import Image
 
-from .base import ImageEntry
 
-
-def get_tilesource_from_image_entry(
-    image_entry: ImageEntry, projection: str = None
-) -> FileTileSource:
+def get_tilesource_from_image(image: Image, projection: str = None) -> FileTileSource:
     # Make sure projection is None by default to use source projection
     try:
-        file_path = image_entry.image_file.file.get_vsi_path(internal=True)
+        file_path = image.file.get_vsi_path(internal=True)
         return GDALFileTileSource(file_path, projection=projection, encoding='PNG')
     except TileSourceException:
-        with image_entry.image_file.file.yield_local_path() as file_path:
+        with image.file.yield_local_path() as file_path:
             return PILFileTileSource(file_path)
 
 
diff --git a/django-rgd-imagery/rgd_imagery/management/commands/_data_helper.py b/django-rgd-imagery/rgd_imagery/management/commands/_data_helper.py
new file mode 100644
index 000000000..6502e08f7
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/management/commands/_data_helper.py
@@ -0,0 +1,173 @@
+from datetime import datetime
+from functools import reduce
+import json
+import logging
+
+import dateutil.parser
+from django.contrib.gis.geos import GEOSGeometry
+from django.db.models import Count
+from django.utils.timezone import make_aware
+from rgd.datastore import datastore
+from rgd.management.commands._data_helper import (
+    _get_or_create_checksum_file,
+    _get_or_create_checksum_file_url,
+    _get_or_create_file_model,
+    _save_signal,
+)
+from rgd.utility import get_or_create_no_commit
+from rgd_imagery import models
+from rgd_imagery.tasks import etl
+from shapely.geometry import shape
+from shapely.wkb import dumps
+
+logger = logging.getLogger(__name__)
+
+
+def make_raster_dict(
+    images,
+    name=None,
+    date=None,
+    cloud_cover=None,
+    ancillary_files=None,
+    instrumentation=None,
+):
+    if not isinstance(images[0], tuple):
+        images = [(None, im) for im in images]
+    return {
+        # images is expect to be a list of tuples
+        'images': images,
+        'name': name,
+        'acquisition_date': date,
+        'cloud_cover': cloud_cover,
+        # ancillary is expected to be list of tuples for URL files ONLY
+        'ancillary_files': ancillary_files,
+        'instrumentation': instrumentation,
+    }
+
+
+def load_images(images):
+    ids = []
+    for name, imfile in images:
+        # Run `load_image` sequentially to ensure `Image` is generated
+        entry = _get_or_create_file_model(models.Image, imfile, name=name)
+        ids.append(entry.pk)
+    return ids
+
+
+def _get_or_create_image_set(image_ids):
+    # Check if an ImageSet already exists containing all of these images
+    q = models.ImageSet.objects.annotate(count=Count('images')).filter(count=len(image_ids))
+    imsets = reduce(lambda p, id: q.filter(images=id), image_ids, q).values()
+    if len(imsets) > 0:
+        # Grab first, could be N-many
+        imset = models.ImageSet.objects.get(id=imsets[0]['id'])
+    else:
+        images = models.Image.objects.filter(pk__in=image_ids).all()
+        imset = models.ImageSet()
+        imset.save()  # Have to save before adding to ManyToManyField
+        for image in images:
+            imset.images.add(image)
+        imset.save()
+    return imset
+
+
+def load_raster(pks, raster_dict, footprint=False):
+    if not isinstance(pks, (list, tuple)):
+        pks = [
+            pks,
+        ]
+    imset = _get_or_create_image_set(pks)
+    # Make raster of that image set
+    raster, created = get_or_create_no_commit(models.Raster, image_set=imset)
+    _save_signal(raster, created)
+
+    # Add optional metadata
+    date = raster_dict.get('acquisition_date', None)
+    cloud_cover = raster_dict.get('cloud_cover', None)
+    name = raster_dict.get('name', None)
+    ancillary = raster_dict.get('ancillary_files', None)
+    instrumentation = raster_dict.get('instrumentation', None)
+    if date:
+        adt = dateutil.parser.isoparser().isoparse(date)
+        try:
+            raster.rastermeta.acquisition_date = make_aware(adt)
+        except ValueError:
+            raster.rastermeta.acquisition_date = adt
+        raster.rastermeta.save(
+            update_fields=[
+                'acquisition_date',
+            ]
+        )
+    if cloud_cover:
+        raster.rastermeta.cloud_cover = cloud_cover
+        raster.rastermeta.save(
+            update_fields=[
+                'cloud_cover',
+            ]
+        )
+    if name:
+        raster.name = name
+        raster.save(
+            update_fields=[
+                'name',
+            ]
+        )
+    if ancillary:
+        [
+            raster.ancillary_files.add(_get_or_create_checksum_file_url(af, name=path))
+            for path, af in ancillary
+        ]
+    if instrumentation:
+        raster.rastermeta.instrumentation = instrumentation
+        raster.rastermeta.save(
+            update_fields=[
+                'instrumentation',
+            ]
+        )
+    if footprint:
+        etl.populate_raster_footprint(raster.id)
+    return raster
+
+
+def load_raster_files(raster_dicts, footprint=False):
+    ids = []
+    count = len(raster_dicts)
+    for i, rf in enumerate(raster_dicts):
+        logger.info(f'Processesing raster {i+1} of {count}')
+        start_time = datetime.now()
+        imentries = load_images(rf.get('images'))
+        raster = load_raster(imentries, rf, footprint=footprint)
+        ids.append(raster.pk)
+        logger.info('\t Loaded raster in: {}'.format(datetime.now() - start_time))
+    return ids
+
+
+def load_kwcoco_archives(archives):
+    ids = []
+    for fspec, farch in archives:
+        spec = _get_or_create_checksum_file(fspec)
+        arch = _get_or_create_checksum_file(farch)
+        ds, created = get_or_create_no_commit(
+            models.KWCOCOArchive, spec_file=spec, image_archive=arch
+        )
+        _save_signal(ds, created)
+        ids.append(ds.id)
+    return ids
+
+
+def load_spatial_image_sets(image_sets):
+    for value in image_sets:
+        image_files, loc_file = value
+        path = datastore.fetch(loc_file)
+        # Load JSON image
+        with open(path, 'r') as f:
+            geom = shape(json.loads(f.read())['geometry'])
+        feature = GEOSGeometry(memoryview(dumps(geom)))
+        # Load image entries
+        image_ids = load_images(list(zip([None] * len(image_files), image_files)))
+        imset = _get_or_create_image_set(image_ids)
+        # Make an ImageSetSpatial
+        imset_spatial, _ = get_or_create_no_commit(models.ImageSetSpatial, image_set=imset)
+        imset_spatial.footprint = feature
+        imset_spatial.outline = feature.convex_hull
+        imset_spatial.save()
diff --git a/rgd/geodata/management/commands/demo_data.py b/django-rgd-imagery/rgd_imagery/management/commands/rgd_imagery_demo.py
similarity index 80%
rename from rgd/geodata/management/commands/demo_data.py
rename to django-rgd-imagery/rgd_imagery/management/commands/rgd_imagery_demo.py
index c402576bb..6f756337a 100644
--- a/rgd/geodata/management/commands/demo_data.py
+++ b/django-rgd-imagery/rgd_imagery/management/commands/rgd_imagery_demo.py
@@ -1,3 +1,5 @@
+from rgd.management.commands._data_helper import SynchronousTasksCommand
+
 from . import _data_helper as helper
 
 SUCCESS_MSG = 'Finished loading all demo data.'
@@ -31,27 +33,7 @@
     # ['US_eMAH_NDVI.2020.350-356.1KM.VI_QUAL.006.2020359165956.tif'],
     ['TC_NG_SFBay_US_Geo.tif'],
 ]
-SHAPE_FILES = [
-    'Streams.zip',
-    'Watershedt.zip',
-    'MuniBounds.zip',
-    'lm_cnty.zip',
-    'dlwatersan.zip',
-    'dlschool.zip',
-    'dlpark.zip',
-    'dlmetro.zip',
-    'dllibrary.zip',
-    'dlhospital.zip',
-    'dlfire.zip',
-    'Solid_Mineral_lease_1.zip',
-    'AG_lease.zip',
-]
-FMV_FILES = []
 KWCOCO_ARCHIVES = [['demo.kwcoco.json', 'demodata.zip'], ['demo_rle.kwcoco.json', 'demo_rle.zip']]
-RASTER_URLS = []
-POINT_CLOUD_FILES = [
-    'topo.vtk',
-]
 SPATIAL_IMAGE_SETS = [
     (
         ['afie_1.jpg', 'afie_2.jpg', 'afie_3.jpg'],
@@ -60,7 +42,7 @@
 ]
 
 
-class Command(helper.SynchronousTasksCommand):
+class Command(SynchronousTasksCommand):
     help = 'Populate database with demo data.'
 
     def add_arguments(self, parser):
@@ -80,10 +62,7 @@ def handle(self, *args, **options):
         helper.load_raster_files(
             [helper.make_raster_dict(im) for im in RASTER_FILES], footprint=footprint
         )
-        helper.load_shape_files(SHAPE_FILES)
-        helper.load_fmv_files(FMV_FILES)
         helper.load_kwcoco_archives(KWCOCO_ARCHIVES)
-        helper.load_point_cloud_files(POINT_CLOUD_FILES)
         helper.load_spatial_image_sets(SPATIAL_IMAGE_SETS)
         self.stdout.write(self.style.SUCCESS(SUCCESS_MSG))
         self.reset_celery()
diff --git a/rgd/geodata/management/commands/s3_landsat.py b/django-rgd-imagery/rgd_imagery/management/commands/rgd_imagery_landsat_rgb_s3.py
similarity index 93%
rename from rgd/geodata/management/commands/s3_landsat.py
rename to django-rgd-imagery/rgd_imagery/management/commands/rgd_imagery_landsat_rgb_s3.py
index fa384f785..983d84c1a 100644
--- a/rgd/geodata/management/commands/s3_landsat.py
+++ b/django-rgd-imagery/rgd_imagery/management/commands/rgd_imagery_landsat_rgb_s3.py
@@ -1,6 +1,7 @@
 import json
 
-from rgd.geodata import datastore
+from rgd import datastore
+from rgd.management.commands._data_helper import SynchronousTasksCommand
 
 from . import _data_helper as helper
 
@@ -32,7 +33,7 @@ def _get_landsat_urls(count):
     return rasters
 
 
-class Command(helper.SynchronousTasksCommand):
+class Command(SynchronousTasksCommand):
     help = 'Populate database with demo landsat data from S3.'
 
     def add_arguments(self, parser):
diff --git a/django-rgd-imagery/rgd_imagery/migrations/0001_initial.py b/django-rgd-imagery/rgd_imagery/migrations/0001_initial.py
new file mode 100644
index 000000000..c27c3b020
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/migrations/0001_initial.py
@@ -0,0 +1,679 @@
+# Generated by Django 3.2.4 on 2021-06-28 19:52
+
+import django.contrib.gis.db.models.fields
+import django.contrib.postgres.fields
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import django_extensions.db.fields
+import rgd.models.mixins
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('rgd', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Annotation',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('caption', models.CharField(blank=True, max_length=100, null=True)),
+                ('label', models.CharField(blank=True, max_length=100, null=True)),
+                ('annotator', models.CharField(blank=True, max_length=100, null=True)),
+                ('notes', models.TextField(blank=True, null=True)),
+                (
+                    'keypoints',
+                    django.contrib.gis.db.models.fields.MultiPointField(null=True, srid=0),
+                ),
+                ('line', django.contrib.gis.db.models.fields.LineStringField(null=True, srid=0)),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='Image',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                ('failure_reason', models.TextField(null=True)),
+                (
+                    'status',
+                    models.CharField(
+                        choices=[
+                            ('created', 'Created but not queued'),
+                            ('queued', 'Queued for processing'),
+                            ('running', 'Processing'),
+                            ('failed', 'Failed'),
+                            ('success', 'Succeeded'),
+                        ],
+                        default='created',
+                        max_length=20,
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                (
+                    'file',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='+',
+                        to='rgd.checksumfile',
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='ImageSet',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('name', models.CharField(blank=True, max_length=1000)),
+                ('description', models.TextField(blank=True, null=True)),
+                ('images', models.ManyToManyField(to='rgd_imagery.Image')),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='Raster',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                ('failure_reason', models.TextField(null=True)),
+                (
+                    'status',
+                    models.CharField(
+                        choices=[
+                            ('created', 'Created but not queued'),
+                            ('queued', 'Queued for processing'),
+                            ('running', 'Processing'),
+                            ('failed', 'Failed'),
+                            ('success', 'Succeeded'),
+                        ],
+                        default='created',
+                        max_length=20,
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('name', models.CharField(blank=True, max_length=1000)),
+                ('description', models.TextField(blank=True, null=True)),
+                ('ancillary_files', models.ManyToManyField(blank=True, to='rgd.ChecksumFile')),
+                (
+                    'image_set',
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.CASCADE, to='rgd_imagery.imageset'
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='Segmentation',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                (
+                    'outline',
+                    django.contrib.gis.db.models.fields.PolygonField(
+                        help_text='The bounding box', null=True, srid=0
+                    ),
+                ),
+                (
+                    'annotation',
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.CASCADE, to='rgd_imagery.annotation'
+                    ),
+                ),
+            ],
+        ),
+        migrations.CreateModel(
+            name='PolygonSegmentation',
+            fields=[
+                (
+                    'segmentation_ptr',
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to='rgd_imagery.segmentation',
+                    ),
+                ),
+                (
+                    'feature',
+                    django.contrib.gis.db.models.fields.MultiPolygonField(null=True, srid=0),
+                ),
+            ],
+            bases=('rgd_imagery.segmentation',),
+        ),
+        migrations.CreateModel(
+            name='RLESegmentation',
+            fields=[
+                (
+                    'segmentation_ptr',
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to='rgd_imagery.segmentation',
+                    ),
+                ),
+                ('blob', models.BinaryField()),
+                ('height', models.PositiveIntegerField()),
+                ('width', models.PositiveIntegerField()),
+            ],
+            bases=('rgd_imagery.segmentation',),
+        ),
+        migrations.CreateModel(
+            name='RegionImage',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                ('failure_reason', models.TextField(null=True)),
+                (
+                    'status',
+                    models.CharField(
+                        choices=[
+                            ('created', 'Created but not queued'),
+                            ('queued', 'Queued for processing'),
+                            ('running', 'Processing'),
+                            ('failed', 'Failed'),
+                            ('success', 'Succeeded'),
+                        ],
+                        default='created',
+                        max_length=20,
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                (
+                    'sample_type',
+                    models.CharField(
+                        choices=[
+                            ('pixel box', 'Pixel bounding box'),
+                            ('geographic box', 'Geographic bounding box'),
+                            ('geojson', 'GeoJSON feature'),
+                            ('annotation', 'Annotation entry'),
+                        ],
+                        default='pixel box',
+                        max_length=20,
+                    ),
+                ),
+                ('sample_parameters', models.JSONField()),
+                (
+                    'processed_image',
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name='+',
+                        to='rgd_imagery.image',
+                    ),
+                ),
+                (
+                    'source_image',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='+',
+                        to='rgd_imagery.image',
+                    ),
+                ),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='RasterMeta',
+            fields=[
+                (
+                    'spatialentry_ptr',
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to='rgd.spatialentry',
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('crs', models.TextField(help_text='PROJ string')),
+                (
+                    'origin',
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.FloatField(), size=2
+                    ),
+                ),
+                (
+                    'extent',
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.FloatField(), size=4
+                    ),
+                ),
+                (
+                    'resolution',
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.FloatField(), size=2
+                    ),
+                ),
+                (
+                    'transform',
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.FloatField(), size=6
+                    ),
+                ),
+                (
+                    'cloud_cover',
+                    models.FloatField(
+                        blank=True,
+                        null=True,
+                        validators=[
+                            django.core.validators.MinValueValidator(0),
+                            django.core.validators.MaxValueValidator(100),
+                        ],
+                    ),
+                ),
+                (
+                    'parent_raster',
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.CASCADE, to='rgd_imagery.raster'
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(
+                'rgd.spatialentry',
+                models.Model,
+                rgd.models.mixins.PermissionPathMixin,
+                rgd.models.mixins.DetailViewMixin,
+            ),
+        ),
+        migrations.CreateModel(
+            name='KWCOCOArchive',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                ('failure_reason', models.TextField(null=True)),
+                (
+                    'status',
+                    models.CharField(
+                        choices=[
+                            ('created', 'Created but not queued'),
+                            ('queued', 'Queued for processing'),
+                            ('running', 'Processing'),
+                            ('failed', 'Failed'),
+                            ('success', 'Succeeded'),
+                        ],
+                        default='created',
+                        max_length=20,
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('name', models.CharField(blank=True, max_length=1000)),
+                ('description', models.TextField(blank=True, null=True)),
+                (
+                    'image_archive',
+                    models.OneToOneField(
+                        help_text='An archive (.tar or .zip) of the images referenced by the spec file (optional).',
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='kwcoco_image_archive',
+                        to='rgd.checksumfile',
+                    ),
+                ),
+                (
+                    'image_set',
+                    models.OneToOneField(
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to='rgd_imagery.imageset',
+                    ),
+                ),
+                (
+                    'spec_file',
+                    models.OneToOneField(
+                        help_text='The JSON spec file.',
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='kwcoco_spec_file',
+                        to='rgd.checksumfile',
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='ImageSetSpatial',
+            fields=[
+                (
+                    'spatialentry_ptr',
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to='rgd.spatialentry',
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                (
+                    'image_set',
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.CASCADE, to='rgd_imagery.imageset'
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(
+                'rgd.spatialentry',
+                models.Model,
+                rgd.models.mixins.PermissionPathMixin,
+                rgd.models.mixins.DetailViewMixin,
+            ),
+        ),
+        migrations.CreateModel(
+            name='ImageMeta',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('driver', models.CharField(max_length=100)),
+                ('height', models.PositiveIntegerField()),
+                ('width', models.PositiveIntegerField()),
+                ('number_of_bands', models.PositiveIntegerField()),
+                (
+                    'parent_image',
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.CASCADE, to='rgd_imagery.image'
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='ConvertedImage',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                ('failure_reason', models.TextField(null=True)),
+                (
+                    'status',
+                    models.CharField(
+                        choices=[
+                            ('created', 'Created but not queued'),
+                            ('queued', 'Queued for processing'),
+                            ('running', 'Processing'),
+                            ('failed', 'Failed'),
+                            ('success', 'Succeeded'),
+                        ],
+                        default='created',
+                        max_length=20,
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                (
+                    'processed_image',
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name='+',
+                        to='rgd_imagery.image',
+                    ),
+                ),
+                (
+                    'source_image',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='+',
+                        to='rgd_imagery.image',
+                    ),
+                ),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='BandMeta',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('band_number', models.IntegerField()),
+                (
+                    'description',
+                    models.TextField(
+                        blank=True,
+                        help_text='Automatically retreived from raster but can be overwritten.',
+                        null=True,
+                    ),
+                ),
+                ('dtype', models.CharField(max_length=10)),
+                ('max', models.FloatField(null=True)),
+                ('min', models.FloatField(null=True)),
+                ('mean', models.FloatField(null=True)),
+                ('std', models.FloatField(null=True)),
+                ('nodata_value', models.FloatField(null=True)),
+                ('interpretation', models.TextField()),
+                (
+                    'parent_image',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE, to='rgd_imagery.image'
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.AddField(
+            model_name='annotation',
+            name='image',
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE, to='rgd_imagery.image'
+            ),
+        ),
+    ]
diff --git a/django-rgd-imagery/rgd_imagery/migrations/__init__.py b/django-rgd-imagery/rgd_imagery/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/rgd/geodata/models/imagery/__init__.py b/django-rgd-imagery/rgd_imagery/models/__init__.py
similarity index 64%
rename from rgd/geodata/models/imagery/__init__.py
rename to django-rgd-imagery/rgd_imagery/models/__init__.py
index fcb271c7b..41566e5fd 100644
--- a/rgd/geodata/models/imagery/__init__.py
+++ b/django-rgd-imagery/rgd_imagery/models/__init__.py
@@ -8,8 +8,7 @@
 from .annotation import Annotation, PolygonSegmentation, RLESegmentation, Segmentation
 
 # flake8: noqa
-from .base import BandMetaEntry, ImageEntry, ImageFile, ImageSet, ImageSetSpatial
+from .base import BandMeta, Image, ImageMeta, ImageSet, ImageSetSpatial
 from .kwcoco import KWCOCOArchive
-from .large_image_utilities import *  # noqa
-from .processed import ConvertedImageFile, SubsampledImage
-from .raster import RasterEntry, RasterMetaEntry
+from .processed import ConvertedImage, RegionImage
+from .raster import Raster, RasterMeta
diff --git a/rgd/geodata/models/imagery/annotation.py b/django-rgd-imagery/rgd_imagery/models/annotation.py
similarity index 89%
rename from rgd/geodata/models/imagery/annotation.py
rename to django-rgd-imagery/rgd_imagery/models/annotation.py
index c38ba899d..17393beba 100644
--- a/rgd/geodata/models/imagery/annotation.py
+++ b/django-rgd-imagery/rgd_imagery/models/annotation.py
@@ -3,16 +3,17 @@
 
 from django.contrib.gis.db import models
 from django.core.exceptions import ObjectDoesNotExist
+from django_extensions.db.models import TimeStampedModel
 import numpy as np
+from rgd.models.mixins import PermissionPathMixin
 
-from ..common import ModifiableEntry
-from .base import ImageEntry
+from .base import Image
 
 
-class Annotation(ModifiableEntry):
-    """Image annotation/label for ``ImageEntry``."""
+class Annotation(TimeStampedModel, PermissionPathMixin):
+    """Image annotation/label for ``Image``."""
 
-    image = models.ForeignKey(ImageEntry, on_delete=models.CASCADE)
+    image = models.ForeignKey(Image, on_delete=models.CASCADE)
 
     caption = models.CharField(max_length=100, blank=True, null=True)
     label = models.CharField(max_length=100, blank=True, null=True)
@@ -26,6 +27,8 @@ def segmentation_type(self):
         """Get type of segmentation."""
         return self.segmentation.get_type()
 
+    permissions_paths = ['image__collection__collection_permissions']
+
 
 class Segmentation(models.Model):
     """A base class for segmentations as there are different kinds.
@@ -64,6 +67,8 @@ def get_type(self):
             pass
         return 'Oultine/BBox'
 
+    permissions_paths = ['annotation__image__collection__collection_permissions']
+
 
 class PolygonSegmentation(Segmentation):
     """one/many shape(s) in pixel coordinates (can be decimal).
diff --git a/rgd/geodata/models/imagery/base.py b/django-rgd-imagery/rgd_imagery/models/base.py
similarity index 60%
rename from rgd/geodata/models/imagery/base.py
rename to django-rgd-imagery/rgd_imagery/models/base.py
index bc5731f15..53581cc6f 100644
--- a/rgd/geodata/models/imagery/base.py
+++ b/django-rgd-imagery/rgd_imagery/models/base.py
@@ -1,24 +1,25 @@
 """Base classes for raster dataset entries."""
 from django.contrib.gis.db import models
+from django_extensions.db.models import TimeStampedModel
+from rgd.models import ChecksumFile, SpatialEntry
+from rgd.models.mixins import DetailViewMixin, PermissionPathMixin, TaskEventMixin
+from rgd_imagery.tasks import jobs
 
-from ... import tasks
-from ..common import ChecksumFile, ModifiableEntry, SpatialEntry
-from ..mixins import TaskEventMixin
 
-
-class ImageFile(ModifiableEntry, TaskEventMixin):
+class Image(TimeStampedModel, TaskEventMixin, PermissionPathMixin):
     """This is a standalone DB entry for image files.
 
     This points to a single image file in an S3 file field.
 
-    This will automatically generate an ``ImageEntry`` on the ``post_save``
+    This will automatically generate an ``ImageMeta`` on the ``post_save``
     event. This points to data in its original location and the generated
-    ``ImageEntry`` points to this.
+    ``ImageMeta`` points to this.
 
     """
 
-    task_funcs = (tasks.task_read_image_file,)
-    file = models.ForeignKey(ChecksumFile, on_delete=models.CASCADE)
+    permissions_paths = ['file__collection__collection_permissions']
+    task_funcs = (jobs.task_load_image,)
+    file = models.ForeignKey(ChecksumFile, on_delete=models.CASCADE, related_name='+')
 
     def image_data_link(self):
         return self.file.data_link()
@@ -26,26 +27,23 @@ def image_data_link(self):
     image_data_link.allow_tags = True
 
 
-class ImageEntry(ModifiableEntry):
+class ImageMeta(TimeStampedModel, PermissionPathMixin):
     """Single image entry, tracks the original file."""
 
-    def __str__(self):
-        return f'{self.name} ({self.id})'
-
-    name = models.CharField(max_length=1000, blank=True)
-    description = models.TextField(null=True, blank=True)
+    permissions_paths = ['parent_image__file__collection__collection_permissions']
 
-    image_file = models.OneToOneField(ImageFile, on_delete=models.CASCADE)
+    parent_image = models.OneToOneField(Image, on_delete=models.CASCADE)
     driver = models.CharField(max_length=100)
     height = models.PositiveIntegerField()
     width = models.PositiveIntegerField()
     number_of_bands = models.PositiveIntegerField()
 
 
-class BandMetaEntry(ModifiableEntry):
+class BandMeta(TimeStampedModel, PermissionPathMixin):
     """A basic container to keep track of useful band info."""
 
-    parent_image = models.ForeignKey(ImageEntry, on_delete=models.CASCADE)
+    permissions_paths = ['parent_image__file__collection__collection_permissions']
+    parent_image = models.ForeignKey(Image, on_delete=models.CASCADE)
     band_number = models.IntegerField()
     description = models.TextField(
         null=True,
@@ -61,16 +59,15 @@ class BandMetaEntry(ModifiableEntry):
     interpretation = models.TextField()
 
 
-class ImageSet(ModifiableEntry):
+class ImageSet(TimeStampedModel, PermissionPathMixin):
     """Container for many images."""
 
-    def __str__(self):
-        return f'{self.name} ({self.id} - {type(self)}'
+    permissions_paths = ['images__file__collection__collection_permissions']
 
     name = models.CharField(max_length=1000, blank=True)
     description = models.TextField(null=True, blank=True)
 
-    images = models.ManyToManyField(ImageEntry)
+    images = models.ManyToManyField(Image)
 
     @property
     def image_bands(self):
@@ -95,10 +92,14 @@ def get_all_annotations(self):
         return annots
 
 
-class ImageSetSpatial(ModifiableEntry, SpatialEntry):
+class ImageSetSpatial(TimeStampedModel, SpatialEntry, PermissionPathMixin, DetailViewMixin):
     """Arbitrary register an ImageSet to a location."""
 
-    name = models.CharField(max_length=1000, blank=True)
-    description = models.TextField(null=True, blank=True)
+    permissions_paths = ['image_set__images__file__collection__collection_permissions']
+    detail_view_name = 'image-set-spatial-detail'
 
     image_set = models.OneToOneField(ImageSet, on_delete=models.CASCADE)
+
+    @property
+    def name(self):
+        return self.image_set.name
diff --git a/rgd/geodata/models/imagery/kwcoco.py b/django-rgd-imagery/rgd_imagery/models/kwcoco.py
similarity index 66%
rename from rgd/geodata/models/imagery/kwcoco.py
rename to django-rgd-imagery/rgd_imagery/models/kwcoco.py
index 5a9188eff..898c6de33 100644
--- a/rgd/geodata/models/imagery/kwcoco.py
+++ b/django-rgd-imagery/rgd_imagery/models/kwcoco.py
@@ -1,12 +1,13 @@
 from django.contrib.gis.db import models
+from django_extensions.db.models import TimeStampedModel
+from rgd.models import ChecksumFile
+from rgd.models.mixins import PermissionPathMixin, TaskEventMixin
+from rgd_imagery.tasks import jobs
 
-from ... import tasks
-from ..common import ChecksumFile, ModifiableEntry
-from ..mixins import TaskEventMixin
 from .base import ImageSet
 
 
-class KWCOCOArchive(ModifiableEntry, TaskEventMixin):
+class KWCOCOArchive(TimeStampedModel, TaskEventMixin, PermissionPathMixin):
     """A container for holding imported KWCOCO datasets.
 
     User must upload a JSON file of the KWCOCO meta info and an optional
@@ -15,8 +16,9 @@ class KWCOCOArchive(ModifiableEntry, TaskEventMixin):
 
     """
 
-    task_funcs = (tasks.task_load_kwcoco_dataset,)
+    task_funcs = (jobs.task_load_kwcoco_dataset,)
     name = models.CharField(max_length=1000, blank=True)
+    description = models.TextField(null=True, blank=True)
     spec_file = models.OneToOneField(
         ChecksumFile,
         on_delete=models.CASCADE,
@@ -38,7 +40,12 @@ def _post_delete(self, *args, **kwargs):
         #  this will cascade to the annotations
         images = self.image_set.images.all()
         for image in images:
-            # This should cascade to the ImageFile and the ImageEntry
-            image.image_file.file.delete()
+            # This should cascade to the Image and the ImageMeta
+            image.file.delete()
         # Now delete the empty image set
         self.image_set.delete()
+
+    permissions_paths = [
+        'spec_file__collection__collection_permissions',
+        'image_archive__collection__collection_permissions',
+    ]
diff --git a/rgd/geodata/models/imagery/processed.py b/django-rgd-imagery/rgd_imagery/models/processed.py
similarity index 58%
rename from rgd/geodata/models/imagery/processed.py
rename to django-rgd-imagery/rgd_imagery/models/processed.py
index 0f6c2eef7..bee8e3c1b 100644
--- a/rgd/geodata/models/imagery/processed.py
+++ b/django-rgd-imagery/rgd_imagery/models/processed.py
@@ -1,32 +1,44 @@
 from django.contrib.gis.db import models
 from django.contrib.gis.geos import GEOSGeometry
 from django.utils.translation import gettext_lazy as _
+from django_extensions.db.models import TimeStampedModel
+from rgd.models.mixins import PermissionPathMixin, TaskEventMixin
+from rgd_imagery.tasks import jobs
 from shapely.geometry import shape
 from shapely.wkb import dumps
 
-from ... import tasks
-from ..common import ChecksumFile, ModifiableEntry
-from ..mixins import TaskEventMixin
-from .base import ImageEntry
+from .base import Image
 
 
-class ConvertedImageFile(ModifiableEntry, TaskEventMixin):
-    """A model to store converted versions of a raster entry."""
+class ProcessedImage(TimeStampedModel, TaskEventMixin, PermissionPathMixin):
+    """Base class for processed images."""
 
-    task_funcs = (tasks.task_convert_to_cog,)
-    converted_file = models.OneToOneField(ChecksumFile, on_delete=models.SET_NULL, null=True)
-    source_image = models.OneToOneField(ImageEntry, on_delete=models.CASCADE)
+    class Meta:
+        abstract = True
+
+    processed_image = models.ForeignKey(
+        Image, on_delete=models.SET_NULL, null=True, related_name='+'
+    )
+    source_image = models.ForeignKey(Image, on_delete=models.CASCADE, related_name='+')
 
     def _post_delete(self, *args, **kwargs):
-        # Cleanup the associated ChecksumFile
-        if self.converted_file:
-            self.converted_file.delete()
+        # Cleanup the associated Image and ChecksumFile
+        if self.processed_image:
+            self.processed_image.file.delete()
+
+    permissions_paths = ['source_image__file__collection__collection_permissions']
+
+
+class ConvertedImage(ProcessedImage):
+    """A model to store converted versions of an Image."""
 
+    task_funcs = (jobs.task_convert_to_cog,)
 
-class SubsampledImage(ModifiableEntry, TaskEventMixin):
-    """A subsample of an ImageEntry."""
 
-    task_funcs = (tasks.task_populate_subsampled_image,)
+class RegionImage(ProcessedImage):
+    """A sub region of an Image."""
+
+    task_funcs = (jobs.task_populate_region_image,)
 
     class SampleTypes(models.TextChoices):
         PIXEL_BOX = 'pixel box', _('Pixel bounding box')
@@ -34,14 +46,11 @@ class SampleTypes(models.TextChoices):
         GEOJSON = 'geojson', _('GeoJSON feature')
         ANNOTATION = 'annotation', _('Annotation entry')
 
-    source_image = models.ForeignKey(ImageEntry, on_delete=models.CASCADE)
     sample_type = models.CharField(
         max_length=20, default=SampleTypes.PIXEL_BOX, choices=SampleTypes.choices
     )
     sample_parameters = models.JSONField()
 
-    data = models.OneToOneField(ChecksumFile, on_delete=models.SET_NULL, null=True)
-
     def get_extent(self):
         """Convert ``sample_parameters`` to length 4 tuple of XY extents.
 
@@ -58,23 +67,23 @@ def get_extent(self):
 
         projection = p.pop('projection', None)
         if self.sample_type in (
-            SubsampledImage.SampleTypes.PIXEL_BOX,
-            SubsampledImage.SampleTypes.ANNOTATION,
+            RegionImage.SampleTypes.PIXEL_BOX,
+            RegionImage.SampleTypes.ANNOTATION,
         ):
             projection = 'pixels'
 
         if self.sample_type in (
-            SubsampledImage.SampleTypes.GEO_BOX,
-            SubsampledImage.SampleTypes.PIXEL_BOX,
+            RegionImage.SampleTypes.GEO_BOX,
+            RegionImage.SampleTypes.PIXEL_BOX,
         ):
             return p['left'], p['right'], p['bottom'], p['top'], projection
-        elif self.sample_type == SubsampledImage.SampleTypes.GEOJSON:
+        elif self.sample_type == RegionImage.SampleTypes.GEOJSON:
             # Convert GeoJSON to extents
             geom = shape(p)
             feature = GEOSGeometry(memoryview(dumps(geom)))
             l, b, r, t = feature.extent  # (xmin, ymin, xmax, ymax)
             return l, r, b, t, projection
-        elif self.sample_type == SubsampledImage.SampleTypes.ANNOTATION:
+        elif self.sample_type == RegionImage.SampleTypes.ANNOTATION:
             from .annotation import Annotation
 
             ann_id = p['id']
@@ -83,7 +92,3 @@ def get_extent(self):
             return l, r, b, t, projection
         else:
             raise ValueError('Sample type ({}) unknown.'.format(self.sample_type))
-
-    def _post_delete(self, *args, **kwargs):
-        # Cleanup the associated ChecksumFile
-        self.data.delete()
diff --git a/rgd/geodata/models/imagery/raster.py b/django-rgd-imagery/rgd_imagery/models/raster.py
similarity index 57%
rename from rgd/geodata/models/imagery/raster.py
rename to django-rgd-imagery/rgd_imagery/models/raster.py
index c3391b5d0..1da64a29f 100644
--- a/rgd/geodata/models/imagery/raster.py
+++ b/django-rgd-imagery/rgd_imagery/models/raster.py
@@ -1,14 +1,16 @@
 from django.contrib.gis.db import models
 from django.contrib.postgres import fields
 from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db.models import Sum
+from django_extensions.db.models import TimeStampedModel
+from rgd.models import ChecksumFile, SpatialEntry
+from rgd.models.mixins import DetailViewMixin, PermissionPathMixin, TaskEventMixin
+from rgd_imagery.tasks import jobs
 
-from ... import tasks
-from ..common import ChecksumFile, ModifiableEntry, SpatialEntry
-from ..mixins import TaskEventMixin
 from .base import ImageSet
 
 
-class RasterEntry(ModifiableEntry, TaskEventMixin):
+class Raster(TimeStampedModel, TaskEventMixin, PermissionPathMixin):
     """This class is a container for the metadata of a raster.
 
     This model inherits from ``ImageSet`` and only adds an extra layer of
@@ -16,8 +18,7 @@ class RasterEntry(ModifiableEntry, TaskEventMixin):
 
     """
 
-    def __str__(self):
-        return 'ID: {} {} (type: {})'.format(self.id, self.name, type(self))
+    permissions_paths = ['image_set__images__file__collection__collection_permissions']
 
     name = models.CharField(max_length=1000, blank=True)
     description = models.TextField(null=True, blank=True)
@@ -26,37 +27,29 @@ def __str__(self):
     ancillary_files = models.ManyToManyField(ChecksumFile, blank=True)
 
     task_funcs = (
-        tasks.task_populate_raster_entry,
-        # tasks.task_populate_raster_footprint,
+        jobs.task_populate_raster,
+        # jobs.task_populate_raster_footprint,
     )
 
-    @property
-    def footprint(self):
-        """Pointer to RasterMetaEntry footprint."""
-        return self.rastermetaentry.footprint
-
-    @property
-    def outline(self):
-        """Pointer to RasterMetaEntry outline."""
-        return self.rastermetaentry.outline
-
-    @property
-    def acquisition_date(self):
-        """Pointer to RasterMetaEntry acquisition_date."""
-        return self.rastermetaentry.acquisition_date
-
     @property
     def count(self):
         """Get number of bands across all images in image set."""
-        n = 0
-        for im in self.image_set.images.all():
-            n += im.number_of_bands
+        n = (
+            ImageSet.objects.filter(pk=self.image_set.pk)
+            .annotate(num_bands=Sum('images__imagemeta__number_of_bands'))
+            .first()
+            .num_bands
+        )
         return n
 
 
-class RasterMetaEntry(ModifiableEntry, SpatialEntry):
+class RasterMeta(TimeStampedModel, SpatialEntry, PermissionPathMixin, DetailViewMixin):
+    permissions_paths = [
+        'parent_raster__image_set__images__file__collection__collection_permissions'
+    ]
+    detail_view_name = 'raster-entry-detail'
 
-    parent_raster = models.OneToOneField(RasterEntry, on_delete=models.CASCADE)
+    parent_raster = models.OneToOneField(Raster, on_delete=models.CASCADE)
 
     # Raster fields
     crs = models.TextField(help_text='PROJ string')  # PROJ String
diff --git a/django-rgd-imagery/rgd_imagery/rest/__init__.py b/django-rgd-imagery/rgd_imagery/rest/__init__.py
new file mode 100644
index 000000000..c5cac1fc5
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/rest/__init__.py
@@ -0,0 +1,9 @@
+__all__ = [
+    'download',
+    'get',
+    'post',
+    'search',
+    'tiles',
+]
+
+from . import download, get, post, search, tiles
diff --git a/django-rgd-imagery/rgd_imagery/rest/download.py b/django-rgd-imagery/rgd_imagery/rest/download.py
new file mode 100644
index 000000000..3b1aa3128
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/rest/download.py
@@ -0,0 +1,43 @@
+from django.http import HttpResponseRedirect
+from django.shortcuts import get_object_or_404
+from drf_yasg.utils import swagger_auto_schema
+from rest_framework.decorators import api_view
+from rgd.models import ChecksumFile
+from rgd.permissions import check_read_perm
+from rgd.rest.download import _get_status_response
+from rgd_imagery import models
+
+
+@swagger_auto_schema(
+    method='GET',
+    operation_summary='Download the associated Image data for this Image directly from S3.',
+)
+@api_view(['GET'])
+def download_image_file(request, pk):
+    instance = get_object_or_404(models.Image, pk=pk)
+    check_read_perm(request.user, instance)
+    url = instance.file.get_url()
+    return HttpResponseRedirect(url)
+
+
+@swagger_auto_schema(
+    method='GET',
+    operation_summary='Download the associated ChecksumFile data for this ConvertedImage directly from S3.',
+)
+@api_view(['GET'])
+def download_cog_file(request, pk):
+    instance = get_object_or_404(models.ConvertedImage, pk=pk)
+    check_read_perm(request.user, instance)
+    af_id = instance.processed_image.file.id
+    instance = get_object_or_404(ChecksumFile, pk=af_id)
+    return HttpResponseRedirect(instance.get_url())
+
+
+@swagger_auto_schema(
+    method='GET',
+    operation_summary='Check the status of RegionImage.',
+)
+@api_view(['GET'])
+def get_status_subsampled_image(request, pk):
+    """Get the status of any RegionImage model."""
+    return _get_status_response(request, 'RegionImage', pk)
diff --git a/django-rgd-imagery/rgd_imagery/rest/get.py b/django-rgd-imagery/rgd_imagery/rest/get.py
new file mode 100644
index 000000000..ecd729203
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/rest/get.py
@@ -0,0 +1,41 @@
+from rest_framework.generics import RetrieveAPIView
+from rgd.rest.get import _PermissionMixin
+from rgd_imagery import models, serializers
+
+
+class GetConvertedImageStatus(RetrieveAPIView, _PermissionMixin):
+    """Get the status of a ConvertedImage by PK."""
+
+    serializer_class = serializers.ConvertedImageSerializer
+    lookup_field = 'pk'
+    queryset = models.ConvertedImage.objects.all()
+
+
+class GetRegionImage(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.RegionImageSerializer
+    lookup_field = 'pk'
+    queryset = models.RegionImage.objects.all()
+
+
+class GetImageMeta(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.ImageMetaSerializer
+    lookup_field = 'pk'
+    queryset = models.ImageMeta.objects.all()
+
+
+class GetImageSet(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.ImageSetSerializer
+    lookup_field = 'pk'
+    queryset = models.ImageSet.objects.all()
+
+
+class GetRasterMeta(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.RasterMetaSerializer
+    lookup_field = 'pk'
+    queryset = models.RasterMeta.objects.all()
+
+
+class GetRasterMetaSTAC(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.STACRasterSerializer
+    lookup_field = 'pk'
+    queryset = models.RasterMeta.objects.all()
diff --git a/django-rgd-imagery/rgd_imagery/rest/post.py b/django-rgd-imagery/rgd_imagery/rest/post.py
new file mode 100644
index 000000000..7bee88448
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/rest/post.py
@@ -0,0 +1,17 @@
+from rest_framework.generics import CreateAPIView
+from rgd_imagery import models, serializers
+
+
+class CreateConvertedImage(CreateAPIView):
+    queryset = models.ConvertedImage.objects.all()
+    serializer_class = serializers.ConvertedImageSerializer
+
+
+class CreateRegionImage(CreateAPIView):
+    queryset = models.RegionImage.objects.all()
+    serializer_class = serializers.RegionImageSerializer
+
+
+class CreateRasterSTAC(CreateAPIView):
+    queryset = models.RasterMeta.objects.all()
+    serializer_class = serializers.STACRasterSerializer
diff --git a/django-rgd-imagery/rgd_imagery/rest/search.py b/django-rgd-imagery/rgd_imagery/rest/search.py
new file mode 100644
index 000000000..8cacb71bc
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/rest/search.py
@@ -0,0 +1,16 @@
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.generics import ListAPIView
+from rgd.permissions import filter_read_perm
+from rgd_imagery import serializers
+from rgd_imagery.filters import RasterMetaFilter
+from rgd_imagery.models import RasterMeta
+
+
+class SearchRasterMetaSTACView(ListAPIView):
+    queryset = RasterMeta.objects.all()
+    serializer_class = serializers.STACRasterSerializer
+    filter_backends = [DjangoFilterBackend]
+    filterset_class = RasterMetaFilter
+
+    def get_queryset(self):
+        return filter_read_perm(self.request.user, super().get_queryset())
diff --git a/rgd/geodata/api/tiles.py b/django-rgd-imagery/rgd_imagery/rest/tiles.py
similarity index 94%
rename from rgd/geodata/api/tiles.py
rename to django-rgd-imagery/rgd_imagery/rest/tiles.py
index b2d33584f..dd3c1940a 100644
--- a/rgd/geodata/api/tiles.py
+++ b/django-rgd-imagery/rgd_imagery/rest/tiles.py
@@ -5,18 +5,17 @@
 from rest_framework.request import Request
 from rest_framework.response import Response
 from rest_framework.views import APIView
-
-from rgd.geodata.models import ImageEntry
-from rgd.geodata.models.imagery import large_image_utilities
+from rgd_imagery import large_image_utilities
+from rgd_imagery.models import Image
 
 
 class BaseTileView(APIView):
     def get_tile_source(self, request: Request, pk: int) -> FileTileSource:
         """Return the built tile source."""
-        image_entry = get_object_or_404(ImageEntry, pk=pk)
+        image_entry = get_object_or_404(Image, pk=pk)
         self.check_object_permissions(request, image_entry)
         projection = request.query_params.get('projection', None)
-        return large_image_utilities.get_tilesource_from_image_entry(image_entry, projection)
+        return large_image_utilities.get_tilesource_from_image(image_entry, projection)
 
 
 class TileMetadataView(BaseTileView):
diff --git a/django-rgd-imagery/rgd_imagery/serializers.py b/django-rgd-imagery/rgd_imagery/serializers.py
new file mode 100644
index 000000000..5fcf69f57
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/serializers.py
@@ -0,0 +1,255 @@
+import json
+
+import dateutil.parser
+from django.contrib.gis.geos import Polygon
+from django.db import transaction
+from pyproj import CRS
+import pystac
+from rest_framework import serializers
+from rest_framework.reverse import reverse
+from rgd import utility
+from rgd.models import ChecksumFile, FileSourceType
+from rgd.permissions import check_write_perm
+from rgd.serializers import ChecksumFileSerializer, SpatialEntrySerializer
+
+from . import models
+
+
+class ImageSerializer(serializers.ModelSerializer):
+    file = ChecksumFileSerializer()
+
+    class Meta:
+        model = models.Image
+        fields = '__all__'
+
+
+class ImageMetaSerializer(serializers.ModelSerializer):
+    parent_image = ImageSerializer()
+
+    def to_representation(self, value):
+        ret = super().to_representation(value)
+        realtive_thumbnail_uri = reverse('image-thumbnail', args=[value.id])
+        if 'request' in self.context:
+            request = self.context['request']
+            ret['thumbnail'] = request.build_absolute_uri(realtive_thumbnail_uri)
+        else:
+            ret['thumbnail'] = realtive_thumbnail_uri
+        return ret
+
+    class Meta:
+        model = models.ImageMeta
+        fields = '__all__'
+        read_only_fields = [
+            'id',
+            'modified',
+            'created',
+            'driver',
+            'height',
+            'width',
+            'number_of_bands',
+        ]
+
+
+class ConvertedImageSerializer(serializers.ModelSerializer):
+
+    processed_image = ImageSerializer(read_only=True)
+
+    def validate_source_image(self, value):
+        if 'request' in self.context:
+            check_write_perm(self.context['request'].user, value)
+        return value
+
+    class Meta:
+        model = models.ConvertedImage
+        fields = '__all__'
+        read_only_fields = ['id', 'status', 'failure_reason', 'processed_image']
+
+
+class RegionImageSerializer(serializers.ModelSerializer):
+
+    processed_image = ImageSerializer(read_only=True)
+
+    def validate_source_image(self, value):
+        if 'request' in self.context:
+            check_write_perm(self.context['request'].user, value)
+        return value
+
+    def to_representation(self, value):
+        ret = super().to_representation(value)
+        realtive_status_uri = reverse('subsampled-status', args=[value.id])
+        if 'request' in self.context:
+            request = self.context['request']
+            ret['status'] = request.build_absolute_uri(realtive_status_uri)
+        else:
+            ret['status'] = realtive_status_uri
+        return ret
+
+    class Meta:
+        model = models.RegionImage
+        fields = '__all__'
+        read_only_fields = [
+            'id',
+            'status',
+            'failure_reason',
+            'processed_image',
+        ]
+
+    def create(self, validated_data):
+        """Prevent duplicated subsamples from being created."""
+        obj, created = models.RegionImage.objects.get_or_create(**validated_data)
+        if not created:
+            # Trigger save event to reprocess the subsampling
+            obj.save()
+        return obj
+
+
+class ImageSetSerializer(serializers.ModelSerializer):
+    images = ImageSerializer(many=True)
+
+    class Meta:
+        model = models.ImageSet
+        fields = '__all__'
+        read_only_fields = [
+            'id',
+            'modified',
+            'created',
+        ]
+
+
+class RasterSerializer(serializers.ModelSerializer):
+    image_set = ImageSetSerializer()
+    ancillary_files = ChecksumFileSerializer(many=True)
+
+    class Meta:
+        model = models.Raster
+        fields = '__all__'
+
+
+class RasterMetaSerializer(SpatialEntrySerializer):
+    parent_raster = RasterSerializer()
+
+    class Meta:
+        model = models.RasterMeta
+        exclude = ['footprint', 'outline']
+
+
+class STACRasterSerializer(serializers.BaseSerializer):
+    def to_internal_value(self, data):
+        # item = pystac.Item.from_dict(data)
+        # errors = item.validate()
+        # if errors:
+        #     raise serializers.ValidationError(errors)
+        return data
+
+    def to_representation(self, instance: models.RasterMeta) -> dict:
+        item = pystac.Item(
+            id=instance.pk,
+            geometry=json.loads(instance.footprint.json),
+            bbox=instance.extent,
+            datetime=(instance.acquisition_date or instance.modified or instance.created),
+            properties=dict(
+                datetime=str(instance.acquisition_date),
+                platform=instance.instrumentation,
+            ),
+        )
+        # 'proj' extension
+        item.ext.enable('projection')
+        item.ext.projection.apply(
+            epsg=CRS.from_proj4(instance.crs).to_epsg(),
+            transform=instance.transform,
+        )
+        # 'eo' extension
+        item.ext.enable('eo')
+        item.ext.eo.apply(cloud_cover=instance.cloud_cover, bands=[])
+        # Add assets
+        for image in instance.parent_raster.image_set.images.all():
+            if image.file.type != FileSourceType.URL:
+                # TODO: we need fix this
+                raise ValueError('Files must point to valid URL resources, not internal storage.')
+            asset = pystac.Asset(
+                href=image.file.get_url(),
+                title=image.file.name,
+                roles=[
+                    'data',
+                ],
+            )
+            item.ext.eo.set_bands(
+                bands=[
+                    pystac.extensions.eo.Band.create(
+                        name=f'B{bandmeta.band_number}',
+                        description=bandmeta.description,
+                    )
+                    for bandmeta in image.bandmeta_set.all()
+                ],
+                asset=asset,
+            )
+            item.add_asset(f'image-{image.pk}', asset)
+
+        for ancillary_file in instance.parent_raster.ancillary_files.all():
+            asset = pystac.Asset(
+                href=ancillary_file.get_url(),
+                title=ancillary_file.name,
+                roles=[
+                    'metadata',
+                ],
+            )
+            item.add_asset(f'ancillary-{ancillary_file.pk}', asset)
+
+        return item.to_dict()
+
+    @transaction.atomic
+    def create(self, data):
+        item = pystac.Item.from_dict(data)
+        images, ancillary = [], []
+        single_asset = False
+        if len(item.assets) == 1:
+            single_asset = True
+        for name in item.assets:
+            asset = item.assets[name]
+            checksum_file, _ = ChecksumFile.objects.get_or_create(
+                type=FileSourceType.URL,
+                url=asset.href,
+            )
+            if single_asset or (asset.roles and 'data' in asset.roles):
+                image, _ = models.Image.objects.get_or_create(file=checksum_file)
+                images.append(image)
+            else:
+                ancillary.append(checksum_file)
+
+        image_set = models.ImageSet.objects.create()
+        image_set.images.set(images)
+
+        raster, _ = models.Raster.objects.get_or_create(
+            image_set=image_set, defaults=dict(name=item.id)
+        )
+        [raster.ancillary_files.add(af) for af in ancillary]
+        raster.save()
+
+        outline = Polygon(
+            (
+                [item.bbox[0], item.bbox[1]],
+                [item.bbox[0], item.bbox[3]],
+                [item.bbox[2], item.bbox[3]],
+                [item.bbox[2], item.bbox[1]],
+                [item.bbox[0], item.bbox[1]],
+            )
+        )
+
+        instance = models.RasterMeta(
+            parent_raster=raster,
+            footprint=json.dumps(item.geometry),
+            crs=f'+init=epsg:{item.ext.projection.epsg}',
+            cloud_cover=item.ext.eo.cloud_cover,
+            transform=item.ext.projection.transform,
+            extent=item.bbox,
+            origin=(item.bbox[0], item.bbox[1]),
+            resolution=(0, 0),  # TODO: fix
+            outline=outline,
+            acquisition_date=dateutil.parser.isoparser().isoparse(item.properties['datetime']),
+            instrumentation=item.properties['platform'],
+        )
+        instance.save()
+        return instance
+
+
+utility.make_serializers(globals(), models)
diff --git a/django-rgd-imagery/rgd_imagery/signals.py b/django-rgd-imagery/rgd_imagery/signals.py
new file mode 100644
index 000000000..095e98582
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/signals.py
@@ -0,0 +1,67 @@
+import os
+
+from django.db import transaction
+from django.db.models.signals import m2m_changed, post_delete, post_save
+from django.dispatch import receiver
+from rgd.utility import skip_signal
+from rgd_imagery import models
+
+
+@receiver(m2m_changed, sender=models.ImageSet.images.through)
+@skip_signal()
+def _m2m_changed_image_set(sender, instance, action, reverse, *args, **kwargs):
+    # If no name was specified for an ImageSet, when images are added to it,
+    # use the common base name of all images as the name of the ImageSet.
+    if action == 'post_add' and not instance.name and instance.images.count():
+        names = [image.file.name for image in instance.images.all() if image.file.name]
+        if len(names):
+            instance.name = os.path.commonprefix(names)
+            instance.save(update_fields=['name'])
+
+
+@receiver(post_save, sender=models.Raster)
+@skip_signal()
+def _post_save_raster(sender, instance, *args, **kwargs):
+    transaction.on_commit(lambda: instance._on_commit_event_task(*args, **kwargs))
+
+
+@receiver(post_save, sender=models.KWCOCOArchive)
+@skip_signal()
+def _post_save_kwcoco_dataset(sender, instance, *args, **kwargs):
+    transaction.on_commit(lambda: instance._on_commit_event_task(*args, **kwargs))
+
+
+@receiver(post_delete, sender=models.KWCOCOArchive)
+@skip_signal()
+def _post_delete_kwcoco_dataset(sender, instance, *args, **kwargs):
+    transaction.on_commit(lambda: instance._post_delete(*args, **kwargs))
+
+
+@receiver(post_save, sender=models.Image)
+@skip_signal()
+def _post_save_image_file(sender, instance, *args, **kwargs):
+    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
+
+
+@receiver(post_save, sender=models.ConvertedImage)
+@skip_signal()
+def _post_save_converted_image_file(sender, instance, *args, **kwargs):
+    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
+
+
+@receiver(post_save, sender=models.RegionImage)
+@skip_signal()
+def _post_save_subsampled_image(sender, instance, *args, **kwargs):
+    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
+
+
+@receiver(post_delete, sender=models.ConvertedImage)
+@skip_signal()
+def _post_delete_converted_image_file(sender, instance, *args, **kwargs):
+    transaction.on_commit(lambda: instance._post_delete(*args, **kwargs))
+
+
+@receiver(post_delete, sender=models.RegionImage)
+@skip_signal()
+def _post_delete_subsampled_image(sender, instance, *args, **kwargs):
+    transaction.on_commit(lambda: instance._post_delete(*args, **kwargs))
diff --git a/django-rgd-imagery/rgd_imagery/tasks/__init__.py b/django-rgd-imagery/rgd_imagery/tasks/__init__.py
new file mode 100644
index 000000000..aa31b2404
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/tasks/__init__.py
@@ -0,0 +1,1 @@
+from .jobs import *  # noqa
diff --git a/rgd/geodata/models/imagery/etl.py b/django-rgd-imagery/rgd_imagery/tasks/etl.py
similarity index 77%
rename from rgd/geodata/models/imagery/etl.py
rename to django-rgd-imagery/rgd_imagery/tasks/etl.py
index 52cd451a6..d8b52159d 100644
--- a/rgd/geodata/models/imagery/etl.py
+++ b/django-rgd-imagery/rgd_imagery/tasks/etl.py
@@ -14,17 +14,13 @@
 import rasterio.shutil
 import rasterio.warp
 from rasterio.warp import Resampling, calculate_default_transform, reproject
+from rgd.models.constants import DB_SRID
+from rgd.models.transform import transform_geometry
+from rgd.utility import get_or_create_no_commit
+from rgd_imagery.models import BandMeta, ConvertedImage, Image, ImageMeta, Raster, RasterMeta
 from shapely.geometry import shape
 from shapely.ops import unary_union
 
-from rgd.utility import get_or_create_no_commit
-
-from ..constants import DB_SRID
-from ..geometry.transform import transform_geometry
-from .base import BandMetaEntry, ImageEntry, ImageFile
-from .processed import ConvertedImageFile
-from .raster import RasterEntry, RasterMetaEntry
-
 logger = get_task_logger(__name__)
 
 GDAL_DATA = os.path.join(os.path.dirname(rasterio.__file__), 'gdal_data')
@@ -34,27 +30,27 @@
 MAX_LOAD_SHAPE = (4000, 4000)
 
 
-def _read_image_to_entry(image_entry, image_file_path):
+def _populate_image_meta_models(image_meta, image_file_path):
 
     with rasterio.open(image_file_path) as src:
-        image_entry.number_of_bands = src.count
-        image_entry.driver = src.driver
-        image_entry.height = src.shape[0]
-        image_entry.width = src.shape[1]
+        image_meta.number_of_bands = src.count
+        image_meta.driver = src.driver
+        image_meta.height = src.shape[0]
+        image_meta.width = src.shape[1]
 
         # A catch-all metadata feild:
-        # TODO: image_entry.metadata =
+        # TODO: image_meta.metadata =
 
         # These are things I couldn't figure out how to get with gdal directly
         dtypes = src.dtypes
         interps = src.colorinterp
 
         # No longer editing image_entry
-        image_entry.save()
+        image_meta.save()
 
         for i in range(src.count):
-            band_meta = BandMetaEntry()
-            band_meta.parent_image = image_entry
+            band_meta = BandMeta()
+            band_meta.parent_image = image_meta.parent_image
             band_meta.band_number = i + 1  # off by 1 indexing
             band_meta.description = src.descriptions[i]
             band_meta.nodata_value = src.nodatavals[i]
@@ -78,29 +74,27 @@ def _read_image_to_entry(image_entry, image_file_path):
             band_meta.save()
 
 
-def read_image_file(ife):
+def load_image(image):
     """Image ingestion routine.
 
-    This helper will open an image file from ``ImageFile`` and create a
-    ``ImageEntry`` and collection of ``BandMetaEntry`` entries.
+    This helper will open an image file from ``Image`` and create a
+    ``ImageMeta`` and collection of ``BandMeta`` entries.
 
     """
     # Fetch the image file this Layer corresponds to
-    if not isinstance(ife, ImageFile):
-        ife = ImageFile.objects.get(id=ife)
+    if not isinstance(image, Image):
+        image = Image.objects.get(id=image)
 
-    with ife.file.yield_local_path(vsi=True) as file_path:
-        image_entry, created = get_or_create_no_commit(
-            ImageEntry, defaults=dict(name=ife.file.name), image_file=ife
-        )
+    with image.file.yield_local_path(vsi=True) as file_path:
+        image_meta, created = get_or_create_no_commit(ImageMeta, parent_image=image)
         if not created:
             # Clear out associated entries because they could be invalid
-            BandMetaEntry.objects.filter(parent_image=image_entry).delete()
-            ConvertedImageFile.objects.filter(source_image=image_entry).delete()
+            BandMeta.objects.filter(parent_image=image).delete()
+            ConvertedImage.objects.filter(source_image=image).delete()
 
-        _read_image_to_entry(image_entry, file_path)
+        _populate_image_meta_models(image_meta, file_path)
 
-    return image_entry
+    return image_meta
 
 
 def _extract_raster_outline(src):
@@ -116,15 +110,15 @@ def _extract_raster_outline(src):
     return transform_geometry(Polygon(coords, srid=src.crs.to_epsg()), src.crs.to_wkt())
 
 
-def _extract_raster_meta(image_file_entry):
-    """Extract all of the raster meta info in our models from an image file.
+def _extract_raster_meta(image):
+    """Extract all of the raster meta info in our models from an Image.
 
     The keys of the returned dict should match the fields of the
-    ``RasterEntry``.
+    ``Raster``.
 
     """
     raster_meta = dict()
-    with image_file_entry.file.yield_local_path(vsi=True) as path:
+    with image.file.yield_local_path(vsi=True) as path:
         with rasterio.open(path) as src:
             raster_meta['crs'] = src.crs.to_proj4()
             raster_meta['origin'] = [src.bounds.left, src.bounds.bottom]
@@ -262,13 +256,13 @@ def _reproject_raster(file_path, epsg):
                 yield rsrc
 
 
-def _extract_raster_footprint(image_file_entry):
-    """Extract the footprint of raster's image file entry.
+def _extract_raster_footprint(image):
+    """Extract the footprint of raster's Image.
 
-    This operates on the assumption that the image file is a valid raster.
+    This operates on the assumption that the Image is a valid raster.
 
     """
-    with image_file_entry.file.yield_local_path(vsi=True) as file_path:
+    with image.file.yield_local_path(vsi=True) as file_path:
         # Reproject the raster to the DB SRID using rasterio directly rather
         #  than transforming the extracted geometry which had issues.
         with _reproject_raster(file_path, DB_SRID) as src:
@@ -292,7 +286,7 @@ def _compare_raster_meta(a, b):
     return True
 
 
-def _validate_image_set_is_raster(image_set_entry):
+def _validate_image_set_is_raster(image_set):
     """Validate if all of the images in a single ``ImageSet`` are a raster.
 
     Will check if all have a spatial reference/geo meta info but not
@@ -301,34 +295,34 @@ def _validate_image_set_is_raster(image_set_entry):
     Returns the first image's meta info if it checks out.
 
     """
-    images = list(image_set_entry.images.all())
+    images = list(image_set.images.all())
 
     if not images:
         raise ValueError('ImageSet returned no images.')
 
     base_image = images.pop()
-    first_meta = _extract_raster_meta(base_image.image_file.imagefile)
+    first_meta = _extract_raster_meta(base_image)
     for image in images:
-        _extract_raster_meta(image.image_file.imagefile)
+        _extract_raster_meta(image)
 
     return first_meta
 
 
-def populate_raster_entry(raster_entry):
+def populate_raster(raster):
     """Autopopulate the fields of the raster."""
-    if not isinstance(raster_entry, RasterEntry):
-        raster_entry = RasterEntry.objects.get(id=raster_entry)
+    if not isinstance(raster, Raster):
+        raster = Raster.objects.get(id=raster)
 
     # Has potential to error with failure reason
-    meta = _validate_image_set_is_raster(raster_entry.image_set)
-    if not raster_entry.name:
-        raster_entry.name = raster_entry.image_set.name
-        raster_entry.save(
+    meta = _validate_image_set_is_raster(raster.image_set)
+    if not raster.name:
+        raster.name = raster.image_set.name
+        raster.save(
             update_fields=[
                 'name',
             ]
         )
-    raster_meta, created = get_or_create_no_commit(RasterMetaEntry, parent_raster=raster_entry)
+    raster_meta, created = get_or_create_no_commit(RasterMeta, parent_raster=raster)
     # Not using `defaults` here because we want `meta` to always get updated.
     for k, v in meta.items():
         # Yeah. This is sketchy, but it works.
@@ -338,12 +332,12 @@ def populate_raster_entry(raster_entry):
 
 
 def populate_raster_outline(raster_id):
-    raster_entry = RasterEntry.objects.get(id=raster_id)
-    base_image = raster_entry.image_set.images.first()
-    with base_image.image_file.file.yield_local_path(vsi=True) as path:
+    raster = Raster.objects.get(id=raster_id)
+    base_image = raster.image_set.images.first()
+    with base_image.file.yield_local_path(vsi=True) as path:
         with rasterio.open(path) as src:
-            raster_entry.rastermetaentry.outline = _extract_raster_outline(src)
-    raster_entry.rastermetaentry.save(
+            raster.rastermeta.outline = _extract_raster_outline(src)
+    raster.rastermeta.save(
         update_fields=[
             'outline',
         ]
@@ -351,15 +345,15 @@ def populate_raster_outline(raster_id):
 
 
 def populate_raster_footprint(raster_id):
-    raster_entry = RasterEntry.objects.get(id=raster_id)
-    # Only set the footprint if the RasterMetaEntry has been created already
+    raster = Raster.objects.get(id=raster_id)
+    # Only set the footprint if the RasterMeta has been created already
     #   this avoids a race condition where footprint might not get set correctly.
     try:
-        raster_meta = RasterMetaEntry.objects.get(parent_raster=raster_entry)
-        base_image = raster_entry.image_set.images.first()
-        footprint = _extract_raster_footprint(base_image.image_file.imagefile)
+        raster_meta = RasterMeta.objects.get(parent_raster=raster)
+        base_image = raster.image_set.images.first()
+        footprint = _extract_raster_footprint(base_image)
         if footprint:
             raster_meta.footprint = footprint
             raster_meta.save(update_fields=['footprint'])
-    except RasterMetaEntry.DoesNotExist:
+    except RasterMeta.DoesNotExist:
         logger.error('Cannot populate raster footprint yet due to race condition. Try again later.')
diff --git a/django-rgd-imagery/rgd_imagery/tasks/jobs.py b/django-rgd-imagery/rgd_imagery/tasks/jobs.py
new file mode 100644
index 000000000..c246cd308
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/tasks/jobs.py
@@ -0,0 +1,65 @@
+from celery import shared_task
+from rgd.tasks import helpers
+
+
+@shared_task(time_limit=86400)
+def task_load_image(file_id):
+    from rgd_imagery.models import Image
+    from rgd_imagery.tasks.etl import load_image
+
+    image_file = Image.objects.get(id=file_id)
+    helpers._run_with_failure_reason(image_file, load_image, file_id)
+
+
+@shared_task(time_limit=86400)
+def task_populate_raster(raster_id):
+    from rgd_imagery.models import Raster
+    from rgd_imagery.tasks.etl import populate_raster
+
+    raster = Raster.objects.get(id=raster_id)
+    helpers._run_with_failure_reason(raster, populate_raster, raster_id)
+
+
+@shared_task(time_limit=86400)
+def task_populate_raster_footprint(raster_id):
+    from rgd_imagery.models import Raster
+    from rgd_imagery.tasks.etl import populate_raster_footprint
+
+    raster = Raster.objects.get(id=raster_id)
+    helpers._run_with_failure_reason(raster, populate_raster_footprint, raster_id)
+
+
+@shared_task(time_limit=86400)
+def task_populate_raster_outline(raster_id):
+    from rgd_imagery.models import Raster
+    from rgd_imagery.tasks.etl import populate_raster_outline
+
+    raster = Raster.objects.get(id=raster_id)
+    helpers._run_with_failure_reason(raster, populate_raster_outline, raster_id)
+
+
+@shared_task(time_limit=86400)
+def task_load_kwcoco_dataset(kwcoco_dataset_id):
+    from rgd_imagery.models import KWCOCOArchive
+    from rgd_imagery.tasks.kwcoco_etl import load_kwcoco_dataset
+
+    ds_entry = KWCOCOArchive.objects.get(id=kwcoco_dataset_id)
+    helpers._run_with_failure_reason(ds_entry, load_kwcoco_dataset, kwcoco_dataset_id)
+
+
+@shared_task(time_limit=86400)
+def task_convert_to_cog(conv_id):
+    from rgd_imagery.models import ConvertedImage
+    from rgd_imagery.tasks.subsample import convert_to_cog
+
+    cog = ConvertedImage.objects.get(id=conv_id)
+    helpers._run_with_failure_reason(cog, convert_to_cog, conv_id)
+
+
+@shared_task(time_limit=86400)
+def task_populate_region_image(subsampled_id):
+    from rgd_imagery.models import RegionImage
+    from rgd_imagery.tasks.subsample import populate_region_image
+
+    cog = RegionImage.objects.get(id=subsampled_id)
+    helpers._run_with_failure_reason(cog, populate_region_image, subsampled_id)
diff --git a/rgd/geodata/models/imagery/kwcoco_etl.py b/django-rgd-imagery/rgd_imagery/tasks/kwcoco_etl.py
similarity index 82%
rename from rgd/geodata/models/imagery/kwcoco_etl.py
rename to django-rgd-imagery/rgd_imagery/tasks/kwcoco_etl.py
index 0e7b80a73..8dc6cac8f 100644
--- a/rgd/geodata/models/imagery/kwcoco_etl.py
+++ b/django-rgd-imagery/rgd_imagery/tasks/kwcoco_etl.py
@@ -8,12 +8,16 @@
 import kwcoco
 import kwimage
 import numpy as np
-
-from ..common import ChecksumFile
-from .annotation import Annotation, PolygonSegmentation, RLESegmentation, Segmentation
-from .base import ImageFile, ImageSet
-from .etl import read_image_file
-from .kwcoco import KWCOCOArchive
+from rgd.models import ChecksumFile
+from rgd_imagery.models import (
+    Annotation,
+    Image,
+    ImageSet,
+    KWCOCOArchive,
+    PolygonSegmentation,
+    RLESegmentation,
+    Segmentation,
+)
 
 logger = get_task_logger(__name__)
 
@@ -89,8 +93,8 @@ def load_kwcoco_dataset(kwcoco_dataset_id):
         if ds_entry.image_set:
             # Delete all previously existing data
             # This should cascade to all the annotations
-            for imageentry in ds_entry.image_set.images.all():
-                imageentry.image_file.file.delete()
+            for image in ds_entry.image_set.images.all():
+                image.file.delete()
         else:
             ds_entry.image_set = ImageSet()
         ds_entry.image_set.name = ds_entry.name
@@ -121,29 +125,24 @@ def load_kwcoco_dataset(kwcoco_dataset_id):
             # Set the root dir to where the images were extracted / the temp dir
             # If images are coming from URL, they will download to here
             ds.img_root = tmpdir
-            # Iterate over images and create an ImageEntry from them.
+            # Iterate over images and create an ImageMeta from them.
             # Any images in the archive that aren't listed in the JSON will be deleted
             for imgid in ds.imgs.keys():
                 ak = ds.index.gid_to_aids[imgid]
                 img = ds.imgs[imgid]
                 anns = [ds.anns[k] for k in ak]
-                # Create the ImageFile entry to track each image's location
+                # Create the Image entry to track each image's location
                 image_file_abs_path = os.path.join(ds.img_root, img['file_name'])
                 name = os.path.basename(image_file_abs_path)
-                image_file = ImageFile()
-                image_file.collection = ds_entry.spec_file.collection
-                image_file.skip_signal = True
-                image_file.file = ChecksumFile()
-                image_file.file.file.save(name, open(image_file_abs_path, 'rb'))
-                image_file.save()
-                # Create a new ImageEntry
-                image_entry = read_image_file(image_file)
-                # Add ImageEntry to ImageSet
-                ds_entry.image_set.images.add(image_entry)
-                # Create annotations that link to that ImageEntry
+                file = ChecksumFile()
+                file.file.save(name, open(image_file_abs_path, 'rb'))
+                image, _ = Image.objects.get_or_create(file=file)
+                # Add ImageMeta to ImageSet
+                ds_entry.image_set.images.add(image)
+                # Create annotations that link to that ImageMeta
                 for ann in anns:
                     annotation_entry = Annotation()
-                    annotation_entry.image = image_entry
+                    annotation_entry.image = image
                     try:
                         annotation_entry.label = ds.cats[ann['category_id']]['name']
                     except KeyError:
diff --git a/django-rgd-imagery/rgd_imagery/tasks/subsample.py b/django-rgd-imagery/rgd_imagery/tasks/subsample.py
new file mode 100644
index 000000000..a48f19e23
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/tasks/subsample.py
@@ -0,0 +1,80 @@
+"""Tasks for subsampling images with GDAL."""
+from contextlib import contextmanager
+
+from celery.utils.log import get_task_logger
+import large_image_converter
+from rgd.models import ChecksumFile
+from rgd.utility import input_output_path_helper, output_path_helper
+from rgd_imagery import large_image_utilities
+from rgd_imagery.models import ConvertedImage, Image, RegionImage
+
+logger = get_task_logger(__name__)
+
+
+@contextmanager
+def _processed_image_helper(param_model):
+    # yields the source image object and the processed image file object
+    if not param_model.processed_image:
+        file = ChecksumFile()
+    else:
+        file = param_model.processed_image.file
+
+    yield (param_model.source_image, file)
+
+    file.save()
+
+    if not param_model.processed_image:
+        param_model.processed_image = Image()
+    param_model.processed_image.file = file
+    param_model.processed_image.save()
+    param_model.save(
+        update_fields=[
+            'processed_image',
+        ]
+    )
+    logger.info(f'Produced ProcessedImage in ChecksumFile: {param_model.processed_image.file.id}')
+
+
+def convert_to_cog(cog):
+    """Populate ConvertedImage with COG file."""
+    if not isinstance(cog, ConvertedImage):
+        cog = ConvertedImage.objects.get(id=cog)
+    else:
+        cog.refresh_from_db()
+
+    with _processed_image_helper(cog) as (image, output):
+
+        with input_output_path_helper(image.file, output.file, prefix='cog_', vsi=True) as (
+            input_path,
+            output_path,
+        ):
+            large_image_converter.convert(str(input_path), str(output_path))
+
+
+def populate_region_image(region):
+    if not isinstance(region, RegionImage):
+        region = RegionImage.objects.get(id=region)
+    else:
+        region.refresh_from_db()
+
+    logger.info(f'Subsample parameters: {region.sample_parameters}')
+    l, r, b, t, projection = region.get_extent()
+
+    with _processed_image_helper(region) as (image, output):
+        tile_source = large_image_utilities.get_tilesource_from_image(image)
+
+        filename = f'region-{image.file.name}'
+
+        with output_path_helper(filename, output.file) as output_path:
+            logger.info(f'The extent: {l, r, b, t}')
+            if region.sample_type in (
+                RegionImage.SampleTypes.GEOJSON,
+                RegionImage.SampleTypes.GEO_BOX,
+            ):
+                path, mime_type = large_image_utilities.get_region_world(
+                    tile_source, l, r, b, t, projection=projection
+                )
+            else:
+                path, mime_type = large_image_utilities.get_region_pixel(tile_source, l, r, b, t)
+            with open(path, 'rb') as f, open(output_path, 'wb') as o:
+                o.write(f.read())
diff --git a/rgd/geodata/templates/geodata/_includes/search_fields_raster.html b/django-rgd-imagery/rgd_imagery/templates/rgd_imagery/_include/search_fields_raster.html
similarity index 100%
rename from rgd/geodata/templates/geodata/_includes/search_fields_raster.html
rename to django-rgd-imagery/rgd_imagery/templates/rgd_imagery/_include/search_fields_raster.html
diff --git a/rgd/geodata/templates/geodata/imagesetspatial_detail.html b/django-rgd-imagery/rgd_imagery/templates/rgd_imagery/imagesetspatial_detail.html
similarity index 90%
rename from rgd/geodata/templates/geodata/imagesetspatial_detail.html
rename to django-rgd-imagery/rgd_imagery/templates/rgd_imagery/imagesetspatial_detail.html
index 8f1ad3a16..5ba4fac1d 100644
--- a/rgd/geodata/templates/geodata/imagesetspatial_detail.html
+++ b/django-rgd-imagery/rgd_imagery/templates/rgd_imagery/imagesetspatial_detail.html
@@ -1,4 +1,4 @@
-{% extends "geodata/_base/detail.html" %}
+{% extends "rgd/_base/detail.html" %}
 
 {% block meta_table_body %}
   <tr>
@@ -12,13 +12,13 @@
 
   <select id="image-selector" onchange="updateBandThumbnail()">
     {% for image in object.image_set.images.all %}
-      <option value="{{ image.id }}">{{ image.name | truncatechars:36 }}</option>
+      <option value="{% url 'image-entry-data' image.pk %}">{{ image.file.name | truncatechars:36 }}</option>
     {% endfor %}
   </select>
 
   <br/>
 
-  {% include 'geodata/_includes/empty_viewer.html' %}
+  {% include 'rgd/_include/empty_viewer.html' %}
 
   <script>
     let extents = JSON.parse('{{ extents|escapejs }}');
@@ -75,7 +75,6 @@
       .draw()
 
     function updateBandThumbnail () {
-      var image_id = Number(selector.value);
       var image = document.createElement('img');
       image.crossOrigin = "";   // ask for CORS permission
       image.onload = function() {
@@ -104,7 +103,7 @@
         imageViewer.center(zoomAndCenter.center);
 
       }
-      image.src = `${host}/api/geodata/imagery/${image_id}/data`
+      image.src = selector.value
     }
     updateBandThumbnail()
   </script>
diff --git a/rgd/geodata/templates/geodata/rastermetaentry_detail.html b/django-rgd-imagery/rgd_imagery/templates/rgd_imagery/rastermeta_detail.html
similarity index 88%
rename from rgd/geodata/templates/geodata/rastermetaentry_detail.html
rename to django-rgd-imagery/rgd_imagery/templates/rgd_imagery/rastermeta_detail.html
index 3721c8c5b..f3fdb5257 100644
--- a/rgd/geodata/templates/geodata/rastermetaentry_detail.html
+++ b/django-rgd-imagery/rgd_imagery/templates/rgd_imagery/rastermeta_detail.html
@@ -1,4 +1,4 @@
-{% extends "geodata/_base/spatial_detail.html" %}
+{% extends "rgd/_base/spatial_detail.html" %}
 
 {% block extra_meta_table_body %}
 
@@ -45,7 +45,7 @@
 
   <select id="band-selector" onchange="updateBandThumbnail()">
     {% for image in object.parent_raster.image_set.images.all %}
-      <option value="{{ image.id }}">{{ image.name | truncatechars:36 }}</option>
+      <option value="{{ image.id }}">{{ image.file.name | truncatechars:36 }}</option>
     {% endfor %}
     <option value="-1">-- none --</option>
   </select>
@@ -63,7 +63,7 @@
 
 {% block detail %}
 
-  {% include 'geodata/_includes/empty_viewer.html' %}
+  {% include 'rgd/_include/empty_viewer.html' %}
 
   <script>
     let extents = JSON.parse('{{ extents|escapejs }}');
@@ -86,8 +86,8 @@
       console.log(image_id)
       if (image_id >= 0) {
         tileLayer.visible(true)
-        tileLayer.url(`${host}/api/geoprocess/imagery/${image_id}/tiles/{z}/{x}/{y}.png?projection=EPSG:3857`)
-        thumbnail.src = `${host}/api/geoprocess/imagery/${image_id}/thumbnail`
+        tileLayer.url(`${host}/api/image_process/imagery/${image_id}/tiles/{z}/{x}/{y}.png?projection=EPSG:3857`)
+        thumbnail.src = `${host}/api/image_process/imagery/${image_id}/thumbnail`
         thumbnail.hidden = false
       } else {
         tileLayer.visible(false)
diff --git a/rgd/geodata/templates/geodata/raster_entries.html b/django-rgd-imagery/rgd_imagery/templates/rgd_imagery/rastermeta_list.html
similarity index 73%
rename from rgd/geodata/templates/geodata/raster_entries.html
rename to django-rgd-imagery/rgd_imagery/templates/rgd_imagery/rastermeta_list.html
index 8946c7243..cd5b4b1ed 100644
--- a/rgd/geodata/templates/geodata/raster_entries.html
+++ b/django-rgd-imagery/rgd_imagery/templates/rgd_imagery/rastermeta_list.html
@@ -1,11 +1,11 @@
-{% extends "geodata/_base/search_view.html" %}
+{% extends "rgd/_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' %}
+  {% include 'rgd/_include/search_fields.html' %}
+  {% include 'rgd_imagery/_include/search_fields_raster.html' %}
 {% endblock search_fields %}
 
 {% block list_view %}
@@ -22,9 +22,9 @@
       <tbody>
         {% for entry in spatial_entries %}
           <tr>
-            <td><a href="{% url 'raster-entry-detail' entry.pk %}">{{ entry.spatial_id }} - {{ self.name }}</a></td>
+            <td><a href="{% url 'raster-entry-detail' entry.pk %}">{{ entry.spatial_id }} - {{ entry.name | truncatechars:12 }}</a></td>
             <td>{{ entry.footprint.area | floatformat:-2 }}</td>
-            <td>RasterMetaEntry</td>
+            <td>RasterMeta</td>
           </tr>
         {% endfor %}
       </tbody>
diff --git a/django-rgd-imagery/rgd_imagery/urls.py b/django-rgd-imagery/rgd_imagery/urls.py
new file mode 100644
index 000000000..db0eabdff
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/urls.py
@@ -0,0 +1,139 @@
+from django.urls import path, register_converter
+
+from . import models, rest, views
+
+
+class FloatUrlParameterConverter:
+    regex = r'-?[0-9]+\.?[0-9]+'
+
+    def to_python(self, value):
+        return float(value)
+
+    def to_url(self, value):
+        return str(value)
+
+
+register_converter(FloatUrlParameterConverter, 'float')
+
+
+urlpatterns = [
+    # Pages
+    path(r'rgd_imagery/raster/', views.RasterMetaEntriesListView.as_view(), name='raster-search'),
+    path(
+        'rgd_imagery/raster/<int:pk>/',
+        views.RasterDetailView.as_view(),
+        name=models.RasterMeta.detail_view_name,
+    ),
+    path(
+        'rgd_imagery/image_set/<int:pk>/',
+        views.ImageSetSpatialDetailView.as_view(),
+        name=models.ImageSetSpatial.detail_view_name,
+    ),
+    #############
+    # Search
+    path('api/rgd_imagery/raster/search', rest.search.SearchRasterMetaSTACView.as_view()),
+    #############
+    # Other
+    path(
+        'api/rgd_imagery/<int:pk>',
+        rest.get.GetImageMeta.as_view(),
+        name='image-entry',
+    ),
+    path(
+        'api/rgd_imagery/<int:pk>/data',
+        rest.download.download_image_file,
+        name='image-entry-data',
+    ),
+    path(
+        'api/rgd_imagery/image_set/<int:pk>',
+        rest.get.GetImageSet.as_view(),
+        name='image-set',
+    ),
+    path(
+        'api/rgd_imagery/raster/<int:pk>',
+        rest.get.GetRasterMeta.as_view(),
+        name='raster-meta-entry',
+    ),
+    path(
+        'api/rgd_imagery/raster/<int:pk>/stac',
+        rest.get.GetRasterMetaSTAC.as_view(),
+        name='raster-meta-entry-stac',
+    ),
+    path(
+        'api/rgd_imagery/raster/stac',
+        rest.post.CreateRasterSTAC.as_view(),
+        name='raster-meta-entry-stac-post',
+    ),
+    #############
+    # Geoprocessing
+    path(
+        'api/image_process/imagery/<int:pk>/tiles',
+        rest.tiles.TileMetadataView.as_view(),
+        name='image-tile-metadata',
+    ),
+    path(
+        'api/image_process/imagery/<int:pk>/tiles/internal',
+        rest.tiles.TileInternalMetadataView.as_view(),
+        name='image-tile-internal-metadata',
+    ),
+    path(
+        'api/image_process/imagery/<int:pk>/tiles/<int:z>/<int:x>/<int:y>.png',
+        rest.tiles.TileView.as_view(),
+        name='image-tiles',
+    ),
+    path(
+        'api/image_process/imagery/<int:pk>/tiles/region/world/<float:left>/<float:right>/<float:bottom>/<float:top>/region.tif',
+        rest.tiles.TileRegionView.as_view(),
+        name='image-region',
+    ),
+    path(
+        'api/image_process/imagery/<int:pk>/tiles/region/pixel/<int:left>/<int:right>/<int:bottom>/<int:top>/region.tif',
+        rest.tiles.TileRegionPixelView.as_view(),
+        name='image-region-pixel',
+    ),
+    path(
+        'api/image_process/imagery/<int:pk>/tiles/<int:z>/<int:x>/<int:y>/corners',
+        rest.tiles.TileCornersView.as_view(),
+        name='image-tile-corners',
+    ),
+    path(
+        'api/image_process/imagery/<int:pk>/thumbnail',
+        rest.tiles.TileThumnailView.as_view(),
+        name='image-thumbnail',
+    ),
+    path(
+        'api/image_process/imagery/<int:pk>/bands',
+        rest.tiles.TileBandInfoView.as_view(),
+        name='image-bands',
+    ),
+    path(
+        'api/image_process/imagery/<int:pk>/bands/<int:band>',
+        rest.tiles.TileSingleBandInfoView.as_view(),
+        name='image-bands-single',
+    ),
+    path('api/image_process/imagery/cog', rest.post.CreateConvertedImage.as_view()),
+    path(
+        'api/image_process/imagery/cog/<int:pk>',
+        rest.get.GetConvertedImageStatus.as_view(),
+        name='cog',
+    ),
+    path(
+        'api/image_process/imagery/cog/<int:pk>/data',
+        rest.download.download_cog_file,
+        name='cog-data',
+    ),
+    path(
+        'api/image_process/imagery/subsample',
+        rest.post.CreateRegionImage.as_view(),
+    ),
+    path(
+        'api/image_process/imagery/subsample/<int:pk>',
+        rest.get.GetRegionImage.as_view(),
+        name='subsampled',
+    ),
+    path(
+        'api/image_process/imagery/subsample/<int:pk>/status',
+        rest.download.get_status_subsampled_image,
+        name='subsampled-status',
+    ),
+]
diff --git a/django-rgd-imagery/rgd_imagery/views.py b/django-rgd-imagery/rgd_imagery/views.py
new file mode 100644
index 000000000..1849bf85a
--- /dev/null
+++ b/django-rgd-imagery/rgd_imagery/views.py
@@ -0,0 +1,18 @@
+from rgd.views import SpatialEntriesListView, _SpatialDetailView
+
+from . import filters, models
+
+
+class ImageSetSpatialDetailView(_SpatialDetailView):
+    model = models.ImageSetSpatial
+
+
+class RasterMetaEntriesListView(SpatialEntriesListView):
+    model = models.RasterMeta
+    filter = filters.RasterMetaFilter
+    context_object_name = 'spatial_entries'
+    template_name = 'rgd_imagery/rastermeta_list.html'
+
+
+class RasterDetailView(_SpatialDetailView):
+    model = models.RasterMeta
diff --git a/django-rgd-imagery/setup.py b/django-rgd-imagery/setup.py
new file mode 100644
index 000000000..288f7719e
--- /dev/null
+++ b/django-rgd-imagery/setup.py
@@ -0,0 +1,65 @@
+from io import open as io_open
+import os
+from pathlib import Path
+
+from setuptools import find_packages, setup
+
+readme_file = Path(__file__).parent.parent / 'README.md'
+if readme_file.exists():
+    with readme_file.open() as f:
+        long_description = f.read()
+else:
+    # When this is first installed in development Docker, README.md is not available
+    long_description = ''
+
+__version__ = None
+filepath = os.path.dirname(__file__)
+version_file = os.path.join(filepath, '..', 'version.py')
+with io_open(version_file, mode='r') as fd:
+    exec(fd.read())
+
+setup(
+    name='django-rgd-imagery',
+    version=__version__,
+    description='',
+    long_description=long_description,
+    long_description_content_type='text/markdown',
+    license='Apache 2.0',
+    author='Kitware, Inc.',
+    author_email='rgd@kitware.com',
+    url='https://github.com/ResonantGeoData/ResonantGeoData',
+    keywords='',
+    classifiers=[
+        'Development Status :: 3 - Alpha',
+        'Environment :: Web Environment',
+        'Framework :: Django :: 3.0',
+        'Framework :: Django',
+        'Intended Audience :: Developers',
+        'License :: OSI Approved :: Apache Software License',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python',
+    ],
+    python_requires='>=3.8',
+    packages=find_packages(),
+    include_package_data=True,
+    install_requires=[
+        'django-rgd',
+        'large-image>=1.6.0',
+        'large-image-source-gdal>=1.6.0',
+        'large-image-source-pil>=1.6.0',
+        'numpy',
+        'pystac[validation]',
+        'shapely',
+    ],
+    extras_require={
+        'worker': [
+            'kwarray>=0.5.10',
+            'kwcoco',
+            'kwimage[headless]>=0.6.7',
+            'large-image-converter',
+            'rasterio',
+        ],
+    },
+)
diff --git a/MANIFEST.in b/django-rgd/MANIFEST.in
similarity index 100%
rename from MANIFEST.in
rename to django-rgd/MANIFEST.in
diff --git a/django-rgd/README.md b/django-rgd/README.md
new file mode 100644
index 000000000..bfc44b1f1
--- /dev/null
+++ b/django-rgd/README.md
@@ -0,0 +1,3 @@
+# ResonantGeoData Core Application
+
+The core app contianing spatial models for django
diff --git a/django-rgd/rgd/__init__.py b/django-rgd/rgd/__init__.py
new file mode 100644
index 000000000..8eb67a912
--- /dev/null
+++ b/django-rgd/rgd/__init__.py
@@ -0,0 +1,7 @@
+from pkg_resources import DistributionNotFound, get_distribution
+
+try:
+    __version__ = get_distribution('django-rgd').version
+except DistributionNotFound:
+    # package is not installed
+    __version__ = None
diff --git a/django-rgd/rgd/admin/__init__.py b/django-rgd/rgd/admin/__init__.py
new file mode 100644
index 000000000..779560c1f
--- /dev/null
+++ b/django-rgd/rgd/admin/__init__.py
@@ -0,0 +1,3 @@
+from . import mixins  # noqa
+from .collection import *  # noqa
+from .common import *  # noqa
diff --git a/django-rgd/rgd/admin/collection.py b/django-rgd/rgd/admin/collection.py
new file mode 100644
index 000000000..486bb374d
--- /dev/null
+++ b/django-rgd/rgd/admin/collection.py
@@ -0,0 +1,16 @@
+from django.contrib import admin
+from django.contrib.gis.admin import OSMGeoAdmin
+from rgd.models import Collection, CollectionPermission
+
+
+class CollectionPermissionInline(admin.TabularInline):
+    model = CollectionPermission
+    fk_name = 'collection'
+    fields = ('user', 'role')
+    extra = 1
+
+
+@admin.register(Collection)
+class CollectionAdmin(OSMGeoAdmin):
+    fields = ('name',)
+    inlines = (CollectionPermissionInline,)
diff --git a/django-rgd/rgd/admin/common.py b/django-rgd/rgd/admin/common.py
new file mode 100644
index 000000000..086832347
--- /dev/null
+++ b/django-rgd/rgd/admin/common.py
@@ -0,0 +1,93 @@
+from django.contrib import admin
+from django.contrib.auth.admin import UserAdmin
+from django.contrib.auth.models import User
+from django.contrib.gis.admin import OSMGeoAdmin
+from rgd.models import ChecksumFile, SpatialAsset, WhitelistedEmail
+
+from .mixins import (
+    MODIFIABLE_FILTERS,
+    TASK_EVENT_FILTERS,
+    TASK_EVENT_READONLY,
+    _FileGetNameMixin,
+    reprocess,
+)
+
+
+def make_users_active(modeladmin, request, queryset):
+    """Make each user active."""
+    for user in queryset.all():
+        if not user.is_active:
+            user.is_active = True
+            user.save(update_fields=['is_active'])
+
+
+def make_users_staff(modeladmin, request, queryset):
+    """Make each user staff."""
+    for user in queryset.all():
+        if not user.is_staff:
+            user.is_staff = True
+            user.save(update_fields=['is_staff'])
+
+
+admin.site.unregister(User)
+
+
+@admin.register(User)
+class CustomUserAdmin(UserAdmin):
+    actions = (
+        make_users_active,
+        make_users_staff,
+    )
+
+    list_display = (
+        'username',
+        'is_staff',
+        'is_superuser',
+        'is_active',
+    )
+
+
+@admin.register(WhitelistedEmail)
+class WhitelistedEmailAdmin(OSMGeoAdmin):
+    list_display = (
+        'pk',
+        'email',
+    )
+
+
+@admin.register(ChecksumFile)
+class ChecksumFileAdmin(OSMGeoAdmin):
+    list_display = (
+        'pk',
+        'name',
+        'status',
+        'modified',
+        'created',
+        'type',
+        'data_link',
+        'collection',
+    )
+    readonly_fields = (
+        'checksum',
+        'last_validation',
+    ) + TASK_EVENT_READONLY
+    actions = (reprocess,)
+    list_filter = (
+        MODIFIABLE_FILTERS
+        + TASK_EVENT_FILTERS
+        + (
+            'type',
+            'collection',
+        )
+    )
+
+
+@admin.register(SpatialAsset)
+class SpatialAssetAdmin(OSMGeoAdmin, _FileGetNameMixin):
+    list_display = (
+        'pk',
+        'get_name',
+        'modified',
+        'created',
+    )
+    list_filter = MODIFIABLE_FILTERS
diff --git a/django-rgd/rgd/admin/mixins.py b/django-rgd/rgd/admin/mixins.py
new file mode 100644
index 000000000..a1402e504
--- /dev/null
+++ b/django-rgd/rgd/admin/mixins.py
@@ -0,0 +1,30 @@
+MODIFIABLE_FILTERS = (
+    'modified',
+    'created',
+)
+
+SPATIAL_ENTRY_FILTERS = (
+    'acquisition_date',
+    'instrumentation',
+)
+
+TASK_EVENT_FILTERS = ('status',)
+
+TASK_EVENT_READONLY = (
+    'failure_reason',
+    'status',
+)
+
+
+class _FileGetNameMixin:
+    def get_name(self, obj):
+        return obj.file.name
+
+    get_name.short_description = 'Name'
+    get_name.admin_order_field = 'file__name'
+
+
+def reprocess(modeladmin, request, queryset):
+    """Trigger the save event task for each entry."""
+    for entry in queryset.all():
+        entry.save()
diff --git a/django-rgd/rgd/apps.py b/django-rgd/rgd/apps.py
new file mode 100644
index 000000000..c86d4595e
--- /dev/null
+++ b/django-rgd/rgd/apps.py
@@ -0,0 +1,9 @@
+from django.apps import AppConfig
+
+
+class RGDConfig(AppConfig):
+    default_auto_field = 'django.db.models.AutoField'
+    name = 'rgd'
+
+    def ready(self):
+        import rgd.signals  # noqa: F401
diff --git a/rgd/geodata/datastore.py b/django-rgd/rgd/datastore.py
similarity index 100%
rename from rgd/geodata/datastore.py
rename to django-rgd/rgd/datastore.py
diff --git a/rgd/geodata/filters.py b/django-rgd/rgd/filters.py
similarity index 55%
rename from rgd/geodata/filters.py
rename to django-rgd/rgd/filters.py
index b83c109a7..c2328ab08 100644
--- a/rgd/geodata/filters.py
+++ b/django-rgd/rgd/filters.py
@@ -2,11 +2,8 @@
 from django.contrib.gis.db.models.functions import GeometryDistance
 from django.contrib.gis.measure import D
 from django.core.validators import RegexValidator
-from django.db.models import Q, Sum
 from django_filters import rest_framework as filters
-
-from rgd.geodata.models.common import ModifiableEntry, SpatialEntry
-from rgd.geodata.models.imagery import RasterMetaEntry
+from rgd.models import SpatialEntry
 
 
 class GeometryFilter(filters.Filter):
@@ -15,27 +12,6 @@ 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(
@@ -91,6 +67,14 @@ class SpatialEntryFilter(filters.FilterSet):
         lookup_expr='icontains',
     )
 
+    @property
+    def _geometry(self):
+        return self.form.cleaned_data['q']
+
+    @property
+    def _has_geom(self):
+        return self._geometry is not None
+
     def filter_q(self, queryset, name, value):
         """Sort the queryset by distance to queried geometry.
 
@@ -99,20 +83,22 @@ def filter_q(self, queryset, name, value):
         This uses the efficient KNN operation:
         https://postgis.net/docs/geometry_distance_knn.html
         """
-        return queryset.annotate(distance=GeometryDistance('footprint', value)).order_by('distance')
+        if value:
+            queryset = queryset.annotate(distance=GeometryDistance('footprint', value)).order_by(
+                'distance'
+            )
+        return queryset
 
     def filter_predicate(self, queryset, name, value):
         """Filter the spatial entries by the chosen predicate."""
-        if value:
-            geom = self.form.cleaned_data['q']
-            return queryset.filter(**{f'footprint__{value}': geom})
+        if value and self._has_geom:
+            queryset = queryset.filter(**{f'footprint__{value}': self._geometry})
         return queryset
 
     def filter_relates(self, queryset, name, value):
         """Filter the spatial entries by the chosen DE-9IM."""
-        if value:
-            geom = self.form.cleaned_data['q']
-            return queryset.filter(footprint__relates=(geom, value))
+        if value and self._has_geom:
+            queryset = queryset.filter(footprint__relates=(self._geometry, value))
         return queryset
 
     def filter_distance(self, queryset, name, value):
@@ -122,13 +108,13 @@ def filter_distance(self, queryset, name, value):
         very taxing on the DBMS right now. The distance in degrees
         can be provided by the initial geometry query.
         """
-        if value:
-            geom = self.form.cleaned_data['q']
+        if value and self._has_geom:
+            geom = self._geometry
             if value.start is not None:
                 queryset = queryset.filter(footprint__distance_gte=(geom, D(m=value.start)))
             if value.stop is not None:
                 queryset = queryset.filter(footprint__distance_lte=(geom, D(m=value.stop)))
-            return queryset
+        return queryset
 
     class Meta:
         model = SpatialEntry
@@ -140,61 +126,3 @@ class Meta:
             'acquired',
             'instrumentation',
         ]
-
-
-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.
-
-        Annotates the queryset with `num_bands`.
-        """
-        if value is not None:
-            queryset = queryset.annotate(
-                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)
-            if value.stop is not None:
-                queryset = queryset.filter(num_bands__lte=value.stop)
-        return queryset
-
-    def filter_resolution(self, queryset, name, value):
-        """Filter by the resolution in the raster data."""
-        if value is not None:
-            if value.start is not None:
-                queryset = queryset.filter(
-                    Q(resolution__0__gte=value.start) & Q(resolution__1__gte=value.start)
-                )
-            if value.stop is not None:
-                queryset = queryset.filter(
-                    Q(resolution__0__lte=value.stop) & Q(resolution__1__lte=value.stop)
-                )
-        return queryset
-
-    class Meta:
-        model = RasterMetaEntry
-        fields = [
-            'num_bands',
-            'resolution',
-            'cloud_cover',
-        ]
diff --git a/django-rgd/rgd/management/commands/_data_helper.py b/django-rgd/rgd/management/commands/_data_helper.py
new file mode 100644
index 000000000..c198cf395
--- /dev/null
+++ b/django-rgd/rgd/management/commands/_data_helper.py
@@ -0,0 +1,90 @@
+import logging
+import os
+
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.core.management.base import BaseCommand
+from django.core.validators import URLValidator
+from rgd import models
+from rgd.datastore import datastore
+from rgd.utility import get_or_create_no_commit
+
+logger = logging.getLogger(__name__)
+
+
+class SynchronousTasksCommand(BaseCommand):
+    def set_synchronous(self):
+        # Set celery to run all tasks synchronously
+        self._eager = getattr(settings, 'CELERY_TASK_ALWAYS_EAGER', False)
+        self._prop = getattr(settings, 'CELERY_TASK_EAGER_PROPAGATES', False)
+        settings.CELERY_TASK_ALWAYS_EAGER = True
+        settings.CELERY_TASK_EAGER_PROPAGATES = True
+
+    def reset_celery(self):
+        # Reset celery to previous settings
+        settings.CELERY_TASK_ALWAYS_EAGER = self._eager
+        settings.CELERY_TASK_EAGER_PROPAGATES = self._prop
+
+
+def _save_signal(entry, created):
+    if not created and entry.status == models.mixins.Status.SUCCEEDED:
+        entry.skip_signal = True
+    entry.save()
+
+
+def _get_or_create_checksum_file_url(url, name=None):
+    URLValidator()(url)  # raises `ValidationError` if not a valid URL
+    try:
+        file_entry = models.ChecksumFile.objects.get(url=url)
+        _save_signal(file_entry, False)
+        if name:
+            file_entry.name = name
+            file_entry.save(update_fields=['name'])
+    except models.ChecksumFile.DoesNotExist:
+        file_entry = models.ChecksumFile()
+        file_entry.url = url
+        file_entry.type = models.FileSourceType.URL
+        if not name:
+            # this is to prevent calling `urlopen` in the save to get the file name.
+            # this is not a great way to set the default name, but its fast
+            name = os.path.basename(url)
+        file_entry.name = name
+        _save_signal(file_entry, True)
+    return file_entry
+
+
+def _get_or_create_checksum_file_datastore(file, name=None):
+    try:
+        file_entry = models.ChecksumFile.objects.get(name=file)
+        _save_signal(file_entry, False)
+    except models.ChecksumFile.DoesNotExist:
+        path = datastore.fetch(file)
+        file_entry = models.ChecksumFile()
+        if name:
+            file_entry.name = name
+        else:
+            file_entry.name = file
+        with open(path, 'rb') as f:
+            file_entry.file.save(os.path.basename(path), f)
+        file_entry.type = models.FileSourceType.FILE_FIELD
+        _save_signal(file_entry, True)
+    return file_entry
+
+
+def _get_or_create_checksum_file(file, name=None):
+    # Check if there is already an image file with this URL or name
+    #  to avoid duplicating data
+    try:
+        file_entry = _get_or_create_checksum_file_url(file, name=name)
+    except ValidationError:
+        file_entry = _get_or_create_checksum_file_datastore(file, name=name)
+    return file_entry
+
+
+def _get_or_create_file_model(model, file, name=None):
+    # For models that point to a `ChecksumFile`
+    file_entry = _get_or_create_checksum_file(file, name=name)
+    # No commit in case we need to skip the signal
+    entry, created = get_or_create_no_commit(model, file=file_entry)
+    _save_signal(entry, created)
+    return entry
diff --git a/rgd/geodata/management/commands/ingest_s3.py b/django-rgd/rgd/management/commands/rgd_s3_files.py
similarity index 95%
rename from rgd/geodata/management/commands/ingest_s3.py
rename to django-rgd/rgd/management/commands/rgd_s3_files.py
index 48411b436..b41031699 100644
--- a/rgd/geodata/management/commands/ingest_s3.py
+++ b/django-rgd/rgd/management/commands/rgd_s3_files.py
@@ -16,7 +16,7 @@ def _iter_matching_objects(
     include_regex: str,
     exclude_regex: str,
 ) -> Generator[dict, None, None]:
-    paginator = s3_client.get_paginator('list_objects')
+    paginator = s3_client.get_paginator('list_objects_v2')
     page_iter = paginator.paginate(Bucket=bucket, Prefix=prefix)
     include_pattern = re.compile(include_regex)
     exclude_pattern = re.compile(exclude_regex)
@@ -27,7 +27,7 @@ def _iter_matching_objects(
                 yield obj
 
 
-class CloudLoader:
+class Loader:
     def __init__(self, bucket: str, region: str, google: bool = False):
         self.bucket = bucket
         self.google = google
@@ -81,7 +81,7 @@ def ingest_s3(
 
     s3_client = boto3.client('s3', **boto3_params)
 
-    loader = CloudLoader(bucket, region, google=google)
+    loader = Loader(bucket, region, google=google)
     pool = multiprocessing.Pool(multiprocessing.cpu_count())
     pool.map(
         loader.load_object,
diff --git a/rgd/geodata/management/commands/whitelist_email.py b/django-rgd/rgd/management/commands/whitelist_email.py
similarity index 87%
rename from rgd/geodata/management/commands/whitelist_email.py
rename to django-rgd/rgd/management/commands/whitelist_email.py
index 9b880084e..7e867ce19 100644
--- a/rgd/geodata/management/commands/whitelist_email.py
+++ b/django-rgd/rgd/management/commands/whitelist_email.py
@@ -2,8 +2,7 @@
 from typing import List
 
 import djclick as click
-
-from rgd.geodata.models import WhitelistedEmail
+from rgd.models import WhitelistedEmail
 
 logger = logging.getLogger(__name__)
 
diff --git a/django-rgd/rgd/migrations/0001_initial.py b/django-rgd/rgd/migrations/0001_initial.py
new file mode 100644
index 000000000..acfe07637
--- /dev/null
+++ b/django-rgd/rgd/migrations/0001_initial.py
@@ -0,0 +1,227 @@
+# Generated by Django 3.2.4 on 2021-06-24 22:52
+
+from django.conf import settings
+import django.contrib.gis.db.models.fields
+from django.db import migrations, models
+import django.db.models.deletion
+import django_extensions.db.fields
+import rgd.models.mixins
+import rgd.utility
+import s3_file_field.fields
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Collection',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                ('name', models.CharField(max_length=127)),
+            ],
+            options={
+                'default_related_name': 'collections',
+            },
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='SpatialEntry',
+            fields=[
+                ('spatial_id', models.AutoField(primary_key=True, serialize=False)),
+                ('acquisition_date', models.DateTimeField(blank=True, default=None, null=True)),
+                ('footprint', django.contrib.gis.db.models.fields.GeometryField(srid=4326)),
+                ('outline', django.contrib.gis.db.models.fields.GeometryField(srid=4326)),
+                (
+                    'instrumentation',
+                    models.CharField(
+                        blank=True,
+                        help_text='The instrumentation used to acquire these data.',
+                        max_length=100,
+                        null=True,
+                    ),
+                ),
+            ],
+        ),
+        migrations.CreateModel(
+            name='WhitelistedEmail',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                ('email', models.EmailField(max_length=254)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='CollectionPermission',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                (
+                    'role',
+                    models.SmallIntegerField(
+                        choices=[(1, 'Reader'), (2, 'Owner')],
+                        db_index=True,
+                        default=1,
+                        help_text='A "reader" can view assets in this collection. An "owner" can additionally add/remove other users, set their permissions, delete the collection, and add/remove other files.',
+                    ),
+                ),
+                (
+                    'collection',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='collection_permissions',
+                        to='rgd.collection',
+                    ),
+                ),
+                (
+                    'user',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='collection_permissions',
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                'default_related_name': 'collection_permissions',
+            },
+        ),
+        migrations.CreateModel(
+            name='ChecksumFile',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
+                    ),
+                ),
+                ('failure_reason', models.TextField(null=True)),
+                (
+                    'status',
+                    models.CharField(
+                        choices=[
+                            ('created', 'Created but not queued'),
+                            ('queued', 'Queued for processing'),
+                            ('running', 'Processing'),
+                            ('failed', 'Failed'),
+                            ('success', 'Succeeded'),
+                        ],
+                        default='created',
+                        max_length=20,
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('name', models.CharField(blank=True, max_length=1000)),
+                ('description', models.TextField(blank=True, null=True)),
+                ('checksum', models.CharField(max_length=128)),
+                ('validate_checksum', models.BooleanField(default=False)),
+                ('last_validation', models.BooleanField(default=True)),
+                ('type', models.IntegerField(choices=[(1, 'FileField'), (2, 'URL')], default=1)),
+                (
+                    'file',
+                    s3_file_field.fields.S3FileField(
+                        blank=True, null=True, upload_to=rgd.utility.uuid_prefix_filename
+                    ),
+                ),
+                ('url', models.TextField(blank=True, null=True)),
+                (
+                    'collection',
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name='checksumfiles',
+                        related_query_name='checksumfiles',
+                        to='rgd.collection',
+                    ),
+                ),
+            ],
+            bases=(models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.CreateModel(
+            name='SpatialAsset',
+            fields=[
+                (
+                    'spatialentry_ptr',
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to='rgd.spatialentry',
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('files', models.ManyToManyField(to='rgd.ChecksumFile')),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+            bases=('rgd.spatialentry', models.Model, rgd.models.mixins.PermissionPathMixin),
+        ),
+        migrations.AddConstraint(
+            model_name='collectionpermission',
+            constraint=models.UniqueConstraint(fields=('collection', 'user'), name='unique_user'),
+        ),
+        migrations.AddConstraint(
+            model_name='checksumfile',
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    models.Q(
+                        ('file__regex', '.+'),
+                        ('type', 1),
+                        models.Q(('url__in', ['', None]), ('url__isnull', True), _connector='OR'),
+                    ),
+                    models.Q(
+                        ('type', 2),
+                        models.Q(('url__isnull', False), ('url__regex', '.+')),
+                        models.Q(('file__in', ['', None]), ('file__isnull', True), _connector='OR'),
+                    ),
+                    _connector='OR',
+                ),
+                name='rgd_checksumfile_file_source_value_matches_type',
+            ),
+        ),
+    ]
diff --git a/django-rgd/rgd/migrations/__init__.py b/django-rgd/rgd/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/django-rgd/rgd/models/__init__.py b/django-rgd/rgd/models/__init__.py
new file mode 100644
index 000000000..b3d7b2e37
--- /dev/null
+++ b/django-rgd/rgd/models/__init__.py
@@ -0,0 +1,11 @@
+from .collection import Collection, CollectionPermission  # noqa
+from .common import (  # noqa
+    ChecksumFile,
+    FileSourceType,
+    SpatialAsset,
+    SpatialEntry,
+    WhitelistedEmail,
+)
+from .constants import *  # noqa
+from .mixins import *  # noqa
+from .transform import transform_geometry  # noqa
diff --git a/rgd/geodata/models/collection.py b/django-rgd/rgd/models/collection.py
similarity index 83%
rename from rgd/geodata/models/collection.py
rename to django-rgd/rgd/models/collection.py
index 611668392..de45ba44e 100644
--- a/rgd/geodata/models/collection.py
+++ b/django-rgd/rgd/models/collection.py
@@ -1,12 +1,16 @@
 from django.conf import settings
 from django.db import models
 
+from .mixins import PermissionPathMixin
+
+
+class Collection(models.Model, PermissionPathMixin):
+    permissions_paths = ['collection_permissions']
 
-class Collection(models.Model):
     name = models.CharField(max_length=127)
 
     def __str__(self):
-        return self.name
+        return f'{self.name} ({self.pk})'
 
     class Meta:
         default_related_name = 'collections'
@@ -19,6 +23,8 @@ class CollectionPermission(models.Model):
         (READER, 'Reader'),
         (OWNER, 'Owner'),
     ]
+    permissions_paths = ['']
+
     collection = models.ForeignKey(Collection, on_delete=models.CASCADE)
     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
     role = models.SmallIntegerField(
diff --git a/rgd/geodata/models/common.py b/django-rgd/rgd/models/common.py
similarity index 83%
rename from rgd/geodata/models/common.py
rename to django-rgd/rgd/models/common.py
index 049d9ad4c..620b3f5fd 100644
--- a/rgd/geodata/models/common.py
+++ b/django-rgd/rgd/models/common.py
@@ -4,14 +4,10 @@
 from urllib.error import URLError
 from urllib.parse import urlencode, urlparse
 
-# from django.contrib.auth import get_user_model
 from django.contrib.gis.db import models
-from django.core.exceptions import ObjectDoesNotExist
-from django.utils import timezone
+from django_extensions.db.models import TimeStampedModel
 from girder_utils.files import field_file_to_local_path
 from model_utils.managers import InheritanceManager
-from s3_file_field import S3FileField
-
 from rgd.utility import (
     _link_url,
     compute_checksum_file,
@@ -23,37 +19,16 @@
     url_file_to_local_path,
     uuid_prefix_filename,
 )
+from s3_file_field import S3FileField
 
 # from .. import tasks
 from .collection import Collection
 from .constants import DB_SRID
-from .mixins import TaskEventMixin
+from .mixins import PermissionPathMixin, TaskEventMixin
 
 logger = logging.getLogger(__name__)
 
 
-class ModifiableEntry(models.Model):
-    """A base class for models that need to track modified datetimes and users."""
-
-    modified = models.DateTimeField(editable=False, help_text='The last time this entry was saved.')
-    created = models.DateTimeField(editable=False, help_text='When this was added to the database.')
-    # creator = models.ForeignKey(
-    #     get_user_model(), on_delete=models.DO_NOTHING, related_name='creator'
-    # )
-    # modifier = models.ForeignKey(
-    #     get_user_model(), on_delete=models.DO_NOTHING, related_name='modifier'
-    # )
-
-    def save(self, *args, **kwargs):
-        if not self.id:
-            self.created = timezone.now()
-        self.modified = timezone.now()
-        if 'update_fields' in kwargs:
-            kwargs = kwargs.copy()
-            kwargs['update_fields'] = set(kwargs['update_fields']) | {'modified'}
-        super(ModifiableEntry, self).save(*args, **kwargs)
-
-
 class SpatialEntry(models.Model):
     """Common model to all geospatial data entries.
 
@@ -61,6 +36,9 @@ class SpatialEntry(models.Model):
 
     """
 
+    # `InheritanceManager` allows us to select inhereted tables via `objects.select_subclasses()`
+    objects = InheritanceManager()
+
     spatial_id = models.AutoField(primary_key=True)
 
     # Datetime of creation for the dataset
@@ -77,62 +55,30 @@ class SpatialEntry(models.Model):
         help_text='The instrumentation used to acquire these data.',
     )
 
-    objects = InheritanceManager()
-
     def __str__(self):
         try:
             return 'Spatial ID: {} (ID: {}, type: {})'.format(self.spatial_id, self.id, type(self))
         except AttributeError:
             return super().__str__()
 
-    @property
-    def subentry(self):
-        try:
-            return self.rastermetaentry
-        except ObjectDoesNotExist:
-            pass
-        try:
-            return self.geometryentry
-        except ObjectDoesNotExist:
-            pass
-        try:
-            return self.fmventry
-        except ObjectDoesNotExist:
-            pass
-        try:
-            return self.pointcloudmetaentry
-        except ObjectDoesNotExist:
-            pass
-        try:
-            return self.imagesetspatial
-        except ObjectDoesNotExist:
-            pass
-        raise ObjectDoesNotExist
-
-    @property
-    def subentry_name(self):
-        """Return the name from the subentry model."""
-        return self.subentry.name
-
-    @property
-    def subentry_type(self):
-        return type(self.subentry).__name__
-
 
 class FileSourceType(models.IntegerChoices):
     FILE_FIELD = 1, 'FileField'
     URL = 2, 'URL'
 
 
-class ChecksumFile(ModifiableEntry, TaskEventMixin):
+class ChecksumFile(TimeStampedModel, TaskEventMixin, PermissionPathMixin):
     """The main class for user-uploaded files.
 
     This has support for manually uploading files or specifing a URL to a file
-    (for example in an existing S3 bucket).
+    (for example in an existing S3 bucket). This broadly supports ``http
://``
+    URLs to file resources as well as ``s3://`` as long as the node the app is
+    running on is provisioned to access that S3 bucket.
 
     """
 
     name = models.CharField(max_length=1000, blank=True)
+    description = models.TextField(null=True, blank=True)
     checksum = models.CharField(max_length=128)  # sha512
     validate_checksum = models.BooleanField(
         default=False
@@ -154,6 +100,7 @@ class ChecksumFile(ModifiableEntry, TaskEventMixin):
     task_funcs = (
         # tasks.task_checksum_file_post_save,
     )
+    permissions_paths = ['collection__collection_permissions']
 
     class Meta:
         constraints = [
@@ -341,11 +288,13 @@ def yield_vsi_path(self, internal=False):
 
 
 class WhitelistedEmail(models.Model):
+    """Pre-approve users for sign up by their email."""
+
     email = models.EmailField()
 
 
-class SpatialAsset(SpatialEntry):
-    """Any spatially referenced asset set.
+class SpatialAsset(SpatialEntry, TimeStampedModel, PermissionPathMixin):
+    """Any spatially referenced file set.
 
     This can be any collection of files that have a spatial reference and are
     not explictly handled by the other SpatialEntry subtypes. For example, this
@@ -354,7 +303,6 @@ class SpatialAsset(SpatialEntry):
 
     """
 
-    name = models.CharField(max_length=1000, blank=True)
-    description = models.TextField(null=True, blank=True)
+    permissions_paths = ['files__collection__collection_permissions']
 
     files = models.ManyToManyField(ChecksumFile)
diff --git a/rgd/geodata/models/constants.py b/django-rgd/rgd/models/constants.py
similarity index 100%
rename from rgd/geodata/models/constants.py
rename to django-rgd/rgd/models/constants.py
diff --git a/rgd/geodata/models/mixins.py b/django-rgd/rgd/models/mixins.py
similarity index 73%
rename from rgd/geodata/models/mixins.py
rename to django-rgd/rgd/models/mixins.py
index acf5d8aa4..30a90e308 100644
--- a/rgd/geodata/models/mixins.py
+++ b/django-rgd/rgd/models/mixins.py
@@ -1,7 +1,8 @@
 """Mixin helper classes."""
-from typing import Iterable
+from typing import Iterable, List
 
 from celery import Task
+from django.conf import settings
 from django.contrib.gis.db import models
 from django.utils.translation import gettext_lazy as _
 
@@ -48,7 +49,11 @@ def _run_tasks(self) -> None:
             ]
         )
         for func in self.task_funcs:
-            func.delay(self.id)
+            if getattr(settings, 'CELERY_TASK_ALWAYS_EAGER', None):
+                # HACK: for some reason this is necessary
+                func(self.id)
+            else:
+                func.delay(self.id)
 
     def _post_save_event_task(self, created: bool, *args, **kwargs) -> None:
         if not created and kwargs.get('update_fields'):
@@ -59,3 +64,20 @@ def _on_commit_event_task(self, *args, **kwargs) -> None:
         if kwargs.get('update_fields'):
             return
         self._run_tasks()
+
+
+class PermissionPathMixin:
+    """Interface for permission querying.
+
+    Get all possible paths to the 'CollectionPermission' model under the ``permissions_paths`` field.
+
+    Relationships are represented as 'dunder's ('__').
+    """
+
+    permissions_paths: List[str] = []
+
+
+class DetailViewMixin:
+    """Interface for spatial entry detail view redirect."""
+
+    detail_view_name: str = None
diff --git a/rgd/geodata/models/geometry/transform.py b/django-rgd/rgd/models/transform.py
similarity index 90%
rename from rgd/geodata/models/geometry/transform.py
rename to django-rgd/rgd/models/transform.py
index 94e16162a..ac4b06a28 100644
--- a/rgd/geodata/models/geometry/transform.py
+++ b/django-rgd/rgd/models/transform.py
@@ -1,6 +1,5 @@
 from django.contrib.gis.gdal import CoordTransform, SpatialReference
-
-from ..constants import DB_SRID
+from rgd.models.constants import DB_SRID
 
 
 def transform_geometry(geometry, source_wkt):
diff --git a/rgd/geodata/permissions.py b/django-rgd/rgd/permissions.py
similarity index 51%
rename from rgd/geodata/permissions.py
rename to django-rgd/rgd/permissions.py
index f535f71dd..bbc1d3fa0 100644
--- a/rgd/geodata/permissions.py
+++ b/django-rgd/rgd/permissions.py
@@ -1,81 +1,44 @@
 from typing import List
 
+from django.apps import apps
 from django.conf import settings
 from django.contrib.auth.backends import BaseBackend
 from django.core.exceptions import PermissionDenied
 from django.db.models import Q
+from rgd import models
 
-from rgd.geodata import models
 
+def get_subclasses(model):
+    """Retrieve all model subclasses for the provided class excluding the model class itself."""
+    return set([m for m in apps.get_models() if issubclass(m, model) and m != model])
 
-def get_collection_permissions_paths(model) -> List[str]:
+
+def get_collection_permissions_paths(model, prefix='') -> List[str]:
     """Get all possible paths to the 'CollectionPermission' model.
 
     Relationships are represented as 'dunder's ('__').
     """
-    # Collection
-    if issubclass(model, models.CollectionPermission):
-        return ['']
-    if issubclass(model, models.Collection):
-        return ['collection_permissions']
-    # Common
-    if issubclass(model, models.ChecksumFile):
-        return ['collection__collection_permissions']
-    # Imagery
-    if issubclass(model, models.ImageEntry):
-        return ['image_file__file__collection__collection_permissions']
-    if issubclass(model, models.ImageSet):
-        return ['images__image_file__file__collection__collection_permissions']
-    if issubclass(model, models.RasterEntry):
-        return ['image_set__images__image_file__file__collection__collection_permissions']
-    if issubclass(model, models.RasterMetaEntry):
-        return [
-            'parent_raster__image_set__images__image_file__file__collection__collection_permissions'
-        ]
-    if issubclass(model, models.BandMetaEntry):
-        return ['parent_image__image_file__file__collection__collection_permissions']
-    if issubclass(model, models.ConvertedImageFile):
-        return ['source_image__image_file__file__collection__collection_permissions']
-    if issubclass(model, models.SubsampledImage):
-        return ['source_image__image_file__file__collection__collection_permissions']
-    if issubclass(model, models.KWCOCOArchive):
-        return ['spec_file__collection__collection_permissions']
-    # Annotation
-    if issubclass(model, models.Annotation):
-        return ['image__image_file__collection__collection_permissions']
-    if issubclass(model, models.Segmentation):
-        return ['annotation__image__image_file__collection__collection_permissions']
-    # Geometry
-    if issubclass(model, models.GeometryEntry):
-        return ['geometry_archive__file__collection__collection_permissions']
-    # FMV
-    if issubclass(model, models.FMVEntry):
-        return ['fmv_file__file__collection__collection_permissions']
-    # Point Cloud
-    if issubclass(model, models.PointCloudFile):
-        return ['file__collection__collection_permissions']
-    if issubclass(model, models.PointCloudEntry):
-        return [
-            'source__file__collection__collection_permissions',
-            'vtp_data__collection__collection_permissions',
-        ]
-    # ImageSetSpatial
-    if issubclass(model, models.ImageSetSpatial):
-        return ['image_set__images__image_file__file__collection__collection_permissions']
-    # SpatialEntry
     if model == models.SpatialEntry:
-        return [
-            'fmventry__fmv_file__file__collection__collection_permissions',
-            'geometryentry__geometry_archive__file__collection__collection_permissions',
-            'rastermetaentry__parent_raster__image_set__images__image_file__file__collection__collection_permissions',
-            'imagesetspatial__image_set__images__image_file__file__collection__collection_permissions',
-        ]
+        submodels = get_subclasses(model)
+        paths = []
+        for sub in submodels:
+            # TODO: there should be a cleaner way to do this.
+            model_name = sub.__name__.lower()
+            [paths.append(s) for s in get_collection_permissions_paths(sub, prefix=model_name)]
+        return paths
+
+    if not hasattr(model, 'permissions_paths'):
+        # NOTE: checking subclass here does not work - must use hasattr :(
+        raise TypeError(f'{type(model)} does not have the `PermissionPathMixin` interface.')
 
-    raise NotImplementedError
+    return [(f'{prefix}__' if prefix else '') + s for s in model.permissions_paths]
 
 
 def filter_perm(user, queryset, role):
     """Filter a queryset."""
+    if queryset.model == models.SpatialEntry:
+        # Return subclasses of SpatialEntry
+        queryset = queryset.select_subclasses()
     # Called outside of view
     if user is None:
         return queryset
diff --git a/django-rgd/rgd/rest/__init__.py b/django-rgd/rgd/rest/__init__.py
new file mode 100644
index 000000000..b41a95fdb
--- /dev/null
+++ b/django-rgd/rgd/rest/__init__.py
@@ -0,0 +1,3 @@
+__all__ = ['download', 'get', 'search']
+
+from . import download, get, search
diff --git a/rgd/geodata/api/download.py b/django-rgd/rgd/rest/download.py
similarity index 50%
rename from rgd/geodata/api/download.py
rename to django-rgd/rgd/rest/download.py
index 1c4535bc0..c7504fcee 100644
--- a/rgd/geodata/api/download.py
+++ b/django-rgd/rgd/rest/download.py
@@ -3,9 +3,8 @@
 from drf_yasg.utils import swagger_auto_schema
 from rest_framework.decorators import api_view
 from rest_framework.response import Response
-
-from rgd.geodata import models
-from rgd.geodata.permissions import check_read_perm
+from rgd import models
+from rgd.permissions import check_read_perm
 
 
 @swagger_auto_schema(
@@ -14,33 +13,8 @@
 )
 @api_view(['GET'])
 def download_checksum_file(request, pk):
-    instance = models.common.ChecksumFile.objects.get(pk=pk)
-    check_read_perm(request.user, instance)
-    return HttpResponseRedirect(instance.get_url())
-
-
-@swagger_auto_schema(
-    method='GET',
-    operation_summary='Download the associated ImageFile data for this ImageEntry directly from S3.',
-)
-@api_view(['GET'])
-def download_image_entry_file(request, pk):
-    instance = models.imagery.ImageEntry.objects.get(pk=pk)
-    check_read_perm(request.user, instance)
-    url = instance.image_file.imagefile.file.get_url()
-    return HttpResponseRedirect(url)
-
-
-@swagger_auto_schema(
-    method='GET',
-    operation_summary='Download the associated ChecksumFile data for this ConvertedImageFile directly from S3.',
-)
-@api_view(['GET'])
-def download_cog_file(request, pk):
-    instance = models.imagery.ConvertedImageFile.objects.get(pk=pk)
+    instance = get_object_or_404(models.ChecksumFile, pk=pk)
     check_read_perm(request.user, instance)
-    af_id = instance.converted_file.id
-    instance = models.common.ChecksumFile.objects.get(pk=af_id)
     return HttpResponseRedirect(instance.get_url())
 
 
@@ -68,13 +42,3 @@ def _get_status_response(request, model, pk):
 def get_status(request, model, pk):
     """Get the status of any TaskEventMixin model."""
     return _get_status_response(request, model, pk)
-
-
-@swagger_auto_schema(
-    method='GET',
-    operation_summary='Check the status of SubsampledImage.',
-)
-@api_view(['GET'])
-def get_status_subsampled_image(request, pk):
-    """Get the status of any SubsampledImage model."""
-    return _get_status_response(request, 'SubsampledImage', pk)
diff --git a/django-rgd/rgd/rest/get.py b/django-rgd/rgd/rest/get.py
new file mode 100644
index 000000000..535f20c3f
--- /dev/null
+++ b/django-rgd/rgd/rest/get.py
@@ -0,0 +1,28 @@
+from rest_framework.generics import RetrieveAPIView
+from rgd import models, serializers
+from rgd.permissions import check_read_perm
+
+
+class _PermissionMixin:
+    def get_object(self):
+        obj = super().get_object()
+        check_read_perm(self.request.user, obj)
+        return obj
+
+
+class GetChecksumFile(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.ChecksumFileSerializer
+    lookup_field = 'pk'
+    queryset = models.ChecksumFile.objects.all()
+
+
+class GetSpatialEntry(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.SpatialEntrySerializer
+    lookup_field = 'spatial_id'
+    queryset = models.SpatialEntry.objects.all()
+
+
+class GetSpatialEntryFootprint(RetrieveAPIView, _PermissionMixin):
+    serializer_class = serializers.SpatialEntryFootprintSerializer
+    lookup_field = 'spatial_id'
+    queryset = models.SpatialEntry.objects.all()
diff --git a/django-rgd/rgd/rest/search.py b/django-rgd/rgd/rest/search.py
new file mode 100644
index 000000000..bac40dffd
--- /dev/null
+++ b/django-rgd/rgd/rest/search.py
@@ -0,0 +1,16 @@
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.generics import ListAPIView
+from rgd import serializers
+from rgd.filters import SpatialEntryFilter
+from rgd.models import SpatialEntry
+from rgd.permissions import filter_read_perm
+
+
+class SearchSpatialEntryView(ListAPIView):
+    queryset = SpatialEntry.objects.all()
+    serializer_class = serializers.SpatialEntrySerializer
+    filter_backends = [DjangoFilterBackend]
+    filterset_class = SpatialEntryFilter
+
+    def get_queryset(self):
+        return filter_read_perm(self.request.user, super().get_queryset())
diff --git a/django-rgd/rgd/serializers.py b/django-rgd/rgd/serializers.py
new file mode 100644
index 000000000..2d6b47aa0
--- /dev/null
+++ b/django-rgd/rgd/serializers.py
@@ -0,0 +1,52 @@
+import json
+
+from rest_framework import serializers
+
+from . import models, utility
+
+
+class ChecksumFileSerializer(serializers.ModelSerializer):
+    def to_representation(self, value):
+        ret = super().to_representation(value)
+        ret['download_url'] = value.get_url()
+        return ret
+
+    class Meta:
+        model = models.ChecksumFile
+        fields = '__all__'
+        read_only_fields = ['id', 'checksum', 'last_validation', 'modified', 'created']
+
+
+class SpatialEntrySerializer(serializers.ModelSerializer):
+    def to_representation(self, value):
+        ret = super().to_representation(value)
+        # NOTE: including footprint can cause the search results to blow up in size
+        ret['outline'] = json.loads(value.outline.geojson)
+        # NOTE HACK: this is dirty but it works
+        subentry = models.SpatialEntry.objects.filter(pk=value.pk).select_subclasses().first()
+        # Add hyperlink to get view for subtype if SpatialEntry
+        ret['subentry_type'] = type(subentry).__name__
+        try:
+            # TODO: enforce all sub models have name
+            ret['subentry_name'] = subentry.name
+        except AttributeError:
+            pass
+        return ret
+
+    class Meta:
+        model = models.SpatialEntry
+        exclude = ['footprint', 'outline']
+
+
+class SpatialEntryFootprintSerializer(SpatialEntrySerializer):
+    def to_representation(self, value):
+        ret = super().to_representation(value)
+        ret['footprint'] = json.loads(value.footprint.geojson)
+        return ret
+
+    class Meta:
+        model = models.SpatialEntry
+        exclude = ['footprint', 'outline']
+
+
+utility.make_serializers(globals(), models)
diff --git a/django-rgd/rgd/signals.py b/django-rgd/rgd/signals.py
new file mode 100644
index 000000000..0eb870c12
--- /dev/null
+++ b/django-rgd/rgd/signals.py
@@ -0,0 +1,23 @@
+from allauth.account.signals import user_signed_up
+from django.db import transaction
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from rgd import models
+from rgd.utility import skip_signal
+
+
+@receiver(post_save, sender=models.ChecksumFile)
+@skip_signal()
+def _post_save_checksum_file(sender, instance, *args, **kwargs):
+    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
+
+
+@receiver(user_signed_up)
+def set_new_user_inactive(sender, **kwargs):
+    user = kwargs.get('user')
+    try:
+        models.WhitelistedEmail.objects.get(email=user.email)
+        user.is_active = True
+    except models.WhitelistedEmail.DoesNotExist:
+        user.is_active = False
+    user.save(update_fields=['is_active'])
diff --git a/rgd/geodata/static/geodata/css/.gitignore b/django-rgd/rgd/static/rgd/css/.gitignore
similarity index 100%
rename from rgd/geodata/static/geodata/css/.gitignore
rename to django-rgd/rgd/static/rgd/css/.gitignore
diff --git a/rgd/geodata/static/geodata/css/sass/_base.scss b/django-rgd/rgd/static/rgd/css/sass/_base.scss
similarity index 100%
rename from rgd/geodata/static/geodata/css/sass/_base.scss
rename to django-rgd/rgd/static/rgd/css/sass/_base.scss
diff --git a/rgd/geodata/static/geodata/css/sass/_transitions.scss b/django-rgd/rgd/static/rgd/css/sass/_transitions.scss
similarity index 100%
rename from rgd/geodata/static/geodata/css/sass/_transitions.scss
rename to django-rgd/rgd/static/rgd/css/sass/_transitions.scss
diff --git a/rgd/geodata/static/geodata/css/sass/_variables.scss b/django-rgd/rgd/static/rgd/css/sass/_variables.scss
similarity index 100%
rename from rgd/geodata/static/geodata/css/sass/_variables.scss
rename to django-rgd/rgd/static/rgd/css/sass/_variables.scss
diff --git a/rgd/geodata/static/geodata/css/sass/style.scss b/django-rgd/rgd/static/rgd/css/sass/style.scss
similarity index 100%
rename from rgd/geodata/static/geodata/css/sass/style.scss
rename to django-rgd/rgd/static/rgd/css/sass/style.scss
diff --git a/rgd/geodata/static/geodata/css/style.css b/django-rgd/rgd/static/rgd/css/style.css
similarity index 100%
rename from rgd/geodata/static/geodata/css/style.css
rename to django-rgd/rgd/static/rgd/css/style.css
diff --git a/django-rgd/rgd/tasks/__init__.py b/django-rgd/rgd/tasks/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/django-rgd/rgd/tasks/helpers.py b/django-rgd/rgd/tasks/helpers.py
new file mode 100644
index 000000000..b819aa3f5
--- /dev/null
+++ b/django-rgd/rgd/tasks/helpers.py
@@ -0,0 +1,29 @@
+import traceback
+
+from celery.utils.log import get_task_logger
+
+logger = get_task_logger(__name__)
+
+
+def _safe_execution(func, *args, **kwargs):
+    """Execute a task and return any tracebacks that occur as a string."""
+    try:
+        func(*args, **kwargs)
+        return ''
+    except Exception as exc:
+        logger.exception(f'Internal error run `{func.__name__}`: {exc}')
+        return traceback.format_exc()
+
+
+def _run_with_failure_reason(model, func, *args, **kwargs):
+    """Run a function that will update the model's `failure_reason`."""
+    from rgd.models.mixins import Status
+
+    model.status = Status.RUNNING
+    model.save(update_fields=['status'])
+    model.failure_reason = _safe_execution(func, *args, **kwargs)
+    if model.failure_reason:
+        model.status = Status.FAILED
+    else:
+        model.status = Status.SUCCEEDED
+    model.save(update_fields=['failure_reason', 'status'])
diff --git a/django-rgd/rgd/tasks/jobs.py b/django-rgd/rgd/tasks/jobs.py
new file mode 100644
index 000000000..89dca7a64
--- /dev/null
+++ b/django-rgd/rgd/tasks/jobs.py
@@ -0,0 +1,12 @@
+from celery import shared_task
+
+from . import helpers
+
+
+@shared_task(time_limit=86400)
+def task_checksum_file_post_save(checksumfile_id):
+    from rgd.models import ChecksumFile
+
+    obj = ChecksumFile.objects.get(id=checksumfile_id)
+
+    helpers._run_with_failure_reason(obj, obj.post_save_job)
diff --git a/rgd/geodata/templates/geodata/_base/base.html b/django-rgd/rgd/templates/rgd/_base/base.html
similarity index 94%
rename from rgd/geodata/templates/geodata/_base/base.html
rename to django-rgd/rgd/templates/rgd/_base/base.html
index a252d9cae..8a5407765 100644
--- a/rgd/geodata/templates/geodata/_base/base.html
+++ b/django-rgd/rgd/templates/rgd/_base/base.html
@@ -35,7 +35,7 @@
       }
     </script>
 
-    <link rel="stylesheet" href="{% static 'geodata/css/style.css' %}" />
+    <link rel="stylesheet" href="{% static 'rgd/css/style.css' %}" />
 
     {% if title %}
       <title>ResonantGeoData - {{ title }}</title>
@@ -46,7 +46,7 @@
   </head>
   <body>
     <header class="main-header">
-      {% include 'geodata/_includes/navbar.html' %}
+      {% include 'rgd/_include/navbar.html' %}
     </header>
 
     {% if user.is_authenticated %}
@@ -84,7 +84,7 @@ <h1 class="banner-title">{% block title %}{% endblock %}</h1>
         {% if user.is_authenticated %}
           {% block content %}{% endblock %}
         {% else %}
-          {% include 'geodata/_includes/not_logged_in.html' %}
+          {% include 'rgd/_include/not_logged_in.html' %}
         {% endif %}
       </main>
 
diff --git a/rgd/geodata/templates/geodata/_base/detail.html b/django-rgd/rgd/templates/rgd/_base/detail.html
similarity index 97%
rename from rgd/geodata/templates/geodata/_base/detail.html
rename to django-rgd/rgd/templates/rgd/_base/detail.html
index d5bd9983f..775420dd6 100644
--- a/rgd/geodata/templates/geodata/_base/detail.html
+++ b/django-rgd/rgd/templates/rgd/_base/detail.html
@@ -1,4 +1,4 @@
-{% extends "geodata/_base/base.html" %}
+{% extends "rgd/_base/base.html" %}
 {% block title %}
   <input class="button is-secondary" type="button" value="&#x2190; Back to Search" onclick="history.back()">
   {{ object.name | truncatechars:36 }} ({{object.pk}})
diff --git a/rgd/geodata/templates/geodata/_base/search_view.html b/django-rgd/rgd/templates/rgd/_base/search_view.html
similarity index 98%
rename from rgd/geodata/templates/geodata/_base/search_view.html
rename to django-rgd/rgd/templates/rgd/_base/search_view.html
index 34569f153..cd9ae53e2 100644
--- a/rgd/geodata/templates/geodata/_base/search_view.html
+++ b/django-rgd/rgd/templates/rgd/_base/search_view.html
@@ -1,4 +1,4 @@
-{% extends "geodata/_base/base.html" %}
+{% extends "rgd/_base/base.html" %}
 
 {% block extra_title_content %}
   <div class="column is-1">
@@ -179,7 +179,7 @@
     </div>
 
     <div class="column p-0">
-      {% include 'geodata/_includes/search_map.html' %}
+      {% include 'rgd/_include/search_map.html' %}
     </div>
   </div>
 
diff --git a/rgd/geodata/templates/geodata/_base/spatial_detail.html b/django-rgd/rgd/templates/rgd/_base/spatial_detail.html
similarity index 97%
rename from rgd/geodata/templates/geodata/_base/spatial_detail.html
rename to django-rgd/rgd/templates/rgd/_base/spatial_detail.html
index 9986c7fda..2191ff175 100644
--- a/rgd/geodata/templates/geodata/_base/spatial_detail.html
+++ b/django-rgd/rgd/templates/rgd/_base/spatial_detail.html
@@ -1,4 +1,4 @@
-{% extends "geodata/_base/detail.html" %}
+{% extends "rgd/_base/detail.html" %}
 
 {% block extra_extra_title_content %}
   <div class="column is-2">
diff --git a/rgd/geodata/templates/geodata/_includes/empty_viewer.html b/django-rgd/rgd/templates/rgd/_include/empty_viewer.html
similarity index 100%
rename from rgd/geodata/templates/geodata/_includes/empty_viewer.html
rename to django-rgd/rgd/templates/rgd/_include/empty_viewer.html
diff --git a/rgd/geodata/templates/geodata/_includes/geojson_reader.html b/django-rgd/rgd/templates/rgd/_include/geojson_reader.html
similarity index 100%
rename from rgd/geodata/templates/geodata/_includes/geojson_reader.html
rename to django-rgd/rgd/templates/rgd/_include/geojson_reader.html
diff --git a/rgd/geodata/templates/geodata/_includes/navbar.html b/django-rgd/rgd/templates/rgd/_include/navbar.html
similarity index 92%
rename from rgd/geodata/templates/geodata/_includes/navbar.html
rename to django-rgd/rgd/templates/rgd/_include/navbar.html
index eb11ad617..b8d47a04c 100644
--- a/rgd/geodata/templates/geodata/_includes/navbar.html
+++ b/django-rgd/rgd/templates/rgd/_include/navbar.html
@@ -1,7 +1,7 @@
 {% block navlinks %}
   <nav class="navbar" role="navigation" aria-label="main navigation">
     <div class="navbar-brand">
-      <a class="navbar-item logo" href="{% url 'index' %}">
+      <a class="navbar-item logo" href="{% url 'rgd-index' %}">
         <strong>ResonantGeoData</strong>
       </a>
 
@@ -13,9 +13,6 @@
     </div>
 
     <div id="navbarBasicExample" class="navbar-menu">
-      <a href="{% url 'raster-search' %}" class="navbar-item">
-        Rasters
-      </a>
       <a href="{% url 'statistics' %}" class="navbar-item">
         Statistics
       </a>
diff --git a/rgd/geodata/templates/geodata/_includes/not_logged_in.html b/django-rgd/rgd/templates/rgd/_include/not_logged_in.html
similarity index 100%
rename from rgd/geodata/templates/geodata/_includes/not_logged_in.html
rename to django-rgd/rgd/templates/rgd/_include/not_logged_in.html
diff --git a/rgd/geodata/templates/geodata/_includes/search_fields.html b/django-rgd/rgd/templates/rgd/_include/search_fields.html
similarity index 99%
rename from rgd/geodata/templates/geodata/_includes/search_fields.html
rename to django-rgd/rgd/templates/rgd/_include/search_fields.html
index 64f774987..06909b295 100644
--- a/rgd/geodata/templates/geodata/_includes/search_fields.html
+++ b/django-rgd/rgd/templates/rgd/_include/search_fields.html
@@ -7,7 +7,7 @@
     <div class="field-body">
       <div class="field">
         <div class="control">
-          <input class="input" type="text" name="q" value="{{ request.GET.q }}" required>
+          <input class="input" type="text" name="q" value="{{ request.GET.q }}">
         </div>
       </div>
     </div>
@@ -87,10 +87,10 @@
     <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>
+          <input type="datetime-local" name="acquired_before" value="{{ request.GET.acquired_before }}">
+          <button onclick="javascript:acquired_before.value=''" type="button">X</button>
         </div>
       </div>
     </div>
diff --git a/rgd/geodata/templates/geodata/_includes/search_fields_modifiable.html b/django-rgd/rgd/templates/rgd/_include/search_fields_modifiable.html
similarity index 100%
rename from rgd/geodata/templates/geodata/_includes/search_fields_modifiable.html
rename to django-rgd/rgd/templates/rgd/_include/search_fields_modifiable.html
diff --git a/rgd/geodata/templates/geodata/_includes/search_map.html b/django-rgd/rgd/templates/rgd/_include/search_map.html
similarity index 98%
rename from rgd/geodata/templates/geodata/_includes/search_map.html
rename to django-rgd/rgd/templates/rgd/_include/search_map.html
index 4dab3a2fc..4def34055 100644
--- a/rgd/geodata/templates/geodata/_includes/search_map.html
+++ b/django-rgd/rgd/templates/rgd/_include/search_map.html
@@ -1,6 +1,6 @@
 {% block search_map %}
 
-  {% include 'geodata/_includes/viewer.html' %}
+  {% include 'rgd/_include/viewer.html' %}
 
   <script>
     // script to add search parameters to the map
diff --git a/rgd/geodata/templates/geodata/_includes/viewer.html b/django-rgd/rgd/templates/rgd/_include/viewer.html
similarity index 57%
rename from rgd/geodata/templates/geodata/_includes/viewer.html
rename to django-rgd/rgd/templates/rgd/_include/viewer.html
index c7cba4622..fe36852cf 100644
--- a/rgd/geodata/templates/geodata/_includes/viewer.html
+++ b/django-rgd/rgd/templates/rgd/_include/viewer.html
@@ -1,12 +1,12 @@
 {% block viewer %}
 
-  {% include 'geodata/_includes/empty_viewer.html' %}
+  {% include 'rgd/_include/empty_viewer.html' %}
 
   <script>
     let extents = JSON.parse('{{ extents|escapejs }}');
     setBounds(extents.extent)
   </script>
 
-  {% include 'geodata/_includes/geojson_reader.html' %}
+  {% include 'rgd/_include/geojson_reader.html' %}
 
 {% endblock %}
diff --git a/rgd/geodata/templates/geodata/spatial_entries.html b/django-rgd/rgd/templates/rgd/spatialentry_list.html
similarity index 75%
rename from rgd/geodata/templates/geodata/spatial_entries.html
rename to django-rgd/rgd/templates/rgd/spatialentry_list.html
index 1569c57b8..ccda86026 100644
--- a/rgd/geodata/templates/geodata/spatial_entries.html
+++ b/django-rgd/rgd/templates/rgd/spatialentry_list.html
@@ -1,10 +1,10 @@
-{% extends "geodata/_base/search_view.html" %}
+{% extends "rgd/_base/search_view.html" %}
 {% block title %}
   Spatial Entries
 {% endblock title %}
 
 {% block search_fields %}
-  {% include 'geodata/_includes/search_fields.html' %}
+  {% include 'rgd/_include/search_fields.html' %}
 {% endblock search_fields %}
 
 {% block list_view %}
@@ -21,9 +21,9 @@
       <tbody>
         {% for entry in spatial_entries %}
           <tr>
-            <td><a href="{% url 'spatial-entry-detail' entry.pk %}">{{ entry.spatial_id }} - {{ entry.subentry_name | truncatechars:12 }}</a></td>
+            <td><a href="{% url 'spatial-entry-detail' entry.pk %}">{{ entry.spatial_id }} - {{ entry.name | truncatechars:12 }}</a></td>
             <td>{{ entry.footprint.area | floatformat:-2 }}</td>
-            <td>{{ entry.subentry_type }}</td>
+            <td>{{ entry }}</td>
           </tr>
         {% endfor %}
       </tbody>
diff --git a/rgd/geodata/templates/geodata/statistics.html b/django-rgd/rgd/templates/rgd/statistics.html
similarity index 90%
rename from rgd/geodata/templates/geodata/statistics.html
rename to django-rgd/rgd/templates/rgd/statistics.html
index bce43ac11..7b020af60 100644
--- a/rgd/geodata/templates/geodata/statistics.html
+++ b/django-rgd/rgd/templates/rgd/statistics.html
@@ -1,4 +1,4 @@
-{% extends "geodata/_base/base.html" %}
+{% extends "rgd/_base/base.html" %}
 {% block title %}
   Catalog Statistics
 {% endblock title %}
@@ -28,10 +28,6 @@
                 <td># of Spatial Entries</td>
                 <td>{{ count }}</td>
               </tr>
-              <tr>
-                <td># of Rasters</td>
-                <td>{{ raster_count }}</td>
-              </tr>
               <tr>
                 <td># of Instruments</td>
                 <td>{{ instrumentation_count }}</td>
@@ -51,7 +47,7 @@
     </div>
 
     <div class="column p-0">
-      {% include 'geodata/_includes/empty_viewer.html' %}
+      {% include 'rgd/_include/empty_viewer.html' %}
 
       <script>
         var mapviews = document.getElementsByClassName('geojs-map');
diff --git a/django-rgd/rgd/urls.py b/django-rgd/rgd/urls.py
new file mode 100644
index 000000000..7ae2e2dd7
--- /dev/null
+++ b/django-rgd/rgd/urls.py
@@ -0,0 +1,48 @@
+from django.urls import path
+
+from . import rest, views
+
+urlpatterns = [
+    # Pages
+    path('rgd', views.SpatialEntriesListView.as_view(), name='rgd-index'),
+    path(
+        'rgd/statistics',
+        views.StatisticsView.as_view(),
+        name='statistics',
+    ),
+    path(
+        'rgd/spatial_entries/<int:pk>/',
+        views.spatial_entry_redirect_view,
+        name='spatial-entry-detail',
+    ),
+    #############
+    # Search
+    path('api/rgd/search', rest.search.SearchSpatialEntryView.as_view()),
+    #############
+    # Other
+    path(
+        'api/rgd/status/<model>/<int:pk>',
+        rest.download.get_status,
+        name='get-status',
+    ),
+    path(
+        'api/rgd/spatial_entry/<int:spatial_id>',
+        rest.get.GetSpatialEntry.as_view(),
+        name='spatial-entry',
+    ),
+    path(
+        'api/rgd/spatial_entry/<int:spatial_id>/footprint',
+        rest.get.GetSpatialEntryFootprint.as_view(),
+        name='spatial-entry-footprint',
+    ),
+    path(
+        'api/rgd/checksum_file/<int:pk>',
+        rest.get.GetChecksumFile.as_view(),
+        name='checksum-file',
+    ),
+    path(
+        'api/rgd/checksum_file/<int:pk>/data',
+        rest.download.download_checksum_file,
+        name='checksum-file-data',
+    ),
+]
diff --git a/rgd/utility.py b/django-rgd/rgd/utility.py
similarity index 95%
rename from rgd/utility.py
rename to django-rgd/rgd/utility.py
index 42c3e43d1..f633b1c16 100644
--- a/rgd/utility.py
+++ b/django-rgd/rgd/utility.py
@@ -1,5 +1,6 @@
 import contextlib
 from contextlib import contextmanager
+from functools import wraps
 import hashlib
 import inspect
 import logging
@@ -285,7 +286,22 @@ def input_output_path_helper(
 
 def uuid_prefix_filename(instance: Any, filename: str):
     """Use a variable in settings to add a prefix to the path and keep the random uuid."""
-    prefix = settings.RGD_FILE_FIELD_PREFIX
+    prefix = getattr(settings, 'RGD_FILE_FIELD_PREFIX', None)
     if prefix:
         return f'{prefix}/{uuid4()}/{filename}'
     return f'{uuid4()}/{filename}'
+
+
+def skip_signal():
+    """Skip the signal on an instance-basis."""
+
+    def _skip_signal(signal_func):
+        @wraps(signal_func)
+        def _decorator(sender, instance, *args, **kwargs):
+            if hasattr(instance, 'skip_signal'):
+                return None
+            return signal_func(sender, instance, *args, **kwargs)
+
+        return _decorator
+
+    return _skip_signal
diff --git a/rgd/geodata/views.py b/django-rgd/rgd/views.py
similarity index 61%
rename from rgd/geodata/views.py
rename to django-rgd/rgd/views.py
index 92bedaf90..04c528d78 100644
--- a/rgd/geodata/views.py
+++ b/django-rgd/rgd/views.py
@@ -1,5 +1,6 @@
 import json
 
+from django.contrib import messages
 from django.contrib.gis.db.models import Collect, Extent
 from django.contrib.gis.db.models.functions import AsGeoJSON, Centroid
 from django.contrib.postgres.aggregates import ArrayAgg
@@ -8,15 +9,9 @@
 from django.views import generic
 from django.views.generic import DetailView
 from rest_framework.reverse import reverse
+from rgd import permissions
 
-from rgd.geodata import permissions
-
-from .filters import RasterMetaEntryFilter, SpatialEntryFilter
-from .models.common import SpatialEntry
-from .models.fmv import FMVEntry
-from .models.geometry import GeometryEntry
-from .models.imagery import ImageSetSpatial, RasterMetaEntry
-from .models.threed import PointCloudEntry, PointCloudMetaEntry
+from . import filters, models
 
 
 class PermissionDetailView(DetailView):
@@ -80,32 +75,33 @@ def get_context_data(self, *args, **kwargs):
 
 
 class SpatialEntriesListView(_SpatialListView):
-    model = SpatialEntry
-    filter = SpatialEntryFilter
+    model = models.SpatialEntry
+    filter = filters.SpatialEntryFilter
     context_object_name = 'spatial_entries'
-    template_name = 'geodata/spatial_entries.html'
+    template_name = 'rgd/spatialentry_list.html'
 
     def get_queryset(self):
         filterset = self.filter(data=self.request.GET)
-        assert filterset.is_valid()
-        queryset = filterset.filter_queryset(
-            self.model.objects.select_related(
-                'rastermetaentry',
-                'rastermetaentry__parent_raster',
-                'geometryentry',
-                'fmventry',
-                'pointcloudmetaentry',
-                'imagesetspatial',
-            )
-        )
-        return permissions.filter_read_perm(self.request.user, queryset).order_by('spatial_id')
+        queryset = self.model.objects.select_subclasses().order_by('spatial_id')
+        if not filterset.is_valid():
+            message = 'Filter parameters illformed. Full results returned.'
+            all_error_messages_content = [
+                msg.message
+                for msg in list(messages.get_messages(self.request))
+                if msg.level_tag == 'error'
+            ]
+            if message not in all_error_messages_content:
+                messages.add_message(self.request, messages.ERROR, message)
+            return queryset
+        queryset = filterset.filter_queryset(queryset)
+        return permissions.filter_read_perm(self.request.user, queryset)
 
 
 class StatisticsView(generic.ListView):
     paginate_by = None
-    model = SpatialEntry
+    model = models.SpatialEntry
     context_object_name = 'spatial_entries'
-    template_name = 'geodata/statistics.html'
+    template_name = 'rgd/statistics.html'
 
     def get_queryset(self):
         queryset = self.model.objects.all()
@@ -117,11 +113,6 @@ def get_context_data(self, *args, **kwargs):
             self.get_queryset().aggregate(
                 count=Count('spatial_id', distinct=True),
                 coordinates=ArrayAgg(AsGeoJSON(Centroid('footprint'))),
-                raster_count=Count(
-                    'spatial_id',
-                    distinct=True,
-                    filter=Q(rastermetaentry__isnull=False),
-                ),
                 instrumentation_count=Count(
                     'instrumentation',
                     distinct=True,
@@ -149,19 +140,6 @@ def get_context_data(self, *args, **kwargs):
         return context
 
 
-class RasterMetaEntriesListView(_SpatialListView):
-    model = RasterMetaEntry
-    filter = RasterMetaEntryFilter
-    context_object_name = 'spatial_entries'
-    template_name = 'geodata/raster_entries.html'
-
-    def get_queryset(self):
-        filterset = self.filter(data=self.request.GET)
-        assert filterset.is_valid()
-        queryset = filterset.filter_queryset(self.model.objects.select_related('parent_raster'))
-        return permissions.filter_read_perm(self.request.user, queryset).order_by('spatial_id')
-
-
 class _SpatialDetailView(PermissionDetailView):
     def get_object(self):
         obj = super().get_object()
@@ -194,59 +172,7 @@ def get_context_data(self, *args, **kwargs):
         return context
 
 
-class RasterEntryDetailView(_SpatialDetailView):
-    model = RasterMetaEntry
-
-
-class FMVEntryDetailView(_SpatialDetailView):
-    model = FMVEntry
-
-    def _get_extent(self):
-        extent = super()._get_extent()
-        if self.object.ground_union is not None:
-            # All or none of these will be set, only check one
-            extent['collect'] = self.object.ground_union.json
-            extent['ground_frames'] = self.object.ground_frames.json
-            extent['frame_numbers'] = self.object._blob_to_array(self.object.frame_numbers)
-        return extent
-
-    def get_context_data(self, *args, **kwargs):
-        context = super().get_context_data(*args, **kwargs)
-        context['frame_rate'] = json.dumps(self.object.fmv_file.frame_rate)
-        return context
-
-
-class GeometryEntryDetailView(_SpatialDetailView):
-    model = GeometryEntry
-
-    def _get_extent(self):
-        extent = super()._get_extent()
-        extent['data'] = self.object.data.json
-        return extent
-
-
 def spatial_entry_redirect_view(request, pk):
-    spat = SpatialEntry.objects.get(pk=pk)
-    sub = spat.subentry
-    if isinstance(sub, RasterMetaEntry):
-        name = 'raster-entry-detail'
-    elif isinstance(sub, GeometryEntry):
-        name = 'geometry-entry-detail'
-    elif isinstance(sub, FMVEntry):
-        name = 'fmv-entry-detail'
-    elif isinstance(sub, PointCloudMetaEntry):
-        name = 'point-cloud-entry-detail'
-        sub = sub.parent_point_cloud
-    elif isinstance(sub, ImageSetSpatial):
-        name = 'image-set-spatial-detail'
-    else:
-        raise ValueError()
-    return redirect(reverse(name, kwargs={'pk': sub.pk}))
-
-
-class PointCloudEntryDetailView(PermissionDetailView):
-    model = PointCloudEntry
-
-
-class ImageSetSpatialDetailView(_SpatialDetailView):
-    model = ImageSetSpatial
+    # NOTE HACK: this is dirty but it works
+    spat = models.SpatialEntry.objects.filter(pk=pk).select_subclasses().first()
+    return redirect(reverse(spat.detail_view_name, kwargs={'pk': spat.pk}))
diff --git a/setup.py b/django-rgd/setup.py
similarity index 62%
rename from setup.py
rename to django-rgd/setup.py
index 02dd7153a..ba52df524 100644
--- a/setup.py
+++ b/django-rgd/setup.py
@@ -1,8 +1,10 @@
+from io import open as io_open
+import os
 from pathlib import Path
 
 from setuptools import find_packages, setup
 
-readme_file = Path(__file__).parent / 'README.md'
+readme_file = Path(__file__).parent.parent / 'README.md'
 if readme_file.exists():
     with readme_file.open() as f:
         long_description = f.read()
@@ -10,15 +12,21 @@
     # When this is first installed in development Docker, README.md is not available
     long_description = ''
 
+__version__ = None
+filepath = os.path.dirname(__file__)
+version_file = os.path.join(filepath, '..', 'version.py')
+with io_open(version_file, mode='r') as fd:
+    exec(fd.read())
+
 setup(
-    name='resonantgeodata',
-    version='0.1.1',
+    name='django-rgd',
+    version=__version__,
     description='',
     long_description=long_description,
     long_description_content_type='text/markdown',
     license='Apache 2.0',
     author='Kitware, Inc.',
-    author_email='kitware@kitware.com',
+    author_email='rgd@kitware.com',
     url='https://github.com/ResonantGeoData/ResonantGeoData',
     keywords='',
     classifiers=[
@@ -36,15 +44,15 @@
     python_requires='>=3.8',
     packages=find_packages(),
     include_package_data=True,
+    dependency_links=[
+        'https://girder.github.io/large_image_wheels',
+    ],
     install_requires=[
         'boto3',
         'celery',
         'django>=3.2',  # See PR #264: due to this bug: https://code.djangoproject.com/ticket/31910
         'django-allauth',
-        'django-cleanup',
         'django-click',
-        'django-configurations[database,email]',
-        'django-crispy-forms',
         'django-extensions',
         'django-filter',
         'django-girder-utils',
@@ -53,45 +61,17 @@
         'djangorestframework',
         'drf-yasg',
         'GDAL',
-        'large-image>=1.6.0',
-        'large-image-source-gdal>=1.6.0',
-        'large-image-source-pil>=1.6.0',
-        'numpy',
         'pooch',
+        'psycopg2',
         'python-magic',
-        'pystac[validation]',
-        'rasterio',
-        'rules',
-        'shapely',
-        'uritemplate',
+        'flower',
         # Production-only
-        'django-composed-configuration[prod]>=0.16',
         'django-s3-file-field[minio]',
-        'flower',
-        'gunicorn',
     ],
     extras_require={
-        'dev': [
-            'django-composed-configuration[dev]>=0.16',
-            'django-debug-toolbar',
-            'ipython',
-            'tox',
-        ],
-        'worker': [
-            'fiona',
-            'scipy',
-            'kwarray>=0.5.10',
-            'kwcoco',
-            'kwimage[headless]>=0.6.7',
-            'large-image-converter',
-            'pyntcloud[LAS]',
-            'pyvista',
-        ],
         'fuse': [
+            'numpy',  # requirement of `simple-httpfs`
             'simple-httpfs',
         ],
-        'fmv': [
-            'kwiver',
-        ],
     },
 )
diff --git a/docker-compose.override.yml b/docker-compose.override.yml
index 6593d6c39..09597e75c 100644
--- a/docker-compose.override.yml
+++ b/docker-compose.override.yml
@@ -4,7 +4,7 @@ services:
     build:
       context: .
       dockerfile: ./dev/django.Dockerfile
-    command: ["./manage.py", "runserver", "0.0.0.0:8000"]
+    command: ["./example_project/manage.py", "runserver", "0.0.0.0:8000"]
     # Log printing via Rich is enhanced by a TTY
     tty: true
     env_file: ./dev/.env.docker-compose
@@ -23,7 +23,7 @@ services:
       dockerfile: ./dev/celery.Dockerfile
     command: [
       "celery",
-      "--app", "rgd.celery",
+      "--app", "rgd_example.celery",
       "worker",
       "--loglevel", "INFO",
       "--without-heartbeat"
@@ -54,4 +54,4 @@ services:
       dockerfile: ./sass.Dockerfile
     volumes:
       # Must point to top level, image will compile things in `sass/ su`
-      - ./rgd/geodata/static/geodata/css/:/src
+      - ./django-rgd/rgd/static/rgd/css/:/src
diff --git a/docs/ingest-data.md b/docs/ingest-data.md
index dcce4dc0a..dfb22590c 100644
--- a/docs/ingest-data.md
+++ b/docs/ingest-data.md
@@ -1,7 +1,7 @@
 # How to Ingest Data
 
 
-## Imagery: `ImageEntry` and `RasterEntry`
+## Imagery: `ImageEntry` and `Raster`
 
 All new images added to the catalog, spatial or not, must be added as a new
 `ImageFile` entry which links to the base `ChecksumFile` model.
@@ -29,7 +29,7 @@ If you want to register one or more of your uploaded images as a geospatial rast
 ![image_set](./images/image_set.png)
 
 
-Once you have an `ImageSet`, we need to register that set with the `RasterEntry` model. Create a new `RasterEntry` and only populate the first section of fields: name, description, and image set. Then hit save, and the linked `RasterMetaEntry` will be automatically populated.
+Once you have an `ImageSet`, we need to register that set with the `Raster` model. Create a new `Raster` and only populate the first section of fields: name, description, and image set. Then hit save, and the linked `RasterMeta` will be automatically populated.
 
 
 ![raster_entry](./images/raster_entry.png)
@@ -39,20 +39,20 @@ After this, the raster is registered with the data catalog, and it will appear o
 
 ![raster_search](./images/raster_search.png)
 
-## Geometry: `GeometryEntry`
+## Geometry: `Geometry`
 
 
-Creating a new `GeometryEntry` is very similar to the previous process of creating a new `ImageEntry`. To do so, first, upload a `.zip` archive of the shapefile and its auxiliary files to a new `ChecksumFile` entry. Then link that `ChecksumFile` to a new `GeometryArchie` entry:
+Creating a new `Geometry` is very similar to the previous process of creating a new `ImageEntry`. To do so, first, upload a `.zip` archive of the shapefile and its auxiliary files to a new `ChecksumFile` entry. Then link that `ChecksumFile` to a new `GeometryArchie` entry:
 
 ![geometry_archive](./images/geometry_archive.png)
 
-This will then auto populate the linked `GeometryEntry`, and the dataset will immediately be available in the catalog.
+This will then auto populate the linked `Geometry`, and the dataset will immediately be available in the catalog.
 
-## Full Motion Video: `FMVEntry`
+## Full Motion Video: `FMVMeta`
 
 
-Creating a new `FMVEntry` is very similar to the previous processes. To do so, first, upload the FMV file to a new `ChecksumFile` entry. Then link that `ChecksumFile` to a new `FMVFile` entry:
+Creating a new `FMVMeta` is very similar to the previous processes. To do so, first, upload the FMV file to a new `ChecksumFile` entry. Then link that `ChecksumFile` to a new `FMV` entry:
 
 ![fmv_file](./images/fmv_file.png)
 
-This will then auto populate the various disabled fields in the `FMVFile` model with temp files for extracting the spatial data and a web-friendly version of the video. This will also automatically fill out the linked `FMVEntry`, and the dataset will soon be available in the catalog. The processing routine for these files can take a while to run to completion.
+This will then auto populate the various disabled fields in the `FMV` model with temp files for extracting the spatial data and a web-friendly version of the video. This will also automatically fill out the linked `FMVMeta`, and the dataset will soon be available in the catalog. The processing routine for these files can take a while to run to completion.
diff --git a/example_project/manage.py b/example_project/manage.py
new file mode 100755
index 000000000..d55bf7d0c
--- /dev/null
+++ b/example_project/manage.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+import os
+import sys
+
+from django.core.management import execute_from_command_line
+
+
+def main() -> None:
+    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rgd_example.settings')
+    execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/example_project/rgd_example/__init__.py b/example_project/rgd_example/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/example_project/rgd_example/celery.py b/example_project/rgd_example/celery.py
new file mode 100644
index 000000000..4ba5240e8
--- /dev/null
+++ b/example_project/rgd_example/celery.py
@@ -0,0 +1,17 @@
+import os
+
+from celery import Celery
+
+# import configurations.importer
+#
+os.environ['DJANGO_SETTINGS_MODULE'] = 'rgd_example.settings'
+# if not os.environ.get('DJANGO_CONFIGURATION'):
+#     raise ValueError('The environment variable "DJANGO_CONFIGURATION" must be set.')
+# configurations.importer.install()
+
+# Using a string config_source means the worker doesn't have to serialize
+# the configuration object to child processes.
+app = Celery('rgd_example', config_source='django.conf:settings', namespace='CELERY')
+
+# Load task modules from all registered Django app configs.
+app.autodiscover_tasks()
diff --git a/example_project/rgd_example/settings.py b/example_project/rgd_example/settings.py
new file mode 100644
index 000000000..0ad628e4c
--- /dev/null
+++ b/example_project/rgd_example/settings.py
@@ -0,0 +1,21 @@
+from rgd_testing_utils.settings import *  # noqa
+
+INSTALLED_APPS += [  # noqa
+    'rgd_3d',
+    'rgd_fmv',
+    'rgd_geometry',
+    'rgd_imagery',
+    # Swagger
+    'drf_yasg',
+    'django_extensions',
+]
+
+ROOT_URLCONF = 'rgd_example.urls'
+WSGI_APPLICATION = 'rgd_example.wsgi.application'
+
+
+# Swagger
+REFETCH_SCHEMA_WITH_AUTH = True
+REFETCH_SCHEMA_ON_LOGOUT = True
+OPERATIONS_SORTER = 'alpha'
+DEEP_LINKING = True
diff --git a/example_project/rgd_example/urls.py b/example_project/rgd_example/urls.py
new file mode 100644
index 000000000..610edd7e6
--- /dev/null
+++ b/example_project/rgd_example/urls.py
@@ -0,0 +1,44 @@
+from django.contrib import admin
+from django.urls import include, path, re_path
+from django.views.generic.base import RedirectView
+from drf_yasg import openapi
+from drf_yasg.views import get_schema_view
+from rest_framework import permissions
+
+urlpatterns = [
+    path('admin/', admin.site.urls),
+    path('accounts/', include('allauth.urls')),
+    path('oauth/', include('oauth2_provider.urls', namespace='oauth2_provider')),
+    path('api/s3-upload/', include('s3_file_field.urls')),
+    path('', include('rgd.urls')),
+    path('', include('rgd_3d.urls')),
+    path('', include('rgd_fmv.urls')),
+    path('', include('rgd_geometry.urls')),
+    path('', include('rgd_imagery.urls')),
+    # Redirect homepage to RGD core app homepage
+    path(r'', RedirectView.as_view(url='rgd', permanent=False), name='index'),
+]
+
+schema_view = get_schema_view(
+    openapi.Info(
+        title='ResonantGeoData API',
+        default_version='v1',
+        description='ResonantGeoData',
+        # terms_of_service='https://www.google.com/policies/terms/',
+        contact=openapi.Contact(email='kitware@kitare.com'),
+        license=openapi.License(name='Apache 2.0'),
+    ),
+    public=False,
+    permission_classes=(permissions.AllowAny,),
+    patterns=urlpatterns,
+)
+
+urlpatterns += [
+    re_path(
+        r'^swagger(?P<format>\.json|\.yaml)$',
+        schema_view.without_ui(cache_timeout=0),
+        name='schema-json',
+    ),
+    path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
+    path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
+]
diff --git a/example_project/rgd_example/wsgi.py b/example_project/rgd_example/wsgi.py
new file mode 100644
index 000000000..3c8b9e493
--- /dev/null
+++ b/example_project/rgd_example/wsgi.py
@@ -0,0 +1,7 @@
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rgd_example.settings')
+
+application = get_wsgi_application()
diff --git a/example_project/setup.py b/example_project/setup.py
new file mode 100644
index 000000000..451045532
--- /dev/null
+++ b/example_project/setup.py
@@ -0,0 +1,41 @@
+from setuptools import find_packages, setup
+
+setup(
+    name='rgd_example',
+    version='0.0.0',
+    description='',
+    long_description_content_type='text/markdown',
+    license='Apache 2.0',
+    author='Kitware, Inc.',
+    author_email='rgd@kitware.com',
+    url='https://github.com/ResonantGeoData/ResonantGeoData',
+    keywords='',
+    classifiers=[
+        'Development Status :: 3 - Alpha',
+        'Environment :: Web Environment',
+        'Framework :: Django :: 3.0',
+        'Framework :: Django',
+        'Intended Audience :: Developers',
+        'License :: OSI Approved :: Apache Software License',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python',
+    ],
+    python_requires='>=3.8',
+    packages=find_packages(),
+    include_package_data=True,
+    install_requires=[
+        'django-extensions',
+        'django-debug-toolbar',
+        'django-rest-swagger',
+        'ipython',
+    ],
+    extras_require={
+        'graph': [
+            'pygraphviz',
+            'pyparsing',
+            'pydot',
+        ],
+    },
+)
diff --git a/manage.py b/manage.py
deleted file mode 100755
index 2c69602e8..000000000
--- a/manage.py
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/usr/bin/env python
-import os
-import sys
-
-import configurations.importer
-
-
-def main():
-    os.environ['DJANGO_SETTINGS_MODULE'] = 'rgd.settings'
-    # Production usage runs manage.py for tasks like collectstatic,
-    # so DJANGO_CONFIGURATION should always be explicitly set in production
-    os.environ.setdefault('DJANGO_CONFIGURATION', 'DevelopmentConfiguration')
-    # No need for check_options, users should always set DJANGO_CONFIGURATION
-    configurations.importer.install(check_options=False)
-
-    try:
-        from django.core.management import execute_from_command_line
-    except ImportError as exc:
-        raise ImportError(
-            "Couldn't import Django. Are you sure it's installed and "
-            'available on your PYTHONPATH environment variable? Did you '
-            'forget to activate a virtual environment?'
-        ) from exc
-    execute_from_command_line(sys.argv)
-
-
-if __name__ == '__main__':
-    main()
diff --git a/requirements.txt b/requirements.txt
index 51268817e..ad965c36c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,10 @@
-# This should only be used by the Heroku buildpack, as it does not support PIP_FIND_LINKS
 --find-links https://girder.github.io/large_image_wheels
--e .
+factory-boy
+pytest
+pytest-django
+pytest-factoryboy
+pytest-mock
+pytest-cov
+pytest-memprof
+-e ./django-rgd
+-e ./testing-utils
diff --git a/rgdc/README.md b/rgd-client/README.md
similarity index 75%
rename from rgdc/README.md
rename to rgd-client/README.md
index 645e2dc36..4c22f5ff4 100644
--- a/rgdc/README.md
+++ b/rgd-client/README.md
@@ -1,11 +1,11 @@
-# rgdc - ResonantGeoDataClient
+# rgd_client - ResonantGeoDataClient
 
-The **rgdc** Python package allows users to make web requests to a ResonantGeoData instance within a Python script.
+The **rgd_client** Python package allows users to make web requests to a ResonantGeoData instance within a Python script.
 
 
 # Installation
 ```
-pip install rgdc
+pip install rgd-client
 ```
 
 # Usage
@@ -15,10 +15,14 @@ import json
 import matplotlib.pyplot as plt
 import numpy as np
 
-from rgdc import Rgdc
+from rgd_client import Rgdc
 
 def plot_geojson(gjs, *args, **kwargs):
-    points = np.array(gjs['coordinates'][0])
+    points = np.array(gjs['coordinates'])
+    if points.ndim == 3:
+        points = points[0]
+    if points.ndim == 1:
+        points = points.reshape((1, points.size, ))
     return plt.plot(points[:,0], points[:,1], *args, **kwargs)
 
 client = Rgdc(username='username', password='password')
@@ -50,12 +54,14 @@ plt.title(f'Count: {len(q)}')
 ```
 
 ### Inspect raster
+
+Preview thumbnails of the raster
+
 ```python
 import imageio
 from io import BytesIO
-import requests
 
-raster = requests.get(q[0]['detail']).json()
+raster = client.get_raster(q[0])
 plot_geojson(bbox, 'k--')
 plot_geojson(raster['outline'], 'r')
 load_image = lambda imbytes: imageio.imread(BytesIO(imbytes))
@@ -72,6 +78,9 @@ plt.show()
 ```
 
 ### Download Raster
+
+Download the entire image set of the raster
+
 ```python
 import rasterio
 from rasterio.plot import show
@@ -79,7 +88,7 @@ from rasterio.plot import show
 paths = client.download_raster(q[0])
 rasters = [rasterio.open(im) for im in paths.images]
 for i, src in enumerate(rasters):
-    plt.subplot(1, count, i+1)
+    plt.subplot(1, len(rasters), i+1)
     ax = plt.gca()
     show(src, ax=ax)
 plt.tight_layout()
diff --git a/rgdc/pyproject.toml b/rgd-client/pyproject.toml
similarity index 100%
rename from rgdc/pyproject.toml
rename to rgd-client/pyproject.toml
diff --git a/rgd-client/rgd_client/__init__.py b/rgd-client/rgd_client/__init__.py
new file mode 100644
index 000000000..1745e4123
--- /dev/null
+++ b/rgd-client/rgd_client/__init__.py
@@ -0,0 +1,2 @@
+from ._version import __version__  # noqa
+from .rgdc import Rgdc  # noqa F401
diff --git a/rgd-client/rgd_client/_version.py b/rgd-client/rgd_client/_version.py
new file mode 100644
index 000000000..832e95544
--- /dev/null
+++ b/rgd-client/rgd_client/_version.py
@@ -0,0 +1,7 @@
+from pkg_resources import DistributionNotFound, get_distribution
+
+try:
+    __version__ = get_distribution('rgd-client').version
+except DistributionNotFound:
+    # package is not installed
+    __version__ = None
diff --git a/rgdc/rgdc/rgdc.py b/rgd-client/rgd_client/rgdc.py
similarity index 91%
rename from rgdc/rgdc/rgdc.py
rename to rgd-client/rgd_client/rgdc.py
index 9c5b14400..5f624ab98 100644
--- a/rgdc/rgdc/rgdc.py
+++ b/rgd-client/rgd_client/rgdc.py
@@ -57,7 +57,7 @@ def __init__(
 
     def list_image_tiles(self, image_id: Union[str, int]) -> Dict:
         """List geodata imagery tiles."""
-        r = self.session.get(f'geoprocess/imagery/{image_id}/tiles')
+        r = self.session.get(f'image_process/imagery/{image_id}/tiles')
         return r.json()
 
     def download_image_file(
@@ -73,7 +73,7 @@ def download_image_file(
         Returns:
             An iterator of byte chunks.
         """
-        r = self.session.get(f'geodata/imagery/{image_id}/data', stream=True)
+        r = self.session.get(f'rgd_imagery/{image_id}/data', stream=True)
         return r.iter_content(chunk_size=chunk_size)
 
     def download_image_thumbnail(
@@ -89,7 +89,7 @@ def download_image_thumbnail(
         Returns:
             Thumbnail bytes.
         """
-        r = self.session.get(f'geoprocess/imagery/{image_id}/thumbnail')
+        r = self.session.get(f'image_process/imagery/{image_id}/thumbnail')
         return r.content
 
     def download_raster_thumbnail(
@@ -101,7 +101,7 @@ def download_raster_thumbnail(
         Download the generated thumbnail for this ImageEntry.
 
         Args:
-            raster_meta_id: The id of the RasterMetaEntry, which is a child to the desired raster entry, or search result.
+            raster_meta_id: The id of the RasterMeta, which is a child to the desired raster entry, or search result.
             band: The index of the image in the raster's image set to produce thumbnail from.
 
         Returns:
@@ -110,7 +110,7 @@ def download_raster_thumbnail(
         if isinstance(raster_meta_id, dict):
             raster_meta_id = spatial_subentry_id(raster_meta_id)
 
-        r = self.session.get(f'geodata/imagery/raster/{raster_meta_id}')
+        r = self.session.get(f'rgd_imagery/raster/{raster_meta_id}')
         parent_raster = r.json().get('parent_raster', {})
         images = parent_raster.get('image_set', {}).get('images', [])
         try:
@@ -131,9 +131,9 @@ def get_raster(self, raster_meta_id: Union[str, int, dict], stac: bool = False)
             raster_meta_id = spatial_subentry_id(raster_meta_id)
 
         if stac:
-            r = self.session.get(f'geodata/imagery/raster/{raster_meta_id}/stac')
+            r = self.session.get(f'rgd_imagery/raster/{raster_meta_id}/stac')
         else:
-            r = self.session.get(f'geodata/imagery/raster/{raster_meta_id}')
+            r = self.session.get(f'rgd_imagery/raster/{raster_meta_id}')
         return r.json()
 
     def download_raster(
@@ -147,7 +147,7 @@ def download_raster(
         Download the image set associated with a raster entry to disk.
 
         Args:
-            raster_meta_id: The id of the RasterMetaEntry, which is a child to the desired raster entry, or search result.
+            raster_meta_id: The id of the RasterMeta, which is a child to the desired raster entry, or search result.
             pathname: The directory to download the image set to. If not supplied, a temporary directory will be used.
             nest_with_name: If True, nests the download within an additional directory, using the raster entry name.
             keep_existing: If False, replace files existing on disk. Only valid if `pathname` is given.
@@ -158,7 +158,7 @@ def download_raster(
         if isinstance(raster_meta_id, dict):
             raster_meta_id = spatial_subentry_id(raster_meta_id)
 
-        r = self.session.get(f'geodata/imagery/raster/{raster_meta_id}')
+        r = self.session.get(f'rgd_imagery/raster/{raster_meta_id}')
         parent_raster = r.json().get('parent_raster', {})
 
         # Create dirs after request to avoid empty dirs if failed
@@ -182,7 +182,7 @@ def download_raster(
         # Download images
         images = parent_raster.get('image_set', {}).get('images', [])
         for image in tqdm(images, desc='Downloading image files'):
-            file = image.get('image_file', {}).get('file', {})
+            file = image.get('file', {})
             file_path = download_checksum_file_to_path(file, path, keep_existing=keep_existing)
             if file_path:
                 raster_download.images.append(file_path)
@@ -238,11 +238,11 @@ def search(
             limit=limit,
             offset=offset,
         )
-        return list(limit_offset_pager(self.session, 'geosearch', params=params))
+        return list(limit_offset_pager(self.session, 'rgd/search', params=params))
 
     def create_raster_stac(self, raster: Dict) -> Dict:
         """Create a raster entry using STAC format."""
-        r = self.session.post('geodata/imagery/raster/stac', json=raster)
+        r = self.session.post('rgd_imagery/raster/stac', json=raster)
         r.raise_for_status()
 
         return r.json()
@@ -311,4 +311,4 @@ def search_raster_stac(
             params['cloud_cover_min'] = ccmin
             params['cloud_cover_max'] = ccmax
 
-        return list(limit_offset_pager(self.session, 'geosearch/raster', params=params))
+        return list(limit_offset_pager(self.session, 'rgd_imagery/raster/search', params=params))
diff --git a/rgdc/rgdc/session.py b/rgd-client/rgd_client/session.py
similarity index 94%
rename from rgdc/rgdc/session.py
rename to rgd-client/rgd_client/session.py
index 049048e9e..94cd1ba81 100644
--- a/rgdc/rgdc/session.py
+++ b/rgd-client/rgd_client/session.py
@@ -5,7 +5,7 @@
 from requests.packages.urllib3.util.retry import Retry
 from requests_toolbelt.sessions import BaseUrlSession
 
-from .version import __version__
+from ._version import __version__
 
 
 class RgdcSession(BaseUrlSession):
@@ -27,7 +27,7 @@ def __init__(
         super().__init__(base_url=base_url)
         self.headers.update(
             {
-                'User-agent': f'rgdc/{__version__}',
+                'User-agent': f'rgd_client/{__version__}',
                 'Accept': 'application/json',
             }
         )
diff --git a/rgdc/rgdc/types.py b/rgd-client/rgd_client/types.py
similarity index 100%
rename from rgdc/rgdc/types.py
rename to rgd-client/rgd_client/types.py
diff --git a/rgdc/rgdc/utils.py b/rgd-client/rgd_client/utils.py
similarity index 98%
rename from rgdc/rgdc/utils.py
rename to rgd-client/rgd_client/utils.py
index a0603cafd..03e6e96b8 100644
--- a/rgdc/rgdc/utils.py
+++ b/rgd-client/rgd_client/utils.py
@@ -49,7 +49,7 @@ def limit_offset_pager(session: Session, url: str, **kwargs) -> Generator[Dict,
         r.raise_for_status()
 
         # Yield results
-        results = r.json()['results']
+        results = r.json()
         yield from results
 
         # Update offset and num_results
@@ -126,7 +126,7 @@ 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']
+    return search_result['spatial_id']
 
 
 def spatial_search_params(
diff --git a/rgdc/setup.py b/rgd-client/setup.py
similarity index 79%
rename from rgdc/setup.py
rename to rgd-client/setup.py
index b7cc883ca..09de0366c 100644
--- a/rgdc/setup.py
+++ b/rgd-client/setup.py
@@ -1,3 +1,5 @@
+from io import open as io_open
+import os
 from pathlib import Path
 
 from setuptools import find_packages, setup
@@ -11,19 +13,21 @@
     # When this is first installed in development Docker, README.md is not available
     long_description = ''
 
-# Load version
-with open(base_dir / 'rgdc' / 'version.py') as version_file:
-    version = version_file.read().replace('__version__ = ', '').replace("'", '').strip()
+__version__ = None
+filepath = os.path.dirname(__file__)
+version_file = os.path.join(filepath, '..', 'version.py')
+with io_open(version_file, mode='r') as fd:
+    exec(fd.read())
 
 setup(
-    name='rgdc',
-    version=version,
+    name='rgd-client',
+    version=__version__,
     description='Make web requests to a ResonantGeoData instance.',
     long_description=long_description,
     long_description_content_type='text/markdown',
     license='Apache 2.0',
     author='Kitware, Inc.',
-    author_email='kitware@kitware.com',
+    author_email='rgd@kitware.com',
     url='https://github.com/ResonantGeoData/ResonantGeoData',
     keywords='',
     classifiers=[
diff --git a/rgd/__init__.py b/rgd/__init__.py
deleted file mode 100644
index d08b682c2..000000000
--- a/rgd/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-# This project module is imported for us when Django starts. To ensure that Celery app is always
-# defined prior to any shared_task definitions (so those tasks will bind to the app), import
-# the Celery module here for side effects.
-from .celery import app as _celery_app  # noqa: F401
-
-# Do not import anything else from this file, to avoid interfering with the startup order of the
-# Celery app and Django's settings.
diff --git a/rgd/asgi.py b/rgd/asgi.py
deleted file mode 100644
index 79b9c724f..000000000
--- a/rgd/asgi.py
+++ /dev/null
@@ -1,16 +0,0 @@
-"""
-ASGI config for ResonantGeoData project.
-
-It exposes the ASGI callable as a module-level variable named ``application``.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
-"""
-
-import os
-
-from django.core.asgi import get_asgi_application
-
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rgd.settings')
-
-application = get_asgi_application()
diff --git a/rgd/celery.py b/rgd/celery.py
deleted file mode 100644
index e0db267ff..000000000
--- a/rgd/celery.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import os
-
-from celery import Celery
-import configurations.importer
-
-os.environ['DJANGO_SETTINGS_MODULE'] = 'rgd.settings'
-if not os.environ.get('DJANGO_CONFIGURATION'):
-    raise ValueError('The environment variable "DJANGO_CONFIGURATION" must be set.')
-configurations.importer.install()
-
-# Using a string config_source means the worker doesn't have to serialize
-# the configuration object to child processes.
-app = Celery('rgd', config_source='django.conf:settings', namespace='CELERY')
-
-# Load task modules from all registered Django app configs.
-app.autodiscover_tasks()
diff --git a/rgd/geodata/actions.py b/rgd/geodata/actions.py
deleted file mode 100644
index d00781507..000000000
--- a/rgd/geodata/actions.py
+++ /dev/null
@@ -1,138 +0,0 @@
-from . import tasks
-from ..utility import get_or_create_no_commit
-from .models.imagery import ConvertedImageFile, ImageFile, ImageSet, RasterEntry
-
-
-def _make_image_set_from_images(images):
-    """Images should be an iterable, not a queryset."""
-    imset = ImageSet()
-    imset.save()  # Have to save before adding to ManyToManyField?
-    for image in images:
-        imset.images.add(image)
-    imset.save()
-    return imset
-
-
-def make_image_files(modeladmin, request, queryset):
-    """Make ImageFile from ChecksumFile."""
-    for file in queryset.all():
-        ImageFile.objects.get_or_create(file=file)
-
-
-def make_image_set_from_image_entries(modeladmin, request, queryset):
-    """Make an `ImageSet` of the selected `ImageEntry`s.
-
-    This is an action on `ImageEntry`.
-    """
-    return _make_image_set_from_images(queryset.all())
-
-
-def _make_raster_from_image_set(imset):
-    raster = RasterEntry()
-    raster.image_set = imset
-    raster.save()
-    return raster
-
-
-def make_raster_from_image_entries(modeladmin, request, queryset):
-    """Make a raster of the selected `ImageEntry`s.
-
-    This is an action on `ImageEntry`
-    """
-    imset = make_image_set_from_image_entries(modeladmin, request, queryset)
-    return _make_raster_from_image_set(imset)
-
-
-def make_raster_from_image_set(modeladmin, request, queryset):
-    """Make a raster of the selected `ImageSet`.
-
-    This is an action on `ImageSet`.
-    """
-    rasters = []
-    for imset in queryset.all():
-        rasters.append(_make_raster_from_image_set(imset))
-    return rasters
-
-
-def make_raster_for_each_image_entry(modeladmin, request, queryset):
-    """Make a raster for each of the selected `ImageEntry`s.
-
-    This is an action on `ImageEntry`.
-
-    This creates one raster for each image entry.
-    """
-    rasters = []
-    for img in queryset.all():
-        imset = _make_image_set_from_images(
-            [
-                img,
-            ]
-        )
-        rasters.append(_make_raster_from_image_set(imset))
-    return rasters
-
-
-def reprocess(modeladmin, request, queryset):
-    """Trigger the save event task for each entry."""
-    for entry in queryset.all():
-        entry.save()
-
-
-def reprocess_rastermeta(modeladmin, request, queryset):
-    """Trigger the save event task for each entry."""
-    for entry in queryset.all():
-        entry.parent_raster.save()
-
-
-def generate_valid_data_footprint(modeladmin, request, queryset):
-    """Generate a valid data footprint for each raster."""
-    for rast in queryset.all():
-        tasks.task_populate_raster_footprint.delay(rast.id)
-
-
-def generate_valid_data_footprint_rastermeta(modeladmin, request, queryset):
-    """Generate a valid data footprint for each raster."""
-    for entry in queryset.all():
-        tasks.task_populate_raster_footprint.delay(entry.parent_raster.id)
-
-
-def generate_outline(modeladmin, request, queryset):
-    """Generate a the outline for each raster."""
-    for rast in queryset.all():
-        tasks.task_populate_raster_outline.delay(rast.id)
-
-
-def clean_empty_image_sets(modeladmin, request, queryset):
-    """Delete empty `ImageSet`s."""
-    for imset in queryset.all():
-        if len(imset.images) < 1:
-            imset.delete()
-
-
-def clean_empty_rasters(modeladmin, request, queryset):
-    """Delete if associated `ImageSet` is empty."""
-    for raster in queryset.all():
-        if len(raster.image_set.images) < 1:
-            raster.image_set.delete()
-
-
-def convert_images(modeladmin, request, queryset):
-    for image in queryset.all():
-        entry, created = get_or_create_no_commit(ConvertedImageFile, source_image=image)
-        entry.save()
-
-
-def make_users_active(modeladmin, request, queryset):
-    """Make each user active."""
-    for user in queryset.all():
-        if not user.is_active:
-            user.is_active = True
-            user.save(update_fields=['is_active'])
-
-
-def make_users_staff(modeladmin, request, queryset):
-    """Make each user staff."""
-    for user in queryset.all():
-        if not user.is_staff:
-            user.is_staff = True
-            user.save(update_fields=['is_staff'])
diff --git a/rgd/geodata/admin.py b/rgd/geodata/admin.py
deleted file mode 100644
index a354fd6e6..000000000
--- a/rgd/geodata/admin.py
+++ /dev/null
@@ -1,489 +0,0 @@
-from django.contrib import admin
-from django.contrib.auth.admin import UserAdmin
-from django.contrib.auth.models import User
-from django.contrib.gis.admin import OSMGeoAdmin
-
-from . import actions
-from .models.collection import Collection, CollectionPermission
-from .models.common import ChecksumFile, WhitelistedEmail
-from .models.fmv import FMVEntry, FMVFile
-from .models.geometry import GeometryArchive, GeometryEntry
-from .models.imagery import (
-    Annotation,
-    BandMetaEntry,
-    ConvertedImageFile,
-    ImageEntry,
-    ImageFile,
-    ImageSet,
-    ImageSetSpatial,
-    KWCOCOArchive,
-    PolygonSegmentation,
-    RasterEntry,
-    RasterMetaEntry,
-    RLESegmentation,
-    Segmentation,
-    SubsampledImage,
-)
-from .models.threed.point_cloud import PointCloudEntry, PointCloudFile, PointCloudMetaEntry
-
-MODIFIABLE_FILTERS = (
-    'modified',
-    'created',
-)
-
-SPATIAL_ENTRY_FILTERS = (
-    'acquisition_date',
-    'instrumentation',
-)
-
-TASK_EVENT_FILTERS = ('status',)
-
-TASK_EVENT_READONLY = (
-    'failure_reason',
-    'status',
-)
-
-admin.site.unregister(User)
-
-
-@admin.register(User)
-class CustomUserAdmin(UserAdmin):
-    actions = [
-        actions.make_users_active,
-        actions.make_users_staff,
-    ]
-
-    list_display = (
-        'username',
-        'is_staff',
-        'is_superuser',
-        'is_active',
-    )
-
-
-class _FileGetNameMixin:
-    def get_name(self, obj):
-        return obj.file.name
-
-    get_name.short_description = 'Name'
-    get_name.admin_order_field = 'file__name'
-
-
-@admin.register(WhitelistedEmail)
-class WhitelistedEmailAdmin(OSMGeoAdmin):
-    list_display = (
-        'id',
-        'email',
-    )
-
-
-@admin.register(ChecksumFile)
-class ChecksumFileAdmin(OSMGeoAdmin):
-    list_display = (
-        'id',
-        'name',
-        'status',
-        'modified',
-        'created',
-        'type',
-        'data_link',
-        'collection',
-    )
-    readonly_fields = (
-        'checksum',
-        'last_validation',
-    ) + TASK_EVENT_READONLY
-    actions = (actions.reprocess, actions.make_image_files)
-    list_filter = (
-        MODIFIABLE_FILTERS
-        + TASK_EVENT_FILTERS
-        + (
-            'type',
-            'collection',
-        )
-    )
-
-
-@admin.register(KWCOCOArchive)
-class KWCOCOArchiveAdmin(OSMGeoAdmin):
-    list_display = (
-        'id',
-        'name',
-        'status',
-        'modified',
-        'created',
-    )
-    readonly_fields = ('image_set',) + TASK_EVENT_READONLY
-    actions = (actions.reprocess,)
-    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
-
-
-@admin.register(ImageSet)
-class ImageSetAdmin(OSMGeoAdmin):
-    list_display = (
-        'id',
-        'name',
-        'count',
-        'modified',
-        'created',
-    )
-    actions = (
-        actions.make_raster_from_image_set,
-        actions.clean_empty_image_sets,
-    )
-    list_filter = MODIFIABLE_FILTERS
-
-
-@admin.register(ImageSetSpatial)
-class ImageSetSpatialAdmin(OSMGeoAdmin):
-    list_display = (
-        'id',
-        'name',
-        'modified',
-        'created',
-    )
-    list_filter = MODIFIABLE_FILTERS + SPATIAL_ENTRY_FILTERS
-
-
-class BandMetaEntryInline(admin.StackedInline):
-    model = BandMetaEntry
-    fk_name = 'parent_image'
-
-    list_display = (
-        'id',
-        'parent_image',
-        'modified',
-        'created',
-    )
-    readonly_fields = (
-        'mean',
-        'max',
-        'min',
-        'modified',
-        'created',
-        'parent_image',
-        'std',
-        'nodata_value',
-        'dtype',
-        'band_number',
-    )
-
-    def has_add_permission(self, request, obj=None):
-        """Prevent user from adding more."""
-        return False
-
-
-@admin.register(ImageEntry)
-class ImageEntryAdmin(OSMGeoAdmin):
-    list_display = (
-        'id',
-        'name',
-        'image_file',
-        'modified',
-        'created',
-    )
-    readonly_fields = (
-        'number_of_bands',
-        'image_file',
-        'height',
-        'width',
-        'driver',
-        'modified',
-        'created',
-    )
-    list_filter = MODIFIABLE_FILTERS + (
-        'number_of_bands',
-        'driver',
-    )
-    actions = (
-        actions.make_image_set_from_image_entries,
-        actions.make_raster_from_image_entries,
-        actions.make_raster_for_each_image_entry,
-        actions.convert_images,
-    )
-    inlines = (BandMetaEntryInline,)
-
-
-@admin.register(RasterMetaEntry)
-class RasterMetaEntryAdmin(OSMGeoAdmin):
-    list_display = (
-        'id',
-        'name',
-        'acquisition_date',
-        'modified',
-        'created',
-    )
-    readonly_fields = (
-        'crs',
-        'origin',
-        'extent',
-        'resolution',
-        'transform',
-        'parent_raster',
-        'modified',
-        'created',
-    )
-    actions = (
-        actions.reprocess_rastermeta,
-        actions.generate_valid_data_footprint_rastermeta,
-    )
-    list_filter = SPATIAL_ENTRY_FILTERS + MODIFIABLE_FILTERS
-
-    modifiable = False  # To still show the footprint and outline
-
-
-@admin.register(RasterEntry)
-class RasterEntryAdmin(OSMGeoAdmin):
-    list_display = (
-        'id',
-        'name',
-        'status',
-        'count',
-        'acquisition_date',
-        'modified',
-        'created',
-    )
-    readonly_fields = (
-        'modified',
-        'created',
-    ) + TASK_EVENT_READONLY
-    actions = (
-        actions.reprocess,
-        actions.generate_valid_data_footprint,
-        actions.clean_empty_rasters,
-    )
-    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
-
-
-class SegmentationInline(admin.StackedInline):
-    model = Segmentation
-    fk_name = 'annotation'
-    list_display = ('id',)
-    readonly_fields = ('outline',)
-
-
-class PolygonSegmentationInline(admin.StackedInline):
-    model = PolygonSegmentation
-    fk_name = 'annotation'
-    list_display = ('id',)
-    readonly_fields = (
-        'outline',
-        'feature',
-    )
-
-
-class RLESegmentationInline(admin.StackedInline):
-    model = RLESegmentation
-    fk_name = 'annotation'
-    list_display = ('id',)
-    readonly_fields = ('outline', 'width', 'height', 'blob')
-
-
-@admin.register(Annotation)
-class AnnotationAdmin(OSMGeoAdmin):
-    list_display = (
-        'id',
-        'caption',
-        'label',
-        'annotator',
-        'segmentation_type',
-        'modified',
-        'created',
-    )
-    readonly_fields = (
-        'keypoints',
-        'line',
-    )
-    inlines = (SegmentationInline, PolygonSegmentationInline, RLESegmentationInline)
-    list_filter = MODIFIABLE_FILTERS + (
-        'annotator',
-        'label',
-    )
-
-
-@admin.register(ImageFile)
-class ImageFileAdmin(OSMGeoAdmin, _FileGetNameMixin):
-    list_display = (
-        'id',
-        'get_name',
-        'status',
-        'modified',
-        'created',
-        'image_data_link',
-    )
-    readonly_fields = (
-        'modified',
-        'created',
-    ) + TASK_EVENT_READONLY
-    actions = (actions.reprocess,)
-    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
-
-
-@admin.register(ConvertedImageFile)
-class ConvertedImageFileAdmin(OSMGeoAdmin):
-    list_display = (
-        'id',
-        'source_image',
-        'status',
-        'modified',
-        'created',
-    )
-    readonly_fields = ('converted_file',) + TASK_EVENT_READONLY
-    actions = (actions.reprocess,)
-    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
-
-
-@admin.register(SubsampledImage)
-class SubsampledImageAdmin(OSMGeoAdmin):
-    list_display = (
-        'id',
-        'source_image',
-        'sample_type',
-        'status',
-        'modified',
-        'created',
-    )
-    readonly_fields = ('data',) + TASK_EVENT_READONLY
-    actions = (actions.reprocess,)
-    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
-
-
-class GeometryEntryInline(admin.StackedInline):
-    model = GeometryEntry
-    fk_name = 'geometry_archive'
-    list_display = (
-        'id',
-        'name',
-        'geometry_archive',
-        'modified',
-        'created',
-    )
-    list_filter = MODIFIABLE_FILTERS + SPATIAL_ENTRY_FILTERS
-    readonly_fields = (
-        'modified',
-        'created',
-        'geometry_archive',
-    )
-    modifiable = False  # To still show the footprint and outline
-
-
-@admin.register(GeometryArchive)
-class GeometryArchiveAdmin(OSMGeoAdmin, _FileGetNameMixin):
-    list_display = (
-        'id',
-        'get_name',
-        'status',
-        'modified',
-        'created',
-        'archive_data_link',
-    )
-    readonly_fields = (
-        'modified',
-        'created',
-    ) + TASK_EVENT_READONLY
-    inlines = (GeometryEntryInline,)
-    actions = (actions.reprocess,)
-    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
-
-
-class FMVEntryInline(admin.StackedInline):
-    model = FMVEntry
-    fk_name = 'fmv_file'
-    list_display = (
-        'id',
-        'name',
-        'fmv_file',
-        'modified',
-        'created',
-    )
-    readonly_fields = (
-        'modified',
-        'created',
-    )
-
-
-@admin.register(FMVFile)
-class FMVFileAdmin(OSMGeoAdmin, _FileGetNameMixin):
-    list_display = (
-        'id',
-        'get_name',
-        'status',
-        'modified',
-        'created',
-        'fmv_data_link',
-        'klv_data_link',
-    )
-    readonly_fields = (
-        'modified',
-        'created',
-        'klv_file',
-        'web_video_file',
-        'frame_rate',
-    ) + TASK_EVENT_READONLY
-    inlines = (FMVEntryInline,)
-    actions = (actions.reprocess,)
-    list_filter = MODIFIABLE_FILTERS
-
-
-class CollectionPermissionInline(admin.TabularInline):
-    model = CollectionPermission
-    fk_name = 'collection'
-    fields = ('user', 'role')
-    extra = 1
-
-
-@admin.register(Collection)
-class CollectionAdmin(OSMGeoAdmin):
-    fields = ('name',)
-    inlines = (CollectionPermissionInline,)
-
-
-@admin.register(PointCloudFile)
-class PointCloudFileAdmin(OSMGeoAdmin, _FileGetNameMixin):
-    list_display = (
-        'id',
-        'get_name',
-        'status',
-        'modified',
-        'created',
-        'data_link',
-    )
-    readonly_fields = (
-        'modified',
-        'created',
-    ) + TASK_EVENT_READONLY
-    actions = (actions.reprocess,)
-    list_filter = MODIFIABLE_FILTERS + TASK_EVENT_FILTERS
-
-
-class PointCloudMetaEntryInline(admin.StackedInline):
-    model = PointCloudMetaEntry
-    fk_name = 'parent_point_cloud'
-    list_display = (
-        'id',
-        'modified',
-        'created',
-        'crs',
-    )
-    readonly_fields = (
-        'modified',
-        'created',
-        'parent_point_cloud',
-    )
-    modifiable = False  # To still show the footprint and outline
-
-
-@admin.register(PointCloudEntry)
-class PointCloudEntryAdmin(OSMGeoAdmin):
-    list_display = (
-        'id',
-        'modified',
-        'created',
-        'data_link',
-    )
-    readonly_fields = (
-        'modified',
-        'created',
-    )
-    inlines = (PointCloudMetaEntryInline,)
-    list_filter = MODIFIABLE_FILTERS
diff --git a/rgd/geodata/api/__init__.py b/rgd/geodata/api/__init__.py
deleted file mode 100644
index 7555231c5..000000000
--- a/rgd/geodata/api/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""Views for the API.
-
-Views routed under `/api/geodata`. The primary `Content-Type` recieved and served
-by these views ought to be `application/json`.
-"""
-
-__all__ = ['download', 'get', 'post', 'search', 'tiles']
-
-from . import download, get, post, search, tiles
diff --git a/rgd/geodata/api/get.py b/rgd/geodata/api/get.py
deleted file mode 100644
index ff2655c78..000000000
--- a/rgd/geodata/api/get.py
+++ /dev/null
@@ -1,104 +0,0 @@
-from rest_framework.generics import RetrieveAPIView
-
-from rgd.geodata.permissions import check_read_perm
-
-from .. import models, serializers
-
-
-class _PermissionMixin:
-    def get_object(self):
-        obj = super().get_object()
-        check_read_perm(self.request.user, obj)
-        return obj
-
-
-class GetConvertedImageStatus(RetrieveAPIView, _PermissionMixin):
-    """Get the status of a ConvertedImageFile by PK."""
-
-    serializer_class = serializers.ConvertedImageFileSerializer
-    lookup_field = 'pk'
-    queryset = models.ConvertedImageFile.objects.all()
-
-
-class GetSubsampledImage(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.SubsampledImageSerializer
-    lookup_field = 'pk'
-    queryset = models.SubsampledImage.objects.all()
-
-
-class GetChecksumFile(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.ChecksumFileSerializer
-    lookup_field = 'pk'
-    queryset = models.ChecksumFile.objects.all()
-
-
-class GetSpatialEntry(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.SpatialEntrySerializer
-    lookup_field = 'spatial_id'
-    queryset = models.SpatialEntry.objects.all()
-
-
-class GetSpatialEntryFootprint(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.SpatialEntryFootprintSerializer
-    lookup_field = 'spatial_id'
-    queryset = models.SpatialEntry.objects.all()
-
-
-class GetImageEntry(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.ImageEntrySerializer
-    lookup_field = 'pk'
-    queryset = models.ImageEntry.objects.all()
-
-
-class GetImageSet(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.ImageSetSerializer
-    lookup_field = 'pk'
-    queryset = models.ImageSet.objects.all()
-
-
-class GetRasterMetaEntry(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.RasterMetaEntrySerializer
-    lookup_field = 'pk'
-    queryset = models.RasterMetaEntry.objects.all()
-
-
-class GetRasterMetaEntrySTAC(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.STACRasterSerializer
-    lookup_field = 'pk'
-    queryset = models.RasterMetaEntry.objects.all()
-
-
-class GetGeometryEntry(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.GeometryEntrySerializer
-    lookup_field = 'pk'
-    queryset = models.GeometryEntry.objects.all()
-
-
-class GetGeometryEntryData(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.GeometryEntryDataSerializer
-    lookup_field = 'pk'
-    queryset = models.GeometryEntry.objects.all()
-
-
-class GetFMVEntry(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.FMVEntrySerializer
-    lookup_field = 'pk'
-    queryset = models.FMVEntry.objects.all()
-
-
-class GetFMVDataEntry(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.FMVEntryDataSerializer
-    lookup_field = 'pk'
-    queryset = models.FMVEntry.objects.all()
-
-
-class GetPointCloudEntry(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.PointCloudEntrySerializer
-    lookup_field = 'pk'
-    queryset = models.PointCloudEntry.objects.all()
-
-
-class GetPointCloudEntryData(RetrieveAPIView, _PermissionMixin):
-    serializer_class = serializers.PointCloudEntryDataSerializer
-    lookup_field = 'pk'
-    queryset = models.PointCloudEntry.objects.all()
diff --git a/rgd/geodata/api/post.py b/rgd/geodata/api/post.py
deleted file mode 100644
index 035df3258..000000000
--- a/rgd/geodata/api/post.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from rest_framework.generics import CreateAPIView
-
-from .. import models, serializers
-
-
-class CreateConvertedImageFile(CreateAPIView):
-    queryset = models.ConvertedImageFile.objects.all()
-    serializer_class = serializers.ConvertedImageFileSerializer
-
-
-class CreateSubsampledImage(CreateAPIView):
-    queryset = models.SubsampledImage.objects.all()
-    serializer_class = serializers.SubsampledImageSerializer
-
-
-class CreateRasterSTAC(CreateAPIView):
-    queryset = models.RasterMetaEntry.objects.all()
-    serializer_class = serializers.STACRasterSerializer
diff --git a/rgd/geodata/api/search.py b/rgd/geodata/api/search.py
deleted file mode 100644
index 4fddfa491..000000000
--- a/rgd/geodata/api/search.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from django_filters.rest_framework import DjangoFilterBackend
-from rest_framework.generics import ListAPIView
-
-from rgd.geodata import serializers
-from rgd.geodata.filters import RasterMetaEntryFilter, SpatialEntryFilter
-from rgd.geodata.models import RasterMetaEntry, SpatialEntry
-from rgd.geodata.permissions import filter_read_perm
-
-
-class SearchSpatialEntryView(ListAPIView):
-    queryset = SpatialEntry.objects.all()
-    serializer_class = serializers.SpatialEntrySerializer
-    filter_backends = [DjangoFilterBackend]
-    filterset_class = SpatialEntryFilter
-
-    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/apps.py b/rgd/geodata/apps.py
deleted file mode 100644
index eedf3d3b8..000000000
--- a/rgd/geodata/apps.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.apps import AppConfig
-
-
-class GeodataConfig(AppConfig):
-    name = 'rgd.geodata'
-
-    def ready(self):
-        import rgd.geodata.signals  # noqa: F401
diff --git a/rgd/geodata/management/commands/_data_helper.py b/rgd/geodata/management/commands/_data_helper.py
deleted file mode 100644
index b76e91e60..000000000
--- a/rgd/geodata/management/commands/_data_helper.py
+++ /dev/null
@@ -1,273 +0,0 @@
-from datetime import datetime
-from functools import reduce
-import json
-import logging
-import os
-
-import dateutil.parser
-from django.conf import settings
-from django.contrib.gis.geos import GEOSGeometry
-from django.core.exceptions import ValidationError
-from django.core.management.base import BaseCommand
-from django.core.validators import URLValidator
-from django.db.models import Count
-from django.utils.timezone import make_aware
-from shapely.geometry import shape
-from shapely.wkb import dumps
-
-from rgd.geodata import models
-from rgd.geodata.datastore import datastore
-from rgd.geodata.models.imagery import etl
-from rgd.utility import get_or_create_no_commit
-
-logger = logging.getLogger(__name__)
-
-
-class SynchronousTasksCommand(BaseCommand):
-    def set_synchronous(self):
-        # Set celery to run all tasks synchronously
-        self._eager = getattr(settings, 'CELERY_TASK_ALWAYS_EAGER', False)
-        self._prop = getattr(settings, 'CELERY_TASK_EAGER_PROPAGATES', False)
-        settings.CELERY_TASK_ALWAYS_EAGER = True
-        settings.CELERY_TASK_EAGER_PROPAGATES = True
-
-    def reset_celery(self):
-        # Reset celery to previous settings
-        settings.CELERY_TASK_ALWAYS_EAGER = self._eager
-        settings.CELERY_TASK_EAGER_PROPAGATES = self._prop
-
-
-def make_raster_dict(
-    images,
-    name=None,
-    date=None,
-    cloud_cover=None,
-    ancillary_files=None,
-    instrumentation=None,
-):
-    if not isinstance(images[0], tuple):
-        images = [(None, im) for im in images]
-    return {
-        # images is expect to be a list of tuples
-        'images': images,
-        'name': name,
-        'acquisition_date': date,
-        'cloud_cover': cloud_cover,
-        # ancillary is expected to be list of tuples for URL files ONLY
-        'ancillary_files': ancillary_files,
-        'instrumentation': instrumentation,
-    }
-
-
-def _save_signal(entry, created):
-    if not created and entry.status == models.mixins.Status.SUCCEEDED:
-        entry.skip_signal = True
-    entry.save()
-
-
-def _get_or_create_checksum_file_url(url, name=None):
-    URLValidator()(url)  # raises `ValidationError` if not a valid URL
-    try:
-        file_entry = models.ChecksumFile.objects.get(url=url)
-        _save_signal(file_entry, False)
-        if name:
-            file_entry.name = name
-            file_entry.save(update_fields=['name'])
-    except models.ChecksumFile.DoesNotExist:
-        file_entry = models.ChecksumFile()
-        file_entry.url = url
-        file_entry.type = models.FileSourceType.URL
-        if not name:
-            # this is to prevent calling `urlopen` in the save to get the file name.
-            # this is not a great way to set the default name, but its fast
-            name = os.path.basename(url)
-        file_entry.name = name
-        _save_signal(file_entry, True)
-    return file_entry
-
-
-def _get_or_create_checksum_file_datastore(file, name=None):
-    try:
-        file_entry = models.ChecksumFile.objects.get(name=file)
-        _save_signal(file_entry, False)
-    except models.ChecksumFile.DoesNotExist:
-        path = datastore.fetch(file)
-        file_entry = models.ChecksumFile()
-        if name:
-            file_entry.name = name
-        else:
-            file_entry.name = file
-        with open(path, 'rb') as f:
-            file_entry.file.save(os.path.basename(path), f)
-        file_entry.type = models.FileSourceType.FILE_FIELD
-        _save_signal(file_entry, True)
-    return file_entry
-
-
-def _get_or_create_checksum_file(file, name=None):
-    # Check if there is already an image file with this URL or name
-    #  to avoid duplicating data
-    try:
-        file_entry = _get_or_create_checksum_file_url(file, name=name)
-    except ValidationError:
-        file_entry = _get_or_create_checksum_file_datastore(file, name=name)
-    return file_entry
-
-
-def _get_or_create_file_model(model, file, name=None):
-    # For models that point to a `ChecksumFile`
-    file_entry = _get_or_create_checksum_file(file, name=name)
-    # No commit in case we need to skip the signal
-    entry, created = get_or_create_no_commit(model, file=file_entry)
-    _save_signal(entry, created)
-    return entry
-
-
-def load_image_files(image_files):
-    ids = []
-    for name, imfile in image_files:
-        # Run `read_image_file` sequentially to ensure `ImageEntry` is generated
-        entry = _get_or_create_file_model(models.ImageFile, imfile, name=name)
-        result = entry.imageentry.pk
-        ids.append(result)
-    return ids
-
-
-def _get_or_create_image_set(image_ids):
-    # Check if an ImageSet already exists containing all of these images
-    q = models.ImageSet.objects.annotate(count=Count('images')).filter(count=len(image_ids))
-    imsets = reduce(lambda p, id: q.filter(images=id), image_ids, q).values()
-    if len(imsets) > 0:
-        # Grab first, could be N-many
-        imset = models.ImageSet.objects.get(id=imsets[0]['id'])
-    else:
-        images = models.ImageEntry.objects.filter(pk__in=image_ids).all()
-        imset = models.ImageSet()
-        imset.save()  # Have to save before adding to ManyToManyField
-        for image in images:
-            imset.images.add(image)
-        imset.save()
-    return imset
-
-
-def load_raster(pks, raster_dict, footprint=False):
-    if not isinstance(pks, (list, tuple)):
-        pks = [
-            pks,
-        ]
-    imset = _get_or_create_image_set(pks)
-    # Make raster of that image set
-    raster, created = get_or_create_no_commit(models.RasterEntry, image_set=imset)
-    _save_signal(raster, created)
-
-    # Add optional metadata
-    date = raster_dict.get('acquisition_date', None)
-    cloud_cover = raster_dict.get('cloud_cover', None)
-    name = raster_dict.get('name', None)
-    ancillary = raster_dict.get('ancillary_files', None)
-    instrumentation = raster_dict.get('instrumentation', None)
-    if date:
-        adt = dateutil.parser.isoparser().isoparse(date)
-        try:
-            raster.rastermetaentry.acquisition_date = make_aware(adt)
-        except ValueError:
-            raster.rastermetaentry.acquisition_date = adt
-        raster.rastermetaentry.save(
-            update_fields=[
-                'acquisition_date',
-            ]
-        )
-    if cloud_cover:
-        raster.rastermetaentry.cloud_cover = cloud_cover
-        raster.rastermetaentry.save(
-            update_fields=[
-                'cloud_cover',
-            ]
-        )
-    if name:
-        raster.name = name
-        raster.save(
-            update_fields=[
-                'name',
-            ]
-        )
-    if ancillary:
-        [
-            raster.ancillary_files.add(_get_or_create_checksum_file_url(af, name=path))
-            for path, af in ancillary
-        ]
-    if instrumentation:
-        raster.rastermetaentry.instrumentation = instrumentation
-        raster.rastermetaentry.save(
-            update_fields=[
-                'instrumentation',
-            ]
-        )
-    if footprint:
-        etl.populate_raster_footprint(raster.id)
-    return raster
-
-
-def load_raster_files(raster_dicts, footprint=False):
-    ids = []
-    count = len(raster_dicts)
-    for i, rf in enumerate(raster_dicts):
-        logger.info(f'Processesing raster {i+1} of {count}')
-        start_time = datetime.now()
-        imentries = load_image_files(rf.get('images'))
-        raster = load_raster(imentries, rf, footprint=footprint)
-        ids.append(raster.pk)
-        logger.info('\t Loaded raster in: {}'.format(datetime.now() - start_time))
-    return ids
-
-
-def load_shape_files(shape_files):
-    ids = []
-    for shpfile in shape_files:
-        entry = _get_or_create_file_model(models.GeometryArchive, shpfile)
-        ids.append(entry.geometryentry.pk)
-    return ids
-
-
-def load_fmv_files(fmv_files):
-    return [_get_or_create_file_model(models.FMVFile, fmv).id for fmv in fmv_files]
-
-
-def load_kwcoco_archives(archives):
-    ids = []
-    for fspec, farch in archives:
-        spec = _get_or_create_checksum_file(fspec)
-        arch = _get_or_create_checksum_file(farch)
-        ds, created = get_or_create_no_commit(
-            models.KWCOCOArchive, spec_file=spec, image_archive=arch
-        )
-        _save_signal(ds, created)
-        ids.append(ds.id)
-    return ids
-
-
-def load_point_cloud_files(pc_files):
-    ids = []
-    for f in pc_files:
-        entry = _get_or_create_file_model(models.PointCloudFile, f)
-        ids.append(entry.pk)
-    return ids
-
-
-def load_spatial_image_sets(image_sets):
-    for value in image_sets:
-        image_files, loc_file = value
-        path = datastore.fetch(loc_file)
-        # Load JSON image
-        with open(path, 'r') as f:
-            geom = shape(json.loads(f.read())['geometry'])
-        feature = GEOSGeometry(memoryview(dumps(geom)))
-        # Load image entries
-        image_ids = load_image_files(list(zip([None] * len(image_files), image_files)))
-        imset = _get_or_create_image_set(image_ids)
-        # Make an ImageSetSpatial
-        imset_spatial, _ = get_or_create_no_commit(models.ImageSetSpatial, image_set=imset)
-        imset_spatial.name = imset.name
-        imset_spatial.footprint = feature
-        imset_spatial.outline = feature.convex_hull
-        imset_spatial.save()
diff --git a/rgd/geodata/management/commands/gc_catalog.py b/rgd/geodata/management/commands/gc_catalog.py
deleted file mode 100644
index 40ac4ea4a..000000000
--- a/rgd/geodata/management/commands/gc_catalog.py
+++ /dev/null
@@ -1,216 +0,0 @@
-from datetime import datetime
-import logging
-import multiprocessing
-import os
-import tempfile
-import xml.etree.ElementTree as ElementTree
-
-import pandas as pd
-
-from rgd.geodata import datastore
-from rgd.utility import safe_urlopen
-
-from . import _data_helper as helper
-
-logger = logging.getLogger(__name__)
-
-SUCCESS_MSG = 'Finished loading all {} data.'
-
-
-def _fetch_landsat_index_table():
-    path = datastore.datastore.fetch('landsat.csv')
-    return pd.read_csv(path)
-
-
-def _fetch_sentinel_index_table():
-    path = datastore.datastore.fetch('sentinel.csv')
-    df = pd.read_csv(path)
-    # Handle issue where tiles for a given date were processed multiple times
-    #   to avoid ingesting duplicate data.
-    clean = df.sort_values('PRODUCT_ID').drop_duplicates(
-        ['MGRS_TILE', 'SENSING_TIME'], keep='first'
-    )
-    return clean.sort_index()
-
-
-def _format_gs_base_url(base_url):
-    return 'http://storage.googleapis.com/' + base_url.replace('gs://', '')
-
-
-def _get_landsat_urls(base_url, name, sensor='TM'):
-    if sensor == 'OLI_TIRS':  # Landsat 8
-        possible_bands = [
-            'B1.TIF',
-            'B2.TIF',
-            'B3.TIF',
-            'B4.TIF',
-            'B5.TIF',
-            'B6.TIF',
-            'B7.TIF',
-            'B8.TIF',
-            'B9.TIF',
-            'B10.TIF',
-            'B11.TIF',
-            'BQA.TIF',
-        ]
-        possible_anc = [
-            'ANG.txt',
-            'MTL.txt',
-        ]
-    elif sensor == 'ETM':  # Landsat 7
-        possible_bands = [
-            'B1.TIF',
-            'B2.TIF',
-            'B3.TIF',
-            'B4.TIF',
-            'B5.TIF',
-            # 'B6.TIF',
-            'B6_VCID_1.TIF',
-            'B6_VCID_2.TIF',
-            'B7.TIF',
-            'B8.TIF',
-            # 'B9.TIF',
-            'BQA.TIF',
-        ]
-        possible_anc = [
-            'ANG.txt',
-            'MTL.txt',
-        ]
-    else:
-        raise ValueError(f'Unknown sensor: {sensor}')
-    base_url = _format_gs_base_url(base_url)
-    # Return tuples of (path, URL) : path is what is saved to model name field
-    # this path should encompass the relative path of the files for the raster
-    urls = [(None, base_url + '/' + name + '_' + band) for band in possible_bands]
-    ancillary = [(None, base_url + '/' + name + '_' + ext) for ext in possible_anc]
-    return urls, ancillary
-
-
-def _load_landsat(row):
-    urls, ancillary = _get_landsat_urls(row['BASE_URL'], row['PRODUCT_ID'], row['SENSOR_ID'])
-    rd = helper.make_raster_dict(
-        urls,
-        date=row['SENSING_TIME'],
-        name=row['PRODUCT_ID'],
-        cloud_cover=row['CLOUD_COVER'],
-        ancillary_files=ancillary,
-        instrumentation=row['SENSOR_ID'],
-    )
-    return rd
-
-
-def _get_sentinel_urls(base_url):
-    base_url = _format_gs_base_url(base_url)
-    manifest_url = base_url + '/manifest.safe'
-    with safe_urlopen(manifest_url) as remote, tempfile.TemporaryDirectory() as tmpdir:
-        manifest_path = os.path.join(tmpdir, 'manifest.safe')
-        with open(manifest_path, 'wb') as f:
-            f.write(remote.read())
-        # Now check to see if `MTD_TL.xml` is in the manifest ONCE - if more or less, skip it.
-        # Ask Matthew Bernstein for insight
-        with open(manifest_path, 'r') as f:
-            if f.read().count('MTD_TL.xml') != 1:
-                raise ValueError('This entry is bad.')
-        tree = ElementTree.parse(manifest_path)
-
-    urls = []
-    ancillary = []
-    # Return tuples of (path, URL) : path is what is saved to model name field
-    # this path should encompass the relative path of the files for the raster
-    root = tree.getroot()
-    meta = root.find('dataObjectSection')
-    for c in meta:
-        f = c.find('byteStream').find('fileLocation')
-        href = f.attrib['href'][2::]  # to remove `./`
-        di = href.split('/')[0]
-        if di in [
-            'GRANULE',
-        ]:
-            url = base_url + '/' + href
-            if url[-4:] == '.jp2':
-                urls.append((href, url))
-            else:
-                ancillary.append((href, url))
-
-    return urls, ancillary
-
-
-def _load_sentinel(row):
-    urls, ancillary = _get_sentinel_urls(row['BASE_URL'])
-    rd = helper.make_raster_dict(
-        urls,
-        date=row['SENSING_TIME'],
-        name=row['PRODUCT_ID'],
-        cloud_cover=row['CLOUD_COVER'],
-        ancillary_files=ancillary,
-        instrumentation=row['PRODUCT_ID'].split('_')[0],
-    )
-    return rd
-
-
-class GCLoader:
-    def __init__(self, satellite, footprint=False):
-        if satellite not in ['landsat', 'sentinel']:
-            raise ValueError(f'Unknown satellite {satellite}.')
-        self.satellite = satellite
-
-        if self.satellite == 'landsat':
-            self.index = _fetch_landsat_index_table()
-        elif self.satellite == 'sentinel':
-            self.index = _fetch_sentinel_index_table()
-
-        self.footprint = footprint
-
-    def _load_raster(self, index):
-        row = self.index.iloc[index]
-        try:
-            if self.satellite == 'landsat':
-                rd = _load_landsat(row)
-            elif self.satellite == 'sentinel':
-                rd = _load_sentinel(row)
-        except ValueError:
-            return None
-        imentries = helper.load_image_files(rd.get('images'))
-        helper.load_raster(imentries, rd, footprint=self.footprint)
-
-    def load_rasters(self, count=None):
-        if not count:
-            count = len(self.index)
-        logger.info(f'Processing {count} rasters...')
-        pool = multiprocessing.Pool(multiprocessing.cpu_count())
-        pool.map(self._load_raster, range(count))
-
-
-class Command(helper.SynchronousTasksCommand):
-    help = 'Populate database with demo landsat data from S3.'
-
-    def add_arguments(self, parser):
-        parser.add_argument('satellite', type=str, help='landsat or sentinel')
-        parser.add_argument(
-            '-c', '--count', type=int, help='Indicates the number scenes to fetch.', default=None
-        )
-        parser.add_argument(
-            '-f',
-            '--footprint',
-            action='store_true',
-            default=False,
-            help='Compute the valid data footprints',
-        )
-
-    def handle(self, *args, **options):
-        self.set_synchronous()
-
-        count = options.get('count', None)
-        satellite = options.get('satellite')
-        footprint = options.get('footprint')
-
-        start_time = datetime.now()
-
-        loader = GCLoader(satellite, footprint=footprint)
-        loader.load_rasters(count)
-
-        self.stdout.write(
-            self.style.SUCCESS('--- Completed in: {} ---'.format(datetime.now() - start_time))
-        )
-        self.stdout.write(self.style.SUCCESS(SUCCESS_MSG.format(satellite)))
-        self.reset_celery()
diff --git a/rgd/geodata/management/commands/s3_stac.py b/rgd/geodata/management/commands/s3_stac.py
deleted file mode 100644
index 36fdda791..000000000
--- a/rgd/geodata/management/commands/s3_stac.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from contextlib import contextmanager
-import json
-import multiprocessing
-import os
-import re
-import tempfile
-from typing import Generator, Optional
-
-import boto3
-from django.conf import settings
-import djclick as click
-
-from rgd.geodata.serializers import STACRasterSerializer
-
-
-def _iter_matching_objects(
-    s3_client,
-    bucket: str,
-    prefix: str,
-    include_regex: str,
-) -> Generator[dict, None, None]:
-    paginator = s3_client.get_paginator('list_objects_v2')
-    page_iter = paginator.paginate(Bucket=bucket, Prefix=prefix, RequestPayer='requester')
-    include_pattern = re.compile(include_regex)
-
-    for page in page_iter:
-        for obj in page['Contents']:
-            if include_pattern.match(obj['Key']):
-                yield obj
-
-
-@contextmanager
-def download_object(s3_client, bucket, obj):
-    with tempfile.TemporaryDirectory() as tmpdir:
-        path = os.path.join(tmpdir, 'stac.json')
-        with open(path, 'wb') as f:
-            s3_client.download_fileobj(bucket, obj['Key'], f)
-        with open(path, 'r') as f:
-            yield json.loads(f.read())
-
-
-class STACLoader:
-    def __init__(self, boto3_params, bucket: str):
-        self.boto3_params = boto3_params
-        self.bucket = bucket
-
-    @property
-    def client(self):
-        session = boto3.Session(**self.boto3_params)
-        return session.client('s3')
-
-    def load_object(self, obj: dict) -> None:
-        with download_object(self.client, self.bucket, obj) as data:
-            STACRasterSerializer().create(data)
-
-
-@click.command()
-@click.argument('bucket')
-@click.option('--include-regex', default=r'^.*\.json')
-@click.option('--prefix', default='')
-@click.option('--region', default='us-west-2')
-@click.option('--access-key-id')
-@click.option('--secret-access-key')
-def ingest_s3(
-    bucket: str,
-    include_regex: str,
-    prefix: str,
-    region: str,
-    access_key_id: Optional[str],
-    secret_access_key: Optional[str],
-) -> None:
-    boto3_params = {
-        'aws_access_key_id': access_key_id,
-        'aws_secret_access_key': secret_access_key,
-        'region_name': region,
-    }
-
-    session = boto3.Session(**boto3_params)
-    s3_client = session.client('s3')
-
-    _eager = getattr(settings, 'CELERY_TASK_ALWAYS_EAGER', False)
-    _prop = getattr(settings, 'CELERY_TASK_EAGER_PROPAGATES', False)
-    settings.CELERY_TASK_ALWAYS_EAGER = True
-    settings.CELERY_TASK_EAGER_PROPAGATES = True
-
-    loader = STACLoader(boto3_params, bucket)
-    pool = multiprocessing.Pool(multiprocessing.cpu_count())
-    pool.map(
-        loader.load_object,
-        _iter_matching_objects(s3_client, bucket, prefix, include_regex),
-    )
-
-    # Reset celery to previous settings
-    settings.CELERY_TASK_ALWAYS_EAGER = _eager
-    settings.CELERY_TASK_EAGER_PROPAGATES = _prop
diff --git a/rgd/geodata/migrations/0001_initial.py b/rgd/geodata/migrations/0001_initial.py
deleted file mode 100644
index 58d513165..000000000
--- a/rgd/geodata/migrations/0001_initial.py
+++ /dev/null
@@ -1,786 +0,0 @@
-# Generated by Django 3.1.5 on 2021-01-28 16:32
-
-import django.contrib.gis.db.models.fields
-import django.contrib.postgres.fields
-from django.db import migrations, models
-import django.db.models.deletion
-import s3_file_field.fields
-
-import rgd.geodata.models.mixins
-
-
-class Migration(migrations.Migration):
-
-    initial = True
-
-    dependencies = []
-
-    operations = [
-        migrations.CreateModel(
-            name='ModifiableEntry',
-            fields=[
-                (
-                    'id',
-                    models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    ),
-                ),
-                (
-                    'modified',
-                    models.DateTimeField(
-                        editable=False, help_text='The last time this entry was saved.'
-                    ),
-                ),
-                (
-                    'created',
-                    models.DateTimeField(
-                        editable=False, help_text='When this was added to the database.'
-                    ),
-                ),
-            ],
-        ),
-        migrations.CreateModel(
-            name='Segmentation',
-            fields=[
-                (
-                    'id',
-                    models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    ),
-                ),
-                (
-                    'outline',
-                    django.contrib.gis.db.models.fields.PolygonField(
-                        help_text='The bounding box', null=True, srid=0
-                    ),
-                ),
-            ],
-        ),
-        migrations.CreateModel(
-            name='SpatialEntry',
-            fields=[
-                ('spatial_id', models.AutoField(primary_key=True, serialize=False)),
-                ('acquisition_date', models.DateTimeField(blank=True, default=None, null=True)),
-                ('footprint', django.contrib.gis.db.models.fields.PolygonField(srid=4326)),
-                ('outline', django.contrib.gis.db.models.fields.PolygonField(srid=4326)),
-            ],
-        ),
-        migrations.CreateModel(
-            name='Annotation',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('caption', models.CharField(blank=True, max_length=100, null=True)),
-                ('label', models.CharField(blank=True, max_length=100, null=True)),
-                ('annotator', models.CharField(blank=True, max_length=100, null=True)),
-                ('notes', models.TextField(blank=True, null=True)),
-                (
-                    'keypoints',
-                    django.contrib.gis.db.models.fields.MultiPointField(null=True, srid=0),
-                ),
-                ('line', django.contrib.gis.db.models.fields.LineStringField(null=True, srid=0)),
-            ],
-            bases=('geodata.modifiableentry',),
-        ),
-        migrations.CreateModel(
-            name='BandMetaEntry',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('band_number', models.IntegerField()),
-                (
-                    'description',
-                    models.TextField(
-                        blank=True,
-                        help_text='Automatically retreived from raster but can be overwritten.',
-                        null=True,
-                    ),
-                ),
-                ('dtype', models.CharField(max_length=10)),
-                ('max', models.FloatField()),
-                ('min', models.FloatField()),
-                ('mean', models.FloatField()),
-                ('std', models.FloatField()),
-                ('nodata_value', models.FloatField(null=True)),
-                ('interpretation', models.TextField()),
-            ],
-            bases=('geodata.modifiableentry',),
-        ),
-        migrations.CreateModel(
-            name='ChecksumFile',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('name', models.CharField(blank=True, max_length=100)),
-                ('checksum', models.CharField(max_length=128)),
-                ('validate_checksum', models.BooleanField(default=False)),
-                ('last_validation', models.BooleanField(default=True)),
-                ('type', models.IntegerField(choices=[(1, 'FileField'), (2, 'URL')], default=1)),
-                ('file', s3_file_field.fields.S3FileField(blank=True, null=True)),
-                ('url', models.TextField(blank=True, null=True)),
-                ('failure_reason', models.TextField(null=True)),
-                (
-                    'status',
-                    models.CharField(
-                        choices=[
-                            ('created', 'Created but not queued'),
-                            ('queued', 'Queued for processing'),
-                            ('running', 'Processing'),
-                            ('failed', 'Failed'),
-                            ('success', 'Succeeded'),
-                        ],
-                        default='created',
-                        max_length=20,
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', rgd.geodata.models.mixins.TaskEventMixin),
-        ),
-        migrations.CreateModel(
-            name='GeometryArchive',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('failure_reason', models.TextField(null=True)),
-                (
-                    'status',
-                    models.CharField(
-                        choices=[
-                            ('created', 'Created but not queued'),
-                            ('queued', 'Queued for processing'),
-                            ('running', 'Processing'),
-                            ('failed', 'Failed'),
-                            ('success', 'Succeeded'),
-                        ],
-                        default='created',
-                        max_length=20,
-                    ),
-                ),
-                (
-                    'file',
-                    models.ForeignKey(
-                        on_delete=django.db.models.deletion.CASCADE, to='geodata.checksumfile'
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', rgd.geodata.models.mixins.TaskEventMixin),
-        ),
-        migrations.CreateModel(
-            name='ImageEntry',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('name', models.CharField(blank=True, max_length=100)),
-                ('description', models.TextField(blank=True, null=True)),
-                (
-                    'instrumentation',
-                    models.CharField(
-                        blank=True,
-                        help_text='The instrumentation used to acquire these data.',
-                        max_length=100,
-                        null=True,
-                    ),
-                ),
-                ('driver', models.CharField(max_length=100)),
-                ('height', models.PositiveIntegerField()),
-                ('width', models.PositiveIntegerField()),
-                ('number_of_bands', models.PositiveIntegerField()),
-                ('metadata', models.JSONField(null=True)),
-            ],
-            bases=('geodata.modifiableentry',),
-        ),
-        migrations.CreateModel(
-            name='ImageSet',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('name', models.CharField(blank=True, max_length=100)),
-                ('description', models.TextField(blank=True, null=True)),
-                ('images', models.ManyToManyField(to='geodata.ImageEntry')),
-            ],
-            bases=('geodata.modifiableentry',),
-        ),
-        migrations.CreateModel(
-            name='PolygonSegmentation',
-            fields=[
-                (
-                    'segmentation_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.segmentation',
-                    ),
-                ),
-                (
-                    'feature',
-                    django.contrib.gis.db.models.fields.MultiPolygonField(null=True, srid=0),
-                ),
-            ],
-            bases=('geodata.segmentation',),
-        ),
-        migrations.CreateModel(
-            name='RasterEntry',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('name', models.CharField(blank=True, max_length=100)),
-                ('description', models.TextField(blank=True, null=True)),
-                ('failure_reason', models.TextField(null=True)),
-                (
-                    'status',
-                    models.CharField(
-                        choices=[
-                            ('created', 'Created but not queued'),
-                            ('queued', 'Queued for processing'),
-                            ('running', 'Processing'),
-                            ('failed', 'Failed'),
-                            ('success', 'Succeeded'),
-                        ],
-                        default='created',
-                        max_length=20,
-                    ),
-                ),
-                (
-                    'image_set',
-                    models.OneToOneField(
-                        on_delete=django.db.models.deletion.CASCADE, to='geodata.imageset'
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', rgd.geodata.models.mixins.TaskEventMixin),
-        ),
-        migrations.CreateModel(
-            name='RLESegmentation',
-            fields=[
-                (
-                    'segmentation_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.segmentation',
-                    ),
-                ),
-                ('blob', models.BinaryField()),
-                ('height', models.PositiveIntegerField()),
-                ('width', models.PositiveIntegerField()),
-            ],
-            bases=('geodata.segmentation',),
-        ),
-        migrations.CreateModel(
-            name='Thumbnail',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('base_thumbnail', models.ImageField(upload_to='thumbnails')),
-                (
-                    'image_entry',
-                    models.OneToOneField(
-                        on_delete=django.db.models.deletion.CASCADE, to='geodata.imageentry'
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry',),
-        ),
-        migrations.CreateModel(
-            name='SubsampledImage',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                (
-                    'sample_type',
-                    models.CharField(
-                        choices=[
-                            ('pixel box', 'Pixel bounding box'),
-                            ('geographic box', 'Geographic bounding box'),
-                            ('geojson', 'GeoJSON feature'),
-                            ('annotation', 'Annotation entry'),
-                        ],
-                        default='pixel box',
-                        max_length=20,
-                    ),
-                ),
-                ('sample_parameters', models.JSONField()),
-                ('failure_reason', models.TextField(null=True)),
-                (
-                    'status',
-                    models.CharField(
-                        choices=[
-                            ('created', 'Created but not queued'),
-                            ('queued', 'Queued for processing'),
-                            ('running', 'Processing'),
-                            ('failed', 'Failed'),
-                            ('success', 'Succeeded'),
-                        ],
-                        default='created',
-                        max_length=20,
-                    ),
-                ),
-                (
-                    'data',
-                    models.OneToOneField(
-                        null=True,
-                        on_delete=django.db.models.deletion.SET_NULL,
-                        to='geodata.checksumfile',
-                    ),
-                ),
-                (
-                    'source_image',
-                    models.ForeignKey(
-                        on_delete=django.db.models.deletion.CASCADE, to='geodata.imageentry'
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', rgd.geodata.models.mixins.TaskEventMixin),
-        ),
-        migrations.AddField(
-            model_name='segmentation',
-            name='annotation',
-            field=models.OneToOneField(
-                on_delete=django.db.models.deletion.CASCADE, to='geodata.annotation'
-            ),
-        ),
-        migrations.CreateModel(
-            name='RasterMetaEntry',
-            fields=[
-                (
-                    'spatialentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        to='geodata.spatialentry',
-                    ),
-                ),
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('crs', models.TextField(help_text='PROJ string')),
-                (
-                    'origin',
-                    django.contrib.postgres.fields.ArrayField(
-                        base_field=models.FloatField(), size=2
-                    ),
-                ),
-                (
-                    'extent',
-                    django.contrib.postgres.fields.ArrayField(
-                        base_field=models.FloatField(), size=4
-                    ),
-                ),
-                (
-                    'resolution',
-                    django.contrib.postgres.fields.ArrayField(
-                        base_field=models.FloatField(), size=2
-                    ),
-                ),
-                (
-                    'transform',
-                    django.contrib.postgres.fields.ArrayField(
-                        base_field=models.FloatField(), size=6
-                    ),
-                ),
-                (
-                    'parent_raster',
-                    models.OneToOneField(
-                        on_delete=django.db.models.deletion.CASCADE, to='geodata.rasterentry'
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', 'geodata.spatialentry'),
-        ),
-        migrations.CreateModel(
-            name='KWCOCOArchive',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('name', models.CharField(blank=True, max_length=100)),
-                ('failure_reason', models.TextField(null=True)),
-                (
-                    'status',
-                    models.CharField(
-                        choices=[
-                            ('created', 'Created but not queued'),
-                            ('queued', 'Queued for processing'),
-                            ('running', 'Processing'),
-                            ('failed', 'Failed'),
-                            ('success', 'Succeeded'),
-                        ],
-                        default='created',
-                        max_length=20,
-                    ),
-                ),
-                (
-                    'image_archive',
-                    models.OneToOneField(
-                        help_text='An archive (.tar or .zip) of the images referenced by the spec file (optional).',
-                        null=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        related_name='kwcoco_image_archive',
-                        to='geodata.checksumfile',
-                    ),
-                ),
-                (
-                    'image_set',
-                    models.OneToOneField(
-                        null=True,
-                        on_delete=django.db.models.deletion.SET_NULL,
-                        to='geodata.imageset',
-                    ),
-                ),
-                (
-                    'spec_file',
-                    models.OneToOneField(
-                        help_text='The JSON spec file.',
-                        on_delete=django.db.models.deletion.CASCADE,
-                        related_name='kwcoco_spec_file',
-                        to='geodata.checksumfile',
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', rgd.geodata.models.mixins.TaskEventMixin),
-        ),
-        migrations.CreateModel(
-            name='ImageFile',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('failure_reason', models.TextField(null=True)),
-                (
-                    'status',
-                    models.CharField(
-                        choices=[
-                            ('created', 'Created but not queued'),
-                            ('queued', 'Queued for processing'),
-                            ('running', 'Processing'),
-                            ('failed', 'Failed'),
-                            ('success', 'Succeeded'),
-                        ],
-                        default='created',
-                        max_length=20,
-                    ),
-                ),
-                (
-                    'file',
-                    models.ForeignKey(
-                        on_delete=django.db.models.deletion.CASCADE, to='geodata.checksumfile'
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', rgd.geodata.models.mixins.TaskEventMixin),
-        ),
-        migrations.AddField(
-            model_name='imageentry',
-            name='image_file',
-            field=models.OneToOneField(
-                on_delete=django.db.models.deletion.CASCADE, to='geodata.imagefile'
-            ),
-        ),
-        migrations.CreateModel(
-            name='GeometryEntry',
-            fields=[
-                (
-                    'spatialentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        to='geodata.spatialentry',
-                    ),
-                ),
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('name', models.CharField(blank=True, max_length=100)),
-                ('description', models.TextField(blank=True, null=True)),
-                ('data', django.contrib.gis.db.models.fields.GeometryCollectionField(srid=4326)),
-                (
-                    'geometry_archive',
-                    models.OneToOneField(
-                        null=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to='geodata.geometryarchive',
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', 'geodata.spatialentry'),
-        ),
-        migrations.CreateModel(
-            name='FMVFile',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('failure_reason', models.TextField(null=True)),
-                (
-                    'status',
-                    models.CharField(
-                        choices=[
-                            ('created', 'Created but not queued'),
-                            ('queued', 'Queued for processing'),
-                            ('running', 'Processing'),
-                            ('failed', 'Failed'),
-                            ('success', 'Succeeded'),
-                        ],
-                        default='created',
-                        max_length=20,
-                    ),
-                ),
-                ('klv_file', s3_file_field.fields.S3FileField(null=True)),
-                ('web_video_file', s3_file_field.fields.S3FileField(null=True)),
-                ('frame_rate', models.FloatField(null=True)),
-                (
-                    'file',
-                    models.ForeignKey(
-                        on_delete=django.db.models.deletion.CASCADE, to='geodata.checksumfile'
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', rgd.geodata.models.mixins.TaskEventMixin),
-        ),
-        migrations.CreateModel(
-            name='FMVEntry',
-            fields=[
-                (
-                    'spatialentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        to='geodata.spatialentry',
-                    ),
-                ),
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('name', models.CharField(max_length=100)),
-                ('description', models.TextField(blank=True, null=True)),
-                ('ground_frames', django.contrib.gis.db.models.fields.MultiPolygonField(srid=4326)),
-                ('ground_union', django.contrib.gis.db.models.fields.MultiPolygonField(srid=4326)),
-                ('flight_path', django.contrib.gis.db.models.fields.MultiPointField(srid=4326)),
-                ('frame_numbers', models.BinaryField()),
-                (
-                    'fmv_file',
-                    models.OneToOneField(
-                        on_delete=django.db.models.deletion.CASCADE, to='geodata.fmvfile'
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', 'geodata.spatialentry'),
-        ),
-        migrations.CreateModel(
-            name='ConvertedImageFile',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('failure_reason', models.TextField(null=True)),
-                (
-                    'status',
-                    models.CharField(
-                        choices=[
-                            ('created', 'Created but not queued'),
-                            ('queued', 'Queued for processing'),
-                            ('running', 'Processing'),
-                            ('failed', 'Failed'),
-                            ('success', 'Succeeded'),
-                        ],
-                        default='created',
-                        max_length=20,
-                    ),
-                ),
-                (
-                    'converted_file',
-                    models.OneToOneField(
-                        null=True,
-                        on_delete=django.db.models.deletion.SET_NULL,
-                        to='geodata.checksumfile',
-                    ),
-                ),
-                (
-                    'source_image',
-                    models.OneToOneField(
-                        on_delete=django.db.models.deletion.CASCADE, to='geodata.imageentry'
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', rgd.geodata.models.mixins.TaskEventMixin),
-        ),
-        migrations.AddConstraint(
-            model_name='checksumfile',
-            constraint=models.CheckConstraint(
-                check=models.Q(
-                    models.Q(
-                        ('file__regex', '.+'),
-                        ('type', 1),
-                        models.Q(('url__in', ['', None]), ('url__isnull', True), _connector='OR'),
-                    ),
-                    models.Q(
-                        ('type', 2),
-                        models.Q(('url__isnull', False), ('url__regex', '.+')),
-                        models.Q(('file__in', ['', None]), ('file__isnull', True), _connector='OR'),
-                    ),
-                    _connector='OR',
-                ),
-                name='geodata_checksumfile_file_source_value_matches_type',
-            ),
-        ),
-        migrations.AddField(
-            model_name='bandmetaentry',
-            name='parent_image',
-            field=models.ForeignKey(
-                on_delete=django.db.models.deletion.CASCADE, to='geodata.imageentry'
-            ),
-        ),
-        migrations.AddField(
-            model_name='annotation',
-            name='image',
-            field=models.ForeignKey(
-                on_delete=django.db.models.deletion.CASCADE, to='geodata.imageentry'
-            ),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0002_auto_20210223_1819.py b/rgd/geodata/migrations/0002_auto_20210223_1819.py
deleted file mode 100644
index 13afbf273..000000000
--- a/rgd/geodata/migrations/0002_auto_20210223_1819.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# Generated by Django 3.2a1 on 2021-02-23 18:19
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0001_initial'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='imageentry',
-            name='failure_reason',
-            field=models.TextField(null=True),
-        ),
-        migrations.AddField(
-            model_name='imageentry',
-            name='status',
-            field=models.CharField(
-                choices=[
-                    ('created', 'Created but not queued'),
-                    ('queued', 'Queued for processing'),
-                    ('running', 'Processing'),
-                    ('failed', 'Failed'),
-                    ('success', 'Succeeded'),
-                ],
-                default='created',
-                max_length=20,
-            ),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0003_auto_20210301_1639.py b/rgd/geodata/migrations/0003_auto_20210301_1639.py
deleted file mode 100644
index 464607fae..000000000
--- a/rgd/geodata/migrations/0003_auto_20210301_1639.py
+++ /dev/null
@@ -1,94 +0,0 @@
-# Generated by Django 3.2a1 on 2021-03-01 16:39
-
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-        ('geodata', '0002_auto_20210223_1819'),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='Collection',
-            fields=[
-                (
-                    'id',
-                    models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    ),
-                ),
-                ('name', models.CharField(max_length=127)),
-            ],
-            options={
-                'default_related_name': 'collections',
-            },
-        ),
-        migrations.CreateModel(
-            name='CollectionMembership',
-            fields=[
-                (
-                    'id',
-                    models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    ),
-                ),
-                (
-                    'role',
-                    models.SmallIntegerField(
-                        choices=[(1, 'Reader'), (2, 'Owner')],
-                        default=1,
-                        help_text='A "reader" can view assets in this collection. An "owner" can additionally add/remove other users, set their permissions, delete the collection, and add/remove other files.',
-                    ),
-                ),
-                (
-                    'collection',
-                    models.ForeignKey(
-                        on_delete=django.db.models.deletion.CASCADE,
-                        related_name='collection_memberships',
-                        to='geodata.collection',
-                    ),
-                ),
-                (
-                    'user',
-                    models.ForeignKey(
-                        on_delete=django.db.models.deletion.CASCADE,
-                        related_name='collection_memberships',
-                        to=settings.AUTH_USER_MODEL,
-                    ),
-                ),
-            ],
-            options={
-                'default_related_name': 'collection_memberships',
-            },
-        ),
-        migrations.AddField(
-            model_name='collection',
-            name='users',
-            field=models.ManyToManyField(
-                related_name='collections',
-                through='geodata.CollectionMembership',
-                to=settings.AUTH_USER_MODEL,
-            ),
-        ),
-        migrations.AddField(
-            model_name='checksumfile',
-            name='collection',
-            field=models.ForeignKey(
-                blank=True,
-                null=True,
-                on_delete=django.db.models.deletion.SET_NULL,
-                related_name='checksumfiles',
-                related_query_name='checksumfiles',
-                to='geodata.collection',
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name='collectionmembership',
-            constraint=models.UniqueConstraint(fields=('collection', 'user'), name='unique_user'),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0004_auto_20210302_2017.py b/rgd/geodata/migrations/0004_auto_20210302_2017.py
deleted file mode 100644
index 89f8443ae..000000000
--- a/rgd/geodata/migrations/0004_auto_20210302_2017.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# Generated by Django 3.2a1 on 2021-03-02 20:17
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0003_auto_20210301_1639'),
-    ]
-
-    operations = [
-        migrations.RemoveField(
-            model_name='imageentry',
-            name='failure_reason',
-        ),
-        migrations.RemoveField(
-            model_name='imageentry',
-            name='metadata',
-        ),
-        migrations.RemoveField(
-            model_name='imageentry',
-            name='status',
-        ),
-        migrations.DeleteModel(
-            name='Thumbnail',
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0005_auto_20210308_1909.py b/rgd/geodata/migrations/0005_auto_20210308_1909.py
deleted file mode 100644
index 43382e40b..000000000
--- a/rgd/geodata/migrations/0005_auto_20210308_1909.py
+++ /dev/null
@@ -1,48 +0,0 @@
-# Generated by Django 3.2a1 on 2021-03-08 19:09
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0004_auto_20210302_2017'),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name='checksumfile',
-            name='name',
-            field=models.CharField(blank=True, max_length=1000),
-        ),
-        migrations.AlterField(
-            model_name='fmventry',
-            name='name',
-            field=models.CharField(max_length=1000),
-        ),
-        migrations.AlterField(
-            model_name='geometryentry',
-            name='name',
-            field=models.CharField(blank=True, max_length=1000),
-        ),
-        migrations.AlterField(
-            model_name='imageentry',
-            name='name',
-            field=models.CharField(blank=True, max_length=1000),
-        ),
-        migrations.AlterField(
-            model_name='imageset',
-            name='name',
-            field=models.CharField(blank=True, max_length=1000),
-        ),
-        migrations.AlterField(
-            model_name='kwcocoarchive',
-            name='name',
-            field=models.CharField(blank=True, max_length=1000),
-        ),
-        migrations.AlterField(
-            model_name='rasterentry',
-            name='name',
-            field=models.CharField(blank=True, max_length=1000),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0006_auto_20210309_0607.py b/rgd/geodata/migrations/0006_auto_20210309_0607.py
deleted file mode 100644
index ef89329af..000000000
--- a/rgd/geodata/migrations/0006_auto_20210309_0607.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# Generated by Django 3.2a1 on 2021-03-09 06:07
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0005_auto_20210308_1909'),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name='bandmetaentry',
-            name='max',
-            field=models.FloatField(null=True),
-        ),
-        migrations.AlterField(
-            model_name='bandmetaentry',
-            name='mean',
-            field=models.FloatField(null=True),
-        ),
-        migrations.AlterField(
-            model_name='bandmetaentry',
-            name='min',
-            field=models.FloatField(null=True),
-        ),
-        migrations.AlterField(
-            model_name='bandmetaentry',
-            name='std',
-            field=models.FloatField(null=True),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0007_rastermetaentry_cloud_cover.py b/rgd/geodata/migrations/0007_rastermetaentry_cloud_cover.py
deleted file mode 100644
index 0d0c59a8a..000000000
--- a/rgd/geodata/migrations/0007_rastermetaentry_cloud_cover.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# Generated by Django 3.2a1 on 2021-03-12 17:46
-
-import django.core.validators
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0006_auto_20210309_0607'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='rastermetaentry',
-            name='cloud_cover',
-            field=models.FloatField(
-                null=True,
-                validators=[
-                    django.core.validators.MinValueValidator(0),
-                    django.core.validators.MaxValueValidator(100),
-                ],
-            ),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0008_rasterentry_ancillary_files.py b/rgd/geodata/migrations/0008_rasterentry_ancillary_files.py
deleted file mode 100644
index 5fd7c26d8..000000000
--- a/rgd/geodata/migrations/0008_rasterentry_ancillary_files.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# Generated by Django 3.2a1 on 2021-03-26 14:45
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0007_rastermetaentry_cloud_cover'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='rasterentry',
-            name='ancillary_files',
-            field=models.ManyToManyField(to='geodata.ChecksumFile'),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0009_auto_20210414_1639.py b/rgd/geodata/migrations/0009_auto_20210414_1639.py
deleted file mode 100644
index cb480040c..000000000
--- a/rgd/geodata/migrations/0009_auto_20210414_1639.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# Generated by Django 3.2 on 2021-04-14 16:39
-
-from django.db import migrations, models
-
-
-def move_instrumentation_for_rasters(apps, schema_editor):
-    RasterMetaEntry = apps.get_model('geodata', 'RasterMetaEntry')  # noqa
-    for r in RasterMetaEntry.objects.all():
-        r.instrumentation = r.parent_raster.image_set.images.first().instrumentation
-        r.save(update_fields=['instrumentation'])
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0008_rasterentry_ancillary_files'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='spatialentry',
-            name='instrumentation',
-            field=models.CharField(
-                blank=True,
-                help_text='The instrumentation used to acquire these data.',
-                max_length=100,
-                null=True,
-            ),
-        ),
-        migrations.RunPython(move_instrumentation_for_rasters),
-        migrations.RemoveField(
-            model_name='imageentry',
-            name='instrumentation',
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0010_auto_20210423_0422.py b/rgd/geodata/migrations/0010_auto_20210423_0422.py
deleted file mode 100644
index 16fee3c22..000000000
--- a/rgd/geodata/migrations/0010_auto_20210423_0422.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# Generated by Django 3.2 on 2021-04-23 04:22
-
-from django.db import migrations
-import s3_file_field.fields
-
-import rgd.utility
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0009_auto_20210414_1639'),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name='checksumfile',
-            name='file',
-            field=s3_file_field.fields.S3FileField(
-                blank=True, null=True, upload_to=rgd.utility.uuid_prefix_filename
-            ),
-        ),
-        migrations.AlterField(
-            model_name='fmvfile',
-            name='klv_file',
-            field=s3_file_field.fields.S3FileField(
-                null=True, upload_to=rgd.utility.uuid_prefix_filename
-            ),
-        ),
-        migrations.AlterField(
-            model_name='fmvfile',
-            name='web_video_file',
-            field=s3_file_field.fields.S3FileField(
-                null=True, upload_to=rgd.utility.uuid_prefix_filename
-            ),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0011_auto_20210505_1552.py b/rgd/geodata/migrations/0011_auto_20210505_1552.py
deleted file mode 100644
index ce09bc79a..000000000
--- a/rgd/geodata/migrations/0011_auto_20210505_1552.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# Generated by Django 3.2 on 2021-05-05 15:52
-
-import django.core.validators
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0010_auto_20210423_0422'),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name='rasterentry',
-            name='ancillary_files',
-            field=models.ManyToManyField(blank=True, to='geodata.ChecksumFile'),
-        ),
-        migrations.AlterField(
-            model_name='rastermetaentry',
-            name='cloud_cover',
-            field=models.FloatField(
-                blank=True,
-                null=True,
-                validators=[
-                    django.core.validators.MinValueValidator(0),
-                    django.core.validators.MaxValueValidator(100),
-                ],
-            ),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0012_whitelistedemail.py b/rgd/geodata/migrations/0012_whitelistedemail.py
deleted file mode 100644
index bec402c87..000000000
--- a/rgd/geodata/migrations/0012_whitelistedemail.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# Generated by Django 3.2.2 on 2021-05-12 16:30
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0011_auto_20210505_1552'),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='WhitelistedEmail',
-            fields=[
-                (
-                    'id',
-                    models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    ),
-                ),
-                ('email', models.EmailField(max_length=254)),
-            ],
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0013_auto_20210512_1954.py b/rgd/geodata/migrations/0013_auto_20210512_1954.py
deleted file mode 100644
index 478789fe4..000000000
--- a/rgd/geodata/migrations/0013_auto_20210512_1954.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# Generated by Django 3.2.2 on 2021-05-12 19:54
-
-import django.contrib.gis.db.models.fields
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0012_whitelistedemail'),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name='spatialentry',
-            name='footprint',
-            field=django.contrib.gis.db.models.fields.GeometryField(srid=4326),
-        ),
-        migrations.CreateModel(
-            name='SpatialAsset',
-            fields=[
-                (
-                    'spatialentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.spatialentry',
-                    ),
-                ),
-                ('name', models.CharField(blank=True, max_length=1000)),
-                ('description', models.TextField(blank=True, null=True)),
-                ('files', models.ManyToManyField(to='geodata.ChecksumFile')),
-            ],
-            bases=('geodata.spatialentry',),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0014_remove_collection_user.py b/rgd/geodata/migrations/0014_remove_collection_user.py
deleted file mode 100644
index e70fb7f92..000000000
--- a/rgd/geodata/migrations/0014_remove_collection_user.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# Generated by Django 3.2.2 on 2021-05-11 21:16
-
-from django.conf import settings
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-        ('geodata', '0013_auto_20210512_1954'),
-    ]
-
-    operations = [
-        migrations.RemoveField(
-            model_name='collection',
-            name='users',
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0015_rename_collectionmembership.py b/rgd/geodata/migrations/0015_rename_collectionmembership.py
deleted file mode 100644
index 58e08a7e8..000000000
--- a/rgd/geodata/migrations/0015_rename_collectionmembership.py
+++ /dev/null
@@ -1,42 +0,0 @@
-# Generated by Django 3.2.2 on 2021-05-11 21:16
-
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-        ('geodata', '0014_remove_collection_user'),
-    ]
-
-    operations = [
-        migrations.RenameModel(
-            old_name='collectionmembership',
-            new_name='collectionpermission',
-        ),
-        migrations.AlterField(
-            model_name='collectionpermission',
-            name='collection',
-            field=models.ForeignKey(
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name='collection_permissions',
-                to='geodata.collection',
-            ),
-        ),
-        migrations.AlterField(
-            model_name='collectionpermission',
-            name='user',
-            field=models.ForeignKey(
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name='collection_permissions',
-                to=settings.AUTH_USER_MODEL,
-            ),
-        ),
-        migrations.AlterModelOptions(
-            name='collectionpermission',
-            options={'default_related_name': 'collection_permissions'},
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0016_auto_20210520_1658.py b/rgd/geodata/migrations/0016_auto_20210520_1658.py
deleted file mode 100644
index 4df876023..000000000
--- a/rgd/geodata/migrations/0016_auto_20210520_1658.py
+++ /dev/null
@@ -1,131 +0,0 @@
-# Generated by Django 3.2.3 on 2021-05-20 16:58
-
-import django.contrib.postgres.fields
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0015_rename_collectionmembership'),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='PointCloudEntry',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('name', models.CharField(blank=True, max_length=1000)),
-                ('description', models.TextField(blank=True, null=True)),
-            ],
-            bases=('geodata.modifiableentry',),
-        ),
-        migrations.CreateModel(
-            name='PointCloudMetaEntry',
-            fields=[
-                (
-                    'spatialentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        to='geodata.spatialentry',
-                    ),
-                ),
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('crs', models.TextField(blank=True, help_text='PROJ string', null=True)),
-                (
-                    'origin',
-                    django.contrib.postgres.fields.ArrayField(
-                        base_field=models.FloatField(), blank=True, null=True, size=3
-                    ),
-                ),
-                (
-                    'parent_point_cloud',
-                    models.OneToOneField(
-                        on_delete=django.db.models.deletion.CASCADE, to='geodata.pointcloudentry'
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', 'geodata.spatialentry'),
-        ),
-        migrations.CreateModel(
-            name='PointCloudFile',
-            fields=[
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('failure_reason', models.TextField(null=True)),
-                (
-                    'status',
-                    models.CharField(
-                        choices=[
-                            ('created', 'Created but not queued'),
-                            ('queued', 'Queued for processing'),
-                            ('running', 'Processing'),
-                            ('failed', 'Failed'),
-                            ('success', 'Succeeded'),
-                        ],
-                        default='created',
-                        max_length=20,
-                    ),
-                ),
-                (
-                    'file',
-                    models.ForeignKey(
-                        on_delete=django.db.models.deletion.CASCADE, to='geodata.checksumfile'
-                    ),
-                ),
-            ],
-            options={
-                'abstract': False,
-            },
-            bases=('geodata.modifiableentry', models.Model),
-        ),
-        migrations.AddField(
-            model_name='pointcloudentry',
-            name='source',
-            field=models.OneToOneField(
-                blank=True,
-                null=True,
-                on_delete=django.db.models.deletion.CASCADE,
-                to='geodata.pointcloudfile',
-            ),
-        ),
-        migrations.AddField(
-            model_name='pointcloudentry',
-            name='vtp_data',
-            field=models.ForeignKey(
-                on_delete=django.db.models.deletion.DO_NOTHING, to='geodata.checksumfile'
-            ),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0017_imagesetspatial.py b/rgd/geodata/migrations/0017_imagesetspatial.py
deleted file mode 100644
index e6adec8d8..000000000
--- a/rgd/geodata/migrations/0017_imagesetspatial.py
+++ /dev/null
@@ -1,48 +0,0 @@
-# Generated by Django 3.2.3 on 2021-05-27 16:47
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0016_auto_20210520_1658'),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='ImageSetSpatial',
-            fields=[
-                (
-                    'spatialentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        to='geodata.spatialentry',
-                    ),
-                ),
-                (
-                    'modifiableentry_ptr',
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to='geodata.modifiableentry',
-                    ),
-                ),
-                ('name', models.CharField(blank=True, max_length=1000)),
-                ('description', models.TextField(blank=True, null=True)),
-                (
-                    'image_set',
-                    models.OneToOneField(
-                        on_delete=django.db.models.deletion.CASCADE, to='geodata.imageset'
-                    ),
-                ),
-            ],
-            bases=('geodata.modifiableentry', 'geodata.spatialentry'),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0018_alter_spatialentry_outline.py b/rgd/geodata/migrations/0018_alter_spatialentry_outline.py
deleted file mode 100644
index 3ce54aff2..000000000
--- a/rgd/geodata/migrations/0018_alter_spatialentry_outline.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# Generated by Django 3.2.3 on 2021-05-27 19:29
-
-import django.contrib.gis.db.models.fields
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0017_imagesetspatial'),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name='spatialentry',
-            name='outline',
-            field=django.contrib.gis.db.models.fields.GeometryField(srid=4326),
-        ),
-    ]
diff --git a/rgd/geodata/migrations/0019_index_collectionpermission_role.py b/rgd/geodata/migrations/0019_index_collectionpermission_role.py
deleted file mode 100644
index 25c894bec..000000000
--- a/rgd/geodata/migrations/0019_index_collectionpermission_role.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# Generated by Django 3.2.4 on 2021-06-09 02:56
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('geodata', '0018_alter_spatialentry_outline'),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name='collectionpermission',
-            name='role',
-            field=models.SmallIntegerField(
-                choices=[(1, 'Reader'), (2, 'Owner')],
-                db_index=True,
-                default=1,
-                help_text='A "reader" can view assets in this collection. An "owner" can additionally add/remove other users, set their permissions, delete the collection, and add/remove other files.',
-            ),
-        ),
-    ]
diff --git a/rgd/geodata/models/__init__.py b/rgd/geodata/models/__init__.py
deleted file mode 100644
index 17ec8c151..000000000
--- a/rgd/geodata/models/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-# These are models we want to expose in the top-level model namespace
-from .collection import Collection, CollectionPermission  # noqa
-from .common import (  # noqa
-    ChecksumFile,
-    FileSourceType,
-    ModifiableEntry,
-    SpatialEntry,
-    WhitelistedEmail,
-)
-from .fmv import *  # noqa
-from .geometry import *  # noqa
-from .imagery import *  # noqa
-from .threed import *  # noqa
diff --git a/rgd/geodata/models/fmv/__init__.py b/rgd/geodata/models/fmv/__init__.py
deleted file mode 100644
index 0fac388a4..000000000
--- a/rgd/geodata/models/fmv/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .base import FMVEntry, FMVFile  # noqa
diff --git a/rgd/geodata/models/geometry/__init__.py b/rgd/geodata/models/geometry/__init__.py
deleted file mode 100644
index 1cb81599b..000000000
--- a/rgd/geodata/models/geometry/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .base import GeometryArchive, GeometryEntry, validate_archive  # noqa
-from .transform import transform_geometry  # noqa
diff --git a/rgd/geodata/models/imagery/subsample.py b/rgd/geodata/models/imagery/subsample.py
deleted file mode 100644
index 8f932f0ef..000000000
--- a/rgd/geodata/models/imagery/subsample.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""Tasks for subsampling images with GDAL."""
-from celery.utils.log import get_task_logger
-import large_image_converter
-
-from rgd.utility import input_output_path_helper, output_path_helper
-
-from . import large_image_utilities
-from ..common import ChecksumFile
-from .processed import ConvertedImageFile, SubsampledImage
-
-logger = get_task_logger(__name__)
-
-
-def convert_to_cog(cog):
-    """Populate ConvertedImageFile with COG file."""
-    if not isinstance(cog, ConvertedImageFile):
-        cog = ConvertedImageFile.objects.get(id=cog)
-    else:
-        cog.refresh_from_db()
-    if not cog.converted_file:
-        cog.converted_file = ChecksumFile()
-    src = cog.source_image.image_file.imagefile.file
-    output = cog.converted_file.file
-
-    with input_output_path_helper(src, output, prefix='cog_', vsi=True) as (
-        input_path,
-        output_path,
-    ):
-        large_image_converter.convert(str(input_path), str(output_path))
-
-    cog.save(
-        update_fields=[
-            'converted_file',
-        ]
-    )
-    logger.info(f'Produced COG in ChecksumFile: {cog.converted_file.id}')
-    return cog.id
-
-
-def populate_subsampled_image(subsampled):
-    if not isinstance(subsampled, SubsampledImage):
-        subsampled = SubsampledImage.objects.get(id=subsampled)
-    else:
-        subsampled.refresh_from_db()
-    image_entry = subsampled.source_image
-
-    logger.info(f'Subsample parameters: {subsampled.sample_parameters}')
-    l, r, b, t, projection = subsampled.get_extent()
-
-    if not subsampled.data:
-        subsampled.data = ChecksumFile()
-
-    tile_source = large_image_utilities.get_tilesource_from_image_entry(image_entry)
-
-    filename = f'subsampled-{image_entry.image_file.file.name}'
-
-    with output_path_helper(filename, subsampled.data.file) as output_path:
-        logger.info(f'The extent: {l, r, b, t}')
-        if subsampled.sample_type in (
-            SubsampledImage.SampleTypes.GEOJSON,
-            SubsampledImage.SampleTypes.GEO_BOX,
-        ):
-            path, mime_type = large_image_utilities.get_region_world(
-                tile_source, l, r, b, t, projection=projection
-            )
-        else:
-            path, mime_type = large_image_utilities.get_region_pixel(tile_source, l, r, b, t)
-        with open(path, 'rb') as f, open(output_path, 'wb') as o:
-            o.write(f.read())
-
-    subsampled.data.save()
-    subsampled.save(
-        update_fields=[
-            'data',
-        ]
-    )
-    logger.info(f'Produced subsampled image in ChecksumFile: {subsampled.data.id}')
-    return subsampled.id
diff --git a/rgd/geodata/models/threed/__init__.py b/rgd/geodata/models/threed/__init__.py
deleted file mode 100644
index 18dbe1d13..000000000
--- a/rgd/geodata/models/threed/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .point_cloud import PointCloudEntry, PointCloudFile, PointCloudMetaEntry  # noqa
diff --git a/rgd/geodata/models/threed/point_cloud.py b/rgd/geodata/models/threed/point_cloud.py
deleted file mode 100644
index 09053fa17..000000000
--- a/rgd/geodata/models/threed/point_cloud.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from django.contrib.gis.db import models
-from django.contrib.postgres import fields
-
-from ... import tasks
-from ..common import ChecksumFile, ModifiableEntry, SpatialEntry
-from ..mixins import TaskEventMixin
-
-
-class PointCloudFile(ModifiableEntry, TaskEventMixin):
-    """Container for point cloud file."""
-
-    task_funcs = (tasks.task_read_point_cloud_file,)
-    file = models.ForeignKey(ChecksumFile, on_delete=models.CASCADE)
-
-    def data_link(self):
-        return self.file.data_link()
-
-    data_link.allow_tags = True
-
-
-class PointCloudEntry(ModifiableEntry):
-    """Container for converted point cloud data.
-
-    The data here must be stored in VTP format. This can be manually uploaded
-    or created automatically via a PointCloudFile upload.
-    """
-
-    name = models.CharField(max_length=1000, blank=True)
-    description = models.TextField(null=True, blank=True)
-
-    # Can be null if not generated from uploaded file
-    source = models.OneToOneField(PointCloudFile, null=True, blank=True, on_delete=models.CASCADE)
-
-    # A place to store converted file - must be in VTP format
-    vtp_data = models.ForeignKey(ChecksumFile, on_delete=models.DO_NOTHING)
-
-    def data_link(self):
-        return self.vtp_data.data_link()
-
-    data_link.allow_tags = True
-
-
-class PointCloudMetaEntry(ModifiableEntry, SpatialEntry):
-    """Optionally register a PointCloudEntry as a SpatialEntry."""
-
-    parent_point_cloud = models.OneToOneField(PointCloudEntry, on_delete=models.CASCADE)
-
-    crs = models.TextField(help_text='PROJ string', blank=True, null=True)  # PROJ String
-    # Origin point to map the 0,0,0 point of the point cloud
-    origin = fields.ArrayField(models.FloatField(), size=3, blank=True, null=True)
-
-    @property
-    def name(self):
-        return self.parent_point_cloud.name
diff --git a/rgd/geodata/serializers.py b/rgd/geodata/serializers.py
deleted file mode 100644
index 4877a1359..000000000
--- a/rgd/geodata/serializers.py
+++ /dev/null
@@ -1,400 +0,0 @@
-import base64
-import json
-
-import dateutil.parser
-from django.contrib.gis.geos import Polygon
-from django.db import transaction
-from pyproj import CRS
-import pystac
-from rest_framework import serializers
-from rest_framework.reverse import reverse
-
-from rgd import utility
-from rgd.geodata.permissions import check_write_perm
-
-from . import models
-
-
-class SpatialEntrySerializer(serializers.ModelSerializer):
-    def to_representation(self, value):
-        ret = super().to_representation(value)
-        # NOTE: including footprint can cause the search results to blow up in size
-        ret['outline'] = json.loads(value.outline.geojson)
-        # Add hyperlink to get view for subtype if SpatialEntry
-        if type(value).__name__ != value.subentry_type:
-            subtype = value.subentry_type
-            ret['subentry_type'] = subtype
-            ret['subentry_pk'] = value.subentry.pk
-            ret['subentry_name'] = value.subentry_name
-            if subtype == 'RasterMetaEntry':
-                ret['subentry_pk'] = value.subentry.pk
-                subtype_uri = reverse('raster-meta-entry', args=[value.subentry.pk])
-            elif subtype == 'GeometryEntry':
-                subtype_uri = reverse('geometry-entry', args=[value.subentry.pk])
-            elif subtype == 'FMVEntry':
-                subtype_uri = reverse('fmv-entry', args=[value.subentry.pk])
-            if 'request' in self.context:
-                request = self.context['request']
-                ret['detail'] = request.build_absolute_uri(subtype_uri)
-            else:
-                ret['detail'] = subtype_uri
-        return ret
-
-    class Meta:
-        model = models.SpatialEntry
-        exclude = ['footprint', 'outline']
-
-
-class SpatialEntryFootprintSerializer(SpatialEntrySerializer):
-    def to_representation(self, value):
-        ret = super().to_representation(value)
-        ret['footprint'] = json.loads(value.footprint.geojson)
-        return ret
-
-    class Meta:
-        model = models.SpatialEntry
-        exclude = ['footprint', 'outline']
-
-
-class GeometryEntrySerializer(SpatialEntrySerializer):
-    class Meta:
-        model = models.GeometryEntry
-        exclude = ['data', 'footprint', 'outline']
-
-
-class GeometryEntryDataSerializer(GeometryEntrySerializer):
-    def to_representation(self, value):
-        ret = super().to_representation(value)
-        ret['data'] = json.loads(value.data.geojson)
-        return ret
-
-    class Meta:
-        model = models.GeometryEntry
-        fields = '__all__'
-
-
-class ConvertedImageFileSerializer(serializers.ModelSerializer):
-    def validate_source_image(self, value):
-        if 'request' in self.context:
-            check_write_perm(self.context['request'].user, value)
-        return value
-
-    class Meta:
-        model = models.ConvertedImageFile
-        fields = '__all__'
-        read_only_fields = ['id', 'status', 'failure_reason', 'converted_file']
-
-
-class ChecksumFileSerializer(serializers.ModelSerializer):
-    def to_representation(self, value):
-        ret = super().to_representation(value)
-        ret['download_url'] = value.get_url()
-        return ret
-
-    class Meta:
-        model = models.ChecksumFile
-        fields = '__all__'
-        read_only_fields = ['id', 'checksum', 'last_validation', 'modified', 'created']
-
-
-class SubsampledImageSerializer(serializers.ModelSerializer):
-
-    data = ChecksumFileSerializer(read_only=True)
-
-    def validate_source_image(self, value):
-        if 'request' in self.context:
-            check_write_perm(self.context['request'].user, value)
-        return value
-
-    def to_representation(self, value):
-        ret = super().to_representation(value)
-        realtive_status_uri = reverse('subsampled-status', args=[value.id])
-        if 'request' in self.context:
-            request = self.context['request']
-            ret['status'] = request.build_absolute_uri(realtive_status_uri)
-        else:
-            ret['status'] = realtive_status_uri
-        return ret
-
-    class Meta:
-        model = models.SubsampledImage
-        fields = '__all__'
-        read_only_fields = [
-            'id',
-            'status',
-            'failure_reason',
-            'data',
-        ]
-
-    def create(self, validated_data):
-        """Prevent duplicated subsamples from being created."""
-        obj, created = models.SubsampledImage.objects.get_or_create(**validated_data)
-        if not created:
-            # Trigger save event to reprocess the subsampling
-            obj.save()
-        return obj
-
-
-class ImageFileSerializer(serializers.ModelSerializer):
-    file = ChecksumFileSerializer()
-
-    class Meta:
-        model = models.ImageFile
-        fields = '__all__'
-
-
-class ImageEntrySerializer(serializers.ModelSerializer):
-    image_file = ImageFileSerializer()
-
-    def to_representation(self, value):
-        ret = super().to_representation(value)
-        realtive_thumbnail_uri = reverse('image-thumbnail', args=[value.id])
-        if 'request' in self.context:
-            request = self.context['request']
-            ret['thumbnail'] = request.build_absolute_uri(realtive_thumbnail_uri)
-        else:
-            ret['thumbnail'] = realtive_thumbnail_uri
-        return ret
-
-    class Meta:
-        model = models.ImageEntry
-        fields = '__all__'
-        read_only_fields = [
-            'id',
-            'modified',
-            'created',
-            'driver',
-            'height',
-            'width',
-            'number_of_bands',
-        ]
-
-
-class ImageSetSerializer(serializers.ModelSerializer):
-    images = ImageEntrySerializer(many=True)
-
-    class Meta:
-        model = models.ImageSet
-        fields = '__all__'
-        read_only_fields = [
-            'id',
-            'modified',
-            'created',
-        ]
-
-
-class RasterEntrySerializer(serializers.ModelSerializer):
-    image_set = ImageSetSerializer()
-    ancillary_files = ChecksumFileSerializer(many=True)
-
-    class Meta:
-        model = models.RasterEntry
-        fields = '__all__'
-
-
-class RasterMetaEntrySerializer(SpatialEntrySerializer):
-    parent_raster = RasterEntrySerializer()
-
-    class Meta:
-        model = models.RasterMetaEntry
-        exclude = ['footprint', 'outline']
-
-
-class FMVFileSerializer(serializers.ModelSerializer):
-    file = ChecksumFileSerializer()
-
-    class Meta:
-        model = models.FMVFile
-        fields = '__all__'
-
-
-class FMVEntrySerializer(SpatialEntrySerializer):
-    fmv_file = FMVFileSerializer()
-
-    class Meta:
-        model = models.FMVEntry
-        exclude = [
-            'ground_frames',
-            'ground_union',
-            'flight_path',
-            'frame_numbers',
-            'outline',
-            'footprint',
-        ]
-
-
-class FMVEntryDataSerializer(FMVEntrySerializer):
-    def to_representation(self, value):
-        ret = super().to_representation(value)
-        ret['ground_frames'] = json.loads(value.ground_frames.geojson)
-        ret['ground_union'] = json.loads(value.ground_union.geojson)
-        ret['flight_path'] = json.loads(value.flight_path.geojson)
-        return ret
-
-    class Meta:
-        model = models.FMVEntry
-        fields = '__all__'
-
-
-class STACRasterSerializer(serializers.BaseSerializer):
-    def to_internal_value(self, data):
-        # item = pystac.Item.from_dict(data)
-        # errors = item.validate()
-        # if errors:
-        #     raise serializers.ValidationError(errors)
-        return data
-
-    def to_representation(self, instance: models.RasterMetaEntry) -> dict:
-        item = pystac.Item(
-            id=instance.pk,
-            geometry=json.loads(instance.footprint.json),
-            bbox=instance.extent,
-            datetime=(instance.acquisition_date or instance.modified or instance.created),
-            properties=dict(
-                datetime=str(instance.acquisition_date),
-                platform=instance.instrumentation,
-            ),
-        )
-        # 'proj' extension
-        item.ext.enable('projection')
-        item.ext.projection.apply(
-            epsg=CRS.from_proj4(instance.crs).to_epsg(),
-            transform=instance.transform,
-        )
-        # 'eo' extension
-        item.ext.enable('eo')
-        item.ext.eo.apply(cloud_cover=instance.cloud_cover, bands=[])
-        # Add assets
-        for image_entry in instance.parent_raster.image_set.images.all():
-            asset = pystac.Asset(
-                href=image_entry.image_file.file.get_url(),
-                title=image_entry.image_file.file.name,
-                roles=[
-                    'data',
-                ],
-            )
-            item.ext.eo.set_bands(
-                bands=[
-                    pystac.extensions.eo.Band.create(
-                        name=f'B{bandmeta.band_number}',
-                        description=bandmeta.description,
-                    )
-                    for bandmeta in image_entry.bandmetaentry_set.all()
-                ],
-                asset=asset,
-            )
-            item.add_asset(f'image-{image_entry.pk}', asset)
-
-        for ancillary_file in instance.parent_raster.ancillary_files.all():
-            asset = pystac.Asset(
-                href=ancillary_file.get_url(),
-                title=ancillary_file.name,
-                roles=[
-                    'metadata',
-                ],
-            )
-            item.add_asset(f'ancillary-{ancillary_file.pk}', asset)
-
-        return item.to_dict()
-
-    @transaction.atomic
-    def create(self, data):
-        # TODO: fix messy import
-        from rgd.geodata.models.imagery.etl import read_image_file
-
-        item = pystac.Item.from_dict(data)
-        images, ancillary = [], []
-        single_asset = False
-        if len(item.assets) == 1:
-            single_asset = True
-        for name in item.assets:
-            asset = item.assets[name]
-            checksum_file, _ = models.ChecksumFile.objects.get_or_create(
-                type=models.FileSourceType.URL,
-                url=asset.href,
-            )
-            if single_asset or (asset.roles and 'data' in asset.roles):
-                image_file, _ = utility.get_or_create_no_commit(
-                    models.ImageFile, file=checksum_file
-                )
-                image_file.skip_signal = True
-                image_file.save()
-                read_image_file(image_file)
-                image_entry = models.ImageEntry.objects.get(image_file=image_file)
-                images.append(image_entry)
-            else:
-                ancillary.append(checksum_file)
-
-        image_set = models.ImageSet.objects.create()
-        image_set.images.set(images)
-
-        raster_entry = models.RasterEntry()
-        raster_entry.skip_signal = True
-        raster_entry.name = item.id
-        raster_entry.image_set = image_set
-        raster_entry.save()
-        [raster_entry.ancillary_files.add(af) for af in ancillary]
-        raster_entry.save()
-
-        outline = Polygon(
-            (
-                [item.bbox[0], item.bbox[1]],
-                [item.bbox[0], item.bbox[3]],
-                [item.bbox[2], item.bbox[3]],
-                [item.bbox[2], item.bbox[1]],
-                [item.bbox[0], item.bbox[1]],
-            )
-        )
-
-        instance = models.RasterMetaEntry(
-            parent_raster=raster_entry,
-            footprint=json.dumps(item.geometry),
-            crs=f'+init=epsg:{item.ext.projection.epsg}',
-            cloud_cover=item.ext.eo.cloud_cover,
-            transform=item.ext.projection.transform,
-            extent=item.bbox,
-            origin=(item.bbox[0], item.bbox[1]),
-            resolution=(0, 0),  # TODO: fix
-            outline=outline,
-            acquisition_date=dateutil.parser.isoparser().isoparse(item.properties['datetime']),
-            instrumentation=item.properties['platform'],
-        )
-        instance.save()
-        return instance
-
-
-class PointCloudFileSerializer(serializers.ModelSerializer):
-    file = ChecksumFileSerializer()
-
-    class Meta:
-        model = models.PointCloudFile
-        fields = '__all__'
-
-
-class PointCloudEntrySerializer(serializers.ModelSerializer):
-    source = PointCloudFileSerializer()
-    vtp_data = ChecksumFileSerializer()
-
-    def to_representation(self, value):
-        ret = super().to_representation(value)
-        return ret
-
-    class Meta:
-        model = models.PointCloudEntry
-        fields = '__all__'
-
-
-class PointCloudEntryDataSerializer(PointCloudEntrySerializer):
-    def to_representation(self, value):
-        ret = super().to_representation(value)
-        # Extract data as base64
-        with value.vtp_data.yield_local_path() as path:
-            with open(path, 'rb') as data:
-                data_content = data.read()
-                base64_content = base64.b64encode(data_content)
-                base64_content = base64_content.decode().replace('\n', '')
-        ret['vtp_data'] = base64_content
-        return ret
-
-
-utility.make_serializers(globals(), models)
diff --git a/rgd/geodata/signals.py b/rgd/geodata/signals.py
deleted file mode 100644
index 51ffb37ad..000000000
--- a/rgd/geodata/signals.py
+++ /dev/null
@@ -1,130 +0,0 @@
-from functools import wraps
-import os
-
-from allauth.account.signals import user_signed_up
-from django.db import transaction
-from django.db.models.signals import m2m_changed, post_delete, post_save
-from django.dispatch import receiver
-
-from .models.common import ChecksumFile, WhitelistedEmail
-from .models.fmv import FMVFile
-from .models.geometry import GeometryArchive
-from .models.imagery import (
-    ConvertedImageFile,
-    ImageFile,
-    ImageSet,
-    KWCOCOArchive,
-    RasterEntry,
-    SubsampledImage,
-)
-from .models.threed.point_cloud import PointCloudFile
-
-
-def skip_signal():
-    """Skip the signal on an instance-basis."""
-
-    def _skip_signal(signal_func):
-        @wraps(signal_func)
-        def _decorator(sender, instance, *args, **kwargs):
-            if hasattr(instance, 'skip_signal'):
-                return None
-            return signal_func(sender, instance, *args, **kwargs)
-
-        return _decorator
-
-    return _skip_signal
-
-
-@receiver(post_save, sender=FMVFile)
-@skip_signal()
-def _post_save_fmv_file(sender, instance, *args, **kwargs):
-    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
-
-
-@receiver(post_save, sender=GeometryArchive)
-@skip_signal()
-def _post_save_geometry_archive(sender, instance, *args, **kwargs):
-    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
-
-
-@receiver(post_save, sender=ChecksumFile)
-@skip_signal()
-def _post_save_checksum_file(sender, instance, *args, **kwargs):
-    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
-
-
-@receiver(m2m_changed, sender=ImageSet.images.through)
-@skip_signal()
-def _m2m_changed_image_set(sender, instance, action, reverse, *args, **kwargs):
-    # If no name was specified for an ImageSet, when images are added to it,
-    # use the common base name of all images as the name of the ImageSet.
-    if action == 'post_add' and not instance.name and instance.images.count():
-        names = [image.name for image in instance.images.all() if image.name]
-        if len(names):
-            instance.name = os.path.commonprefix(names)
-            instance.save(update_fields=['name'])
-
-
-@receiver(post_save, sender=RasterEntry)
-@skip_signal()
-def _post_save_raster_entry(sender, instance, *args, **kwargs):
-    transaction.on_commit(lambda: instance._on_commit_event_task(*args, **kwargs))
-
-
-@receiver(post_save, sender=KWCOCOArchive)
-@skip_signal()
-def _post_save_kwcoco_dataset(sender, instance, *args, **kwargs):
-    transaction.on_commit(lambda: instance._on_commit_event_task(*args, **kwargs))
-
-
-@receiver(post_delete, sender=KWCOCOArchive)
-@skip_signal()
-def _post_delete_kwcoco_dataset(sender, instance, *args, **kwargs):
-    transaction.on_commit(lambda: instance._post_delete(*args, **kwargs))
-
-
-@receiver(post_save, sender=ImageFile)
-@skip_signal()
-def _post_save_image_file(sender, instance, *args, **kwargs):
-    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
-
-
-@receiver(post_save, sender=ConvertedImageFile)
-@skip_signal()
-def _post_save_converted_image_file(sender, instance, *args, **kwargs):
-    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
-
-
-@receiver(post_save, sender=SubsampledImage)
-@skip_signal()
-def _post_save_subsampled_image(sender, instance, *args, **kwargs):
-    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
-
-
-@receiver(post_delete, sender=ConvertedImageFile)
-@skip_signal()
-def _post_delete_converted_image_file(sender, instance, *args, **kwargs):
-    transaction.on_commit(lambda: instance._post_delete(*args, **kwargs))
-
-
-@receiver(post_delete, sender=SubsampledImage)
-@skip_signal()
-def _post_delete_subsampled_image(sender, instance, *args, **kwargs):
-    transaction.on_commit(lambda: instance._post_delete(*args, **kwargs))
-
-
-@receiver(post_save, sender=PointCloudFile)
-@skip_signal()
-def _post_save_point_cloud_file(sender, instance, *args, **kwargs):
-    transaction.on_commit(lambda: instance._post_save_event_task(*args, **kwargs))
-
-
-@receiver(user_signed_up)
-def set_new_user_inactive(sender, **kwargs):
-    user = kwargs.get('user')
-    try:
-        WhitelistedEmail.objects.get(email=user.email)
-        user.is_active = True
-    except WhitelistedEmail.DoesNotExist:
-        user.is_active = False
-    user.save(update_fields=['is_active'])
diff --git a/rgd/geodata/tasks.py b/rgd/geodata/tasks.py
deleted file mode 100644
index 04cf959b8..000000000
--- a/rgd/geodata/tasks.py
+++ /dev/null
@@ -1,130 +0,0 @@
-import traceback
-
-from celery import shared_task
-from celery.utils.log import get_task_logger
-
-# NOTE: do not import `models` to avoid recursive imports
-
-logger = get_task_logger(__name__)
-
-
-def _safe_execution(func, *args, **kwargs):
-    """Execute a task and return any tracebacks that occur as a string."""
-    try:
-        func(*args, **kwargs)
-        return ''
-    except Exception as exc:
-        logger.exception(f'Internal error run `{func.__name__}`: {exc}')
-        return traceback.format_exc()
-
-
-def _run_with_failure_reason(model, func, *args, **kwargs):
-    """Run a function that will update the model's `failure_reason`."""
-    from .models.mixins import Status
-
-    model.status = Status.RUNNING
-    model.save(update_fields=['status'])
-    model.failure_reason = _safe_execution(func, *args, **kwargs)
-    if model.failure_reason:
-        model.status = Status.FAILED
-    else:
-        model.status = Status.SUCCEEDED
-    model.save(update_fields=['failure_reason', 'status'])
-
-
-@shared_task(time_limit=86400)
-def task_read_image_file(file_id):
-    from .models.imagery import ImageFile
-    from .models.imagery.etl import read_image_file
-
-    image_file = ImageFile.objects.get(id=file_id)
-    _run_with_failure_reason(image_file, read_image_file, file_id)
-
-
-@shared_task(time_limit=86400)
-def task_read_geometry_archive(archive_id):
-    from .models.geometry.etl import GeometryArchive, read_geometry_archive
-
-    archive = GeometryArchive.objects.get(id=archive_id)
-    _run_with_failure_reason(archive, read_geometry_archive, archive_id)
-
-
-@shared_task(time_limit=86400)
-def task_populate_raster_entry(raster_id):
-    from .models.imagery import RasterEntry
-    from .models.imagery.etl import populate_raster_entry
-
-    raster_entry = RasterEntry.objects.get(id=raster_id)
-    _run_with_failure_reason(raster_entry, populate_raster_entry, raster_id)
-
-
-@shared_task(time_limit=86400)
-def task_populate_raster_footprint(raster_id):
-    from .models.imagery import RasterEntry
-    from .models.imagery.etl import populate_raster_footprint
-
-    raster_entry = RasterEntry.objects.get(id=raster_id)
-    _run_with_failure_reason(raster_entry, populate_raster_footprint, raster_id)
-
-
-@shared_task(time_limit=86400)
-def task_populate_raster_outline(raster_id):
-    from .models.imagery import RasterEntry
-    from .models.imagery.etl import populate_raster_outline
-
-    raster_entry = RasterEntry.objects.get(id=raster_id)
-    _run_with_failure_reason(raster_entry, populate_raster_outline, raster_id)
-
-
-@shared_task(time_limit=86400)
-def task_load_kwcoco_dataset(kwcoco_dataset_id):
-    from .models.imagery import KWCOCOArchive
-    from .models.imagery.kwcoco_etl import load_kwcoco_dataset
-
-    ds_entry = KWCOCOArchive.objects.get(id=kwcoco_dataset_id)
-    _run_with_failure_reason(ds_entry, load_kwcoco_dataset, kwcoco_dataset_id)
-
-
-@shared_task(time_limit=86400)
-def task_read_fmv_file(file_id):
-    from .models.fmv import FMVFile
-    from .models.fmv.etl import read_fmv_file
-
-    fmv_file = FMVFile.objects.get(id=file_id)
-    _run_with_failure_reason(fmv_file, read_fmv_file, file_id)
-
-
-@shared_task(time_limit=86400)
-def task_convert_to_cog(conv_id):
-    from .models.imagery import ConvertedImageFile
-    from .models.imagery.subsample import convert_to_cog
-
-    cog = ConvertedImageFile.objects.get(id=conv_id)
-    _run_with_failure_reason(cog, convert_to_cog, conv_id)
-
-
-@shared_task(time_limit=86400)
-def task_populate_subsampled_image(subsampled_id):
-    from .models.imagery import SubsampledImage
-    from .models.imagery.subsample import populate_subsampled_image
-
-    cog = SubsampledImage.objects.get(id=subsampled_id)
-    _run_with_failure_reason(cog, populate_subsampled_image, subsampled_id)
-
-
-@shared_task(time_limit=86400)
-def task_checksum_file_post_save(checksumfile_id):
-    from .models.common import ChecksumFile
-
-    obj = ChecksumFile.objects.get(id=checksumfile_id)
-
-    _run_with_failure_reason(obj, obj.post_save_job)
-
-
-@shared_task(time_limit=86400)
-def task_read_point_cloud_file(pc_file_id):
-    from .models.threed.etl import read_point_cloud_file
-    from .models.threed.point_cloud import PointCloudFile
-
-    pc_file = PointCloudFile.objects.get(id=pc_file_id)
-    _run_with_failure_reason(pc_file, read_point_cloud_file, pc_file_id)
diff --git a/rgd/geodata/urls.py b/rgd/geodata/urls.py
deleted file mode 100644
index 86b7ca24c..000000000
--- a/rgd/geodata/urls.py
+++ /dev/null
@@ -1,221 +0,0 @@
-from django.urls import path, register_converter
-
-from . import api, views
-
-
-class FloatUrlParameterConverter:
-    regex = r'-?[0-9]+\.?[0-9]+'
-
-    def to_python(self, value):
-        return float(value)
-
-    def to_url(self, value):
-        return str(value)
-
-
-register_converter(FloatUrlParameterConverter, 'float')
-
-
-urlpatterns = [
-    # Pages
-    path(r'', views.SpatialEntriesListView.as_view(), name='index'),
-    path(r'geodata/raster/', views.RasterMetaEntriesListView.as_view(), name='raster-search'),
-    path(
-        'geodata/statistics',
-        views.StatisticsView.as_view(),
-        name='statistics',
-    ),
-    path(
-        'geodata/spatial_entries/<int:pk>/',
-        views.spatial_entry_redirect_view,
-        name='spatial-entry-detail',
-    ),
-    path(
-        'geodata/raster/<int:pk>/',
-        views.RasterEntryDetailView.as_view(),
-        name='raster-entry-detail',
-    ),
-    path(
-        'geodata/fmv/<int:pk>/',
-        views.FMVEntryDetailView.as_view(),
-        name='fmv-entry-detail',
-    ),
-    path(
-        'geodata/geometry/<int:pk>/',
-        views.GeometryEntryDetailView.as_view(),
-        name='geometry-entry-detail',
-    ),
-    path(
-        'geodata/point_cloud/<int:pk>/',
-        views.PointCloudEntryDetailView.as_view(),
-        name='point-cloud-entry-detail',
-    ),
-    path(
-        'geodata/imagery/image_set/<int:pk>/',
-        views.ImageSetSpatialDetailView.as_view(),
-        name='image-set-spatial-detail',
-    ),
-    #############
-    # Search
-    path('api/geosearch', api.search.SearchSpatialEntryView.as_view()),
-    path('api/geosearch/raster', api.search.SearchRasterMetaEntrySTACView.as_view()),
-    #############
-    # Other
-    path(
-        'api/geodata/status/<model>/<int:pk>',
-        api.download.get_status,
-        name='get-status',
-    ),
-    path(
-        'api/geodata/common/spatial_entry/<int:spatial_id>',
-        api.get.GetSpatialEntry.as_view(),
-        name='spatial-entry',
-    ),
-    path(
-        'api/geodata/common/spatial_entry/<int:spatial_id>/footprint',
-        api.get.GetSpatialEntryFootprint.as_view(),
-        name='spatial-entry-footprint',
-    ),
-    path(
-        'api/geodata/common/checksum_file/<int:pk>',
-        api.get.GetChecksumFile.as_view(),
-        name='checksum-file',
-    ),
-    path(
-        'api/geodata/common/checksum_file/<int:pk>/data',
-        api.download.download_checksum_file,
-        name='checksum-file-data',
-    ),
-    path(
-        'api/geodata/geometry/<int:pk>',
-        api.get.GetGeometryEntry.as_view(),
-        name='geometry-entry',
-    ),
-    path(
-        'api/geodata/geometry/<int:pk>/data',
-        api.get.GetGeometryEntryData.as_view(),
-        name='geometry-entry-data',
-    ),
-    path(
-        'api/geodata/imagery/<int:pk>',
-        api.get.GetImageEntry.as_view(),
-        name='image-entry',
-    ),
-    path(
-        'api/geodata/imagery/<int:pk>/data',
-        api.download.download_image_entry_file,
-        name='image-entry-data',
-    ),
-    path(
-        'api/geodata/imagery/image_set/<int:pk>',
-        api.get.GetImageSet.as_view(),
-        name='image-set',
-    ),
-    path(
-        'api/geodata/imagery/raster/<int:pk>',
-        api.get.GetRasterMetaEntry.as_view(),
-        name='raster-meta-entry',
-    ),
-    path(
-        'api/geodata/imagery/raster/<int:pk>/stac',
-        api.get.GetRasterMetaEntrySTAC.as_view(),
-        name='raster-meta-entry-stac',
-    ),
-    path(
-        'api/geodata/imagery/raster/stac',
-        api.post.CreateRasterSTAC.as_view(),
-        name='raster-meta-entry-stac-post',
-    ),
-    path(
-        'api/geodata/fmv/<int:pk>',
-        api.get.GetFMVEntry.as_view(),
-        name='fmv-entry',
-    ),
-    path(
-        'api/geodata/fmv/<int:pk>/data',
-        api.get.GetFMVDataEntry.as_view(),
-        name='fmv-entry-data',
-    ),
-    path(
-        'api/geodata/point_cloud/<int:pk>',
-        api.get.GetPointCloudEntry.as_view(),
-        name='point-cloud-entry',
-    ),
-    path(
-        'api/geodata/point_cloud/<int:pk>/base64',
-        api.get.GetPointCloudEntryData.as_view(),
-        name='point-cloud-entry-data',
-    ),
-    #############
-    # Geoprocessing
-    path(
-        'api/geoprocess/imagery/<int:pk>/tiles',
-        api.tiles.TileMetadataView.as_view(),
-        name='image-tile-metadata',
-    ),
-    path(
-        'api/geoprocess/imagery/<int:pk>/tiles/internal',
-        api.tiles.TileInternalMetadataView.as_view(),
-        name='image-tile-internal-metadata',
-    ),
-    path(
-        'api/geoprocess/imagery/<int:pk>/tiles/<int:z>/<int:x>/<int:y>.png',
-        api.tiles.TileView.as_view(),
-        name='image-tiles',
-    ),
-    path(
-        'api/geoprocess/imagery/<int:pk>/tiles/region/world/<float:left>/<float:right>/<float:bottom>/<float:top>/region.tif',
-        api.tiles.TileRegionView.as_view(),
-        name='image-region',
-    ),
-    path(
-        'api/geoprocess/imagery/<int:pk>/tiles/region/pixel/<int:left>/<int:right>/<int:bottom>/<int:top>/region.tif',
-        api.tiles.TileRegionPixelView.as_view(),
-        name='image-region-pixel',
-    ),
-    path(
-        'api/geoprocess/imagery/<int:pk>/tiles/<int:z>/<int:x>/<int:y>/corners',
-        api.tiles.TileCornersView.as_view(),
-        name='image-tile-corners',
-    ),
-    path(
-        'api/geoprocess/imagery/<int:pk>/thumbnail',
-        api.tiles.TileThumnailView.as_view(),
-        name='image-thumbnail',
-    ),
-    path(
-        'api/geoprocess/imagery/<int:pk>/bands',
-        api.tiles.TileBandInfoView.as_view(),
-        name='image-bands',
-    ),
-    path(
-        'api/geoprocess/imagery/<int:pk>/bands/<int:band>',
-        api.tiles.TileSingleBandInfoView.as_view(),
-        name='image-bands-single',
-    ),
-    path('api/geoprocess/imagery/cog', api.post.CreateConvertedImageFile.as_view()),
-    path(
-        'api/geoprocess/imagery/cog/<int:pk>',
-        api.get.GetConvertedImageStatus.as_view(),
-        name='cog',
-    ),
-    path(
-        'api/geoprocess/imagery/cog/<int:pk>/data',
-        api.download.download_cog_file,
-        name='cog-data',
-    ),
-    path(
-        'api/geoprocess/imagery/subsample',
-        api.post.CreateSubsampledImage.as_view(),
-    ),
-    path(
-        'api/geoprocess/imagery/subsample/<int:pk>',
-        api.get.GetSubsampledImage.as_view(),
-        name='subsampled',
-    ),
-    path(
-        'api/geoprocess/imagery/subsample/<int:pk>/status',
-        api.download.get_status_subsampled_image,
-        name='subsampled-status',
-    ),
-]
diff --git a/rgd/settings.py b/rgd/settings.py
deleted file mode 100644
index 34121ac16..000000000
--- a/rgd/settings.py
+++ /dev/null
@@ -1,128 +0,0 @@
-import os
-from pathlib import Path
-from typing import Type
-
-from composed_configuration import (
-    ComposedConfiguration,
-    ConfigMixin,
-    DevelopmentBaseConfiguration,
-    HerokuProductionBaseConfiguration,
-    ProductionBaseConfiguration,
-    TestingBaseConfiguration,
-)
-from configurations import values
-
-
-class GeoDjangoMixin(ConfigMixin):
-    # A setting to allow read access to all users if permissions are unassigned
-    RGD_GLOBAL_READ_ACCESS = values.Value(default=False)
-
-    @staticmethod
-    def before_binding(configuration: Type[ComposedConfiguration]):
-        configuration.INSTALLED_APPS += ['django.contrib.gis']
-        try:
-            import re
-
-            import osgeo
-
-            libsdir = os.path.join(
-                os.path.dirname(os.path.dirname(osgeo._gdal.__file__)), 'GDAL.libs'
-            )
-            libs = {
-                re.split(r'-|\.', name)[0]: os.path.join(libsdir, name)
-                for name in os.listdir(libsdir)
-            }
-            configuration.GDAL_LIBRARY_PATH = libs['libgdal']
-            configuration.GEOS_LIBRARY_PATH = libs['libgeos_c']
-        except Exception:
-            # TODO: Log that we aren't using the expected GDAL wheel?
-            pass
-
-
-class SwaggerMixin(ConfigMixin):
-    REFETCH_SCHEMA_WITH_AUTH = True
-    REFETCH_SCHEMA_ON_LOGOUT = True
-    OPERATIONS_SORTER = 'alpha'
-    DEEP_LINKING = True
-
-
-class CrispyFormsMixin(ConfigMixin):
-    @staticmethod
-    def before_binding(configuration: Type[ComposedConfiguration]):
-        configuration.INSTALLED_APPS += ['crispy_forms']
-
-
-class RgdMixin(CrispyFormsMixin, GeoDjangoMixin, SwaggerMixin, ConfigMixin):
-    WSGI_APPLICATION = 'rgd.wsgi.application'
-    ROOT_URLCONF = 'rgd.urls'
-
-    BASE_DIR = Path(__file__).resolve(strict=True).parent.parent
-
-    @staticmethod
-    def before_binding(configuration: Type[ComposedConfiguration]):
-        # Install local apps first, to ensure any overridden resources are found first
-        configuration.INSTALLED_APPS = [
-            'rgd.geodata.apps.GeodataConfig',
-        ] + configuration.INSTALLED_APPS
-
-        # Install additional apps
-        configuration.INSTALLED_APPS += [
-            's3_file_field',
-            'rules.apps.AutodiscoverRulesConfig',  # TODO: need this?
-            # To ensure that exceptions inside other apps' signal handlers do not affect the
-            # integrity of file deletions within transactions, CleanupConfig should be last.
-            'django_cleanup.apps.CleanupConfig',
-        ]
-
-        configuration.REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'].append(
-            'rest_framework.authentication.BasicAuthentication'
-        )
-
-        configuration.AUTHENTICATION_BACKENDS.insert(0, 'rules.permissions.ObjectPermissionBackend')
-
-    # This cannot have a default value, since the password and database name are always
-    # set by the service admin
-    DATABASES = values.DatabaseURLValue(
-        environ_name='DATABASE_URL',
-        environ_prefix='DJANGO',
-        environ_required=True,
-        # Additional kwargs to DatabaseURLValue are passed to dj-database-url
-        engine='django.contrib.gis.db.backends.postgis',
-        conn_max_age=600,
-    )
-
-    CELERY_WORKER_SEND_TASK_EVENTS = True
-
-    RGD_FILE_FIELD_PREFIX = values.Value(default=None)
-
-
-class DevelopmentConfiguration(RgdMixin, DevelopmentBaseConfiguration):
-    pass
-
-
-class TestingConfiguration(RgdMixin, TestingBaseConfiguration):
-    CELERY_TASK_ALWAYS_EAGER = True
-    CELERY_TASK_EAGER_PROPAGATES = True
-
-
-class ProductionConfiguration(RgdMixin, ProductionBaseConfiguration):
-    pass
-
-
-class AWSProductionConfiguration(RgdMixin, ProductionBaseConfiguration):
-    AWS_S3_ACCESS_KEY_ID = None
-    AWS_S3_SECRET_ACCESS_KEY = None
-    AWS_S3_REGION_NAME = None
-    SECURE_PROXY_SSL_HEADER = values.TupleValue(('HTTP_X_FORWARDED_PROTO', 'https'), separator=',')
-
-
-class HerokuProductionConfiguration(RgdMixin, HerokuProductionBaseConfiguration):
-    # Use different env var names (with no DJANGO_ prefix) for services that Heroku auto-injects
-    DATABASES = values.DatabaseURLValue(
-        environ_name='DATABASE_URL',
-        environ_prefix=None,
-        environ_required=True,
-        engine='django.contrib.gis.db.backends.postgis',
-        conn_max_age=600,
-        ssl_require=True,
-    )
diff --git a/rgd/urls.py b/rgd/urls.py
deleted file mode 100644
index f44084d9b..000000000
--- a/rgd/urls.py
+++ /dev/null
@@ -1,93 +0,0 @@
-import re
-
-from django.conf import settings
-from django.contrib import admin
-from django.urls import include, path, re_path
-from drf_yasg import openapi
-from drf_yasg.inspectors.view import SwaggerAutoSchema
-from drf_yasg.views import get_schema_view
-from rest_framework import permissions
-import uritemplate  # noqa: F401
-
-
-def drf_yasg_get_summary_and_description(self):
-    """Return an operation summary and description.
-
-    This is a modified form of the function in drf_yasg.
-
-    When getting descriptions from view docstrings or when there is no summary,
-    the resultant summary is prefixed with the method.  When taking
-    descriptions from docstrings, change newlines to improve how the default
-    swagger client displays them.
-    """
-    description = self.overrides.get('operation_description', None)
-    summary = self.overrides.get('operation_summary', None)
-    method = self.operation_keys[-1].replace('_', ' ').capitalize()
-    if description is None:
-        description = self._sch.get_description(self.path, self.method) or ''
-        description = description.strip().replace('\r', '')
-
-        if description and summary is None:
-            # description from docstring... do summary magic
-            summary, description = self.split_summary_from_description(description)
-            # Replace single newlines with spaces and multiple newlines with
-            # single newlines.
-            description = re.sub(
-                r'\r',
-                '\n',
-                re.sub(r'\n', ' ', re.sub(r'\n\n+', '\r', re.sub(r'\r', '', description))),
-            )
-            if summary:
-                info = summary.rstrip('.')
-                summary = f'{method} {info}'
-            else:
-                info = ' '.join(self.operation_keys[:-1])
-                summary = f'{method} {info}'
-    if summary is None:
-        summary = f'{method} {description}'
-    summary = summary[:120]
-
-    return summary, description
-
-
-# Modify drf_yasg's method of generating summaries and descriptions to work
-# better with our views.
-SwaggerAutoSchema.get_summary_and_description = drf_yasg_get_summary_and_description
-
-
-urlpatterns = [
-    path('accounts/', include('allauth.urls')),
-    path('oauth/', include('oauth2_provider.urls', namespace='oauth2_provider')),
-    path('admin/', admin.site.urls),
-    path('api/s3-upload/', include('s3_file_field.urls')),
-    path('', include('rgd.geodata.urls')),
-]
-
-schema_view = get_schema_view(
-    openapi.Info(
-        title='ResonantGeoData API',
-        default_version='v1',
-        description='ResonantGeoData',
-        # terms_of_service='https://www.google.com/policies/terms/',
-        contact=openapi.Contact(email='kitware@kitare.com'),
-        license=openapi.License(name='Apache 2.0'),
-    ),
-    public=False,
-    permission_classes=(permissions.AllowAny,),
-    patterns=urlpatterns,
-)
-
-urlpatterns += [
-    re_path(
-        r'^swagger(?P<format>\.json|\.yaml)$',
-        schema_view.without_ui(cache_timeout=0),
-        name='schema-json',
-    ),
-    path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
-    path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
-]
-
-if settings.DEBUG:
-    import debug_toolbar
-
-    urlpatterns = [path('__debug__/', include(debug_toolbar.urls))] + urlpatterns
diff --git a/rgd/wsgi.py b/rgd/wsgi.py
deleted file mode 100644
index 0be65f5cd..000000000
--- a/rgd/wsgi.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import os
-
-import configurations.importer
-from django.core.wsgi import get_wsgi_application
-
-os.environ['DJANGO_SETTINGS_MODULE'] = 'rgd.settings'
-if not os.environ.get('DJANGO_CONFIGURATION'):
-    raise ValueError('The environment variable "DJANGO_CONFIGURATION" must be set.')
-configurations.importer.install()
-
-application = get_wsgi_application()
diff --git a/rgdc/rgdc/__init__.py b/rgdc/rgdc/__init__.py
deleted file mode 100644
index 0d2461b9c..000000000
--- a/rgdc/rgdc/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .rgdc import Rgdc  # noqa F401
-from .version import __version__  # noqa F401
diff --git a/rgdc/rgdc/version.py b/rgdc/rgdc/version.py
deleted file mode 100644
index df9144c54..000000000
--- a/rgdc/rgdc/version.py
+++ /dev/null
@@ -1,1 +0,0 @@
-__version__ = '0.1.1'
diff --git a/terraform/.gitignore b/terraform/.gitignore
deleted file mode 100644
index 1c99dc138..000000000
--- a/terraform/.gitignore
+++ /dev/null
@@ -1,1 +0,0 @@
-.terraform/
diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl
deleted file mode 100644
index 931e4b9ec..000000000
--- a/terraform/.terraform.lock.hcl
+++ /dev/null
@@ -1,57 +0,0 @@
-# This file is maintained automatically by "terraform init".
-# Manual edits may be lost in future updates.
-
-provider "registry.terraform.io/hashicorp/aws" {
-  version = "3.27.0"
-  hashes = [
-    "h1:ccxtk7jAtmBPvAEXswOEYJcyp5jTD9QlQeg8GEzYmxQ=",
-    "zh:2986eb5a1ffbb0336c6390aad533b62efc832aa8aa5460d523e1f2daa4f42f79",
-    "zh:825317cdb80860833125a856c0befc877cba22d41c631c5a7ca22400693d4356",
-    "zh:a47aad668cc74058f508c56c5407cd715dbb9b6389aa68d37543e897895db43f",
-    "zh:c0011502d0eb4637918127c3987a8cc07a015ea00f74f4956fd111c736286a4d",
-    "zh:d5088ab51043bb2239132f4ed3760292b6aa4f7296232e4b8017f8c5c34f051a",
-    "zh:d893658e983eb17a23a8124c79a910cc729cb1d751d5509b8e756101c828ad91",
-    "zh:dcc4384ee79ea9492c87eb01e664f7f6b1f1d156471476f30b28336c9d9a4aec",
-    "zh:e4abfaf013f31791cd029af7b6f989f73e3efca28fe2917057b428d051c4085f",
-    "zh:f2a4d9446d23afe2a42421e7d5f902d34451fb31b7787b5e3aef95c08fec5ced",
-    "zh:f54a6af10b077db9dc11556c27f59ba5c60e1b2ba96fe3aa9cd90d8c67d980f6",
-  ]
-}
-
-provider "registry.terraform.io/hashicorp/random" {
-  version = "3.0.1"
-  hashes = [
-    "h1:SzM8nt2wzLMI28A3CWAtW25g3ZCm1O4xD0h3Ps/rU1U=",
-    "zh:0d4f683868324af056a9eb2b06306feef7c202c88dbbe6a4ad7517146a22fb50",
-    "zh:4824b3c7914b77d41dfe90f6f333c7ac9860afb83e2a344d91fbe46e5dfbec26",
-    "zh:4b82e43712f3cf0d0cbc95b2cbcd409ba8f0dc7848fdfb7c13633c27468ed04a",
-    "zh:78b3a2b860c3ebc973a794000015f5946eb59b82705d701d487475406b2612f1",
-    "zh:88bc65197bd74ff408d147b32f0045372ae3a3f2a2fdd7f734f315d988c0e4a2",
-    "zh:91bd3c9f625f177f3a5d641a64e54d4b4540cb071070ecda060a8261fb6eb2ef",
-    "zh:a6818842b28d800f784e0c93284ff602b0c4022f407e4750da03f50b853a9a2c",
-    "zh:c4a1a2b52abd05687e6cfded4a789dcd7b43e7a746e4d02dd1055370cf9a994d",
-    "zh:cf65041bf12fc3bde709c1d267dbe94142bc05adcabc4feb17da3b12249132ac",
-    "zh:e385e00e7425dda9d30b74ab4ffa4636f4b8eb23918c0b763f0ffab84ece0c5c",
-  ]
-}
-
-provider "registry.terraform.io/heroku/heroku" {
-  version = "3.2.0"
-  hashes = [
-    "h1:pOR56G7k8issXzaTGDLnoBqxVfQEm3wGuZK+Ur9j5m4=",
-    "zh:2038fdcb1186588717ee56e2cd08fd349a9aa301934c1b77c1477a184e5b6300",
-    "zh:285ea9324f416211e8be215408c53b3680c7a8f15bc7cd74f470425916c30c3a",
-    "zh:2e50bb729a0f7b9d37a925af33e939dadbd42a152686359aa6b8ab4e21dcb1cc",
-    "zh:41ac62e7c136e21859135d07058dff6d7a6e8f96c2459008b597551327897853",
-    "zh:5cbc769606a2a3835b1def03a59202c94ad2b602976043571d7a2b47b3f34743",
-    "zh:69f2df9b4461c352f56265b01c4c4e1e9cd0de5d792ed7ecf0c60ee16ce9d1e5",
-    "zh:7d64db7df3a7713f66673b4178de4626051e9881d8f8bd4d6fc48fc98bfb384f",
-    "zh:9528ba3e0966b0f08fd5686b388f6066fa5bd28a3b7d336f0e3fb52861f6428d",
-    "zh:aa4595c5510fdf72423e80f385799be733b438b35a20ce7676e3be620673765f",
-    "zh:b0c9c04ec87854047873e4be8b4ea04e7f2d364dc1b21ab1935d57e42a96765e",
-    "zh:c3ff3000c5d34c45e3387a253524ff7704fce06cd1b9a6bb553951a520e83540",
-    "zh:d0935229599de856ecf170d799db186eb3096fdad85809cde06381a9e9429b94",
-    "zh:d54ef8076354b2c5831270d6ffa2fc7fe7cd2ef55cf4d396fb3c6f8ea2915316",
-    "zh:f72473b6052ecf3107a09e419345a43bfaee30d99e0e42446a9933d4d29ec112",
-  ]
-}
diff --git a/terraform/main.tf b/terraform/main.tf
deleted file mode 100644
index c43105e28..000000000
--- a/terraform/main.tf
+++ /dev/null
@@ -1,50 +0,0 @@
-terraform {
-  backend "remote" {
-    organization = "ResonantGeoData"
-
-    workspaces {
-      name = "ResonantGeoData"
-    }
-  }
-}
-
-provider "aws" {
-  region              = "us-east-1"
-  allowed_account_ids = ["381864640041"]
-  # Must set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY envvars
-}
-
-provider "heroku" {
-  # Must set HEROKU_EMAIL, HEROKU_API_KEY envvars
-}
-
-data "heroku_team" "common" {
-  name = "kitware"
-}
-
-resource "aws_route53_zone" "common" {
-  name = "resonantgeodata.com"
-}
-
-module "django" {
-  source  = "girder/django/heroku"
-  version = "0.8.0"
-
-  project_slug     = "resonantgeodata"
-  subdomain_name   = "www"
-  heroku_team_name = data.heroku_team.common.name
-  route53_zone_id  = aws_route53_zone.common.id
-
-  # Optional overrides
-  # See https://registry.terraform.io/modules/girder/django/heroku/
-  # for other possible optional variables
-  additional_django_vars = {
-    DJANGO_SENTRY_DSN         = "https://b3dac135af6c42fea439998200656ca3@o267860.ingest.sentry.io/5458973"
-    DJANGO_SENTRY_ENVIRONMENT = "heroku-main"
-  }
-  # This defaults to 1, but may be changed
-  heroku_worker_dyno_quantity = 0
-  heroku_web_dyno_size        = "standard-2x"
-  heroku_worker_dyno_size     = "standard-1x"
-  heroku_postgresql_plan      = "hobby-basic"
-}
diff --git a/terraform/versions.tf b/terraform/versions.tf
deleted file mode 100644
index b85ee5c82..000000000
--- a/terraform/versions.tf
+++ /dev/null
@@ -1,12 +0,0 @@
-terraform {
-  required_version = ">= 0.13"
-
-  required_providers {
-    aws = {
-      source = "hashicorp/aws"
-    }
-    heroku = {
-      source = "heroku/heroku"
-    }
-  }
-}
diff --git a/terraform/watch.tf b/terraform/watch.tf
deleted file mode 100644
index ffac6ecf0..000000000
--- a/terraform/watch.tf
+++ /dev/null
@@ -1,29 +0,0 @@
-resource "aws_route53_record" "watch_acm_validation" {
-  name    = "_1665444c5f45134012652f2b83b63432.watch.resonantgeodata.com"
-  type    = "CNAME"
-  ttl     = "300"
-  zone_id = aws_route53_zone.common.id
-  records = ["_4e20c5eb5edd4ea6f2b5f5c66ce76409.bbfvkzsszw.acm-validations.aws."]
-}
-
-resource "aws_route53_record" "watch" {
-  name    = "watch.resonantgeodata.com"
-  type    = "CNAME"
-  ttl     = "300"
-  zone_id = aws_route53_zone.common.zone_id
-  records = ["rdg-elb2-955771211.us-west-2.elb.amazonaws.com."]
-}
-
-module "watch_smtp" {
-  source  = "girder/girder/aws//modules/smtp"
-  version = "0.8.0"
-
-  fqdn            = aws_route53_record.watch.fqdn
-  project_slug    = "watch"
-  route53_zone_id = aws_route53_zone.common.zone_id
-}
-
-output "watch_smtp_url" {
-  value     = "submission://${urlencode(module.watch_smtp.username)}:${urlencode(module.watch_smtp.password)}@${module.watch_smtp.host}:${module.watch_smtp.port}"
-  sensitive = true
-}
diff --git a/terraform/watch_dev.tf b/terraform/watch_dev.tf
deleted file mode 100644
index 8ccb9ed2c..000000000
--- a/terraform/watch_dev.tf
+++ /dev/null
@@ -1,35 +0,0 @@
-provider "aws" {
-  alias               = "aws_ohio"
-  region              = "us-east-2"
-  allowed_account_ids = ["381864640041"]
-  # Must set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY envvars
-}
-
-# Provisioned manually
-data "aws_instance" "watch_dev" {
-  provider = aws.aws_ohio
-
-  filter {
-    name   = "tag:Name"
-    values = ["abhishek-rgd"]
-  }
-}
-
-resource "aws_eip" "watch_dev" {
-  provider = aws.aws_ohio
-
-  instance = data.aws_instance.watch_dev.instance_id
-  tags = {
-    Name = "abhishek-rgd"
-  }
-}
-
-resource "aws_route53_record" "watch_dev" {
-  provider = aws.aws_ohio
-
-  name    = "watch-test"
-  type    = "A"
-  ttl     = "300"
-  zone_id = aws_route53_zone.common.zone_id
-  records = [aws_eip.watch_dev.public_ip]
-}
diff --git a/tox.ini b/tox.ini
index 99c6595e4..81ee6cc7b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,15 +1,28 @@
 [tox]
 envlist =
     lint,
-    test,
     check-migrations,
+    test-rgd,
+    test-rgd-3d,
+    test-rgd-fmv,
+    test-rgd-geometry,
+    test-rgd-imagery,
 
 [testenv]
 basepython=python3.8
-
-[testenv:lint]
 skipsdist = true
 skip_install = true
+setenv =
+    PIP_FIND_LINKS = https://girder.github.io/large_image_wheels
+passenv =
+    CELERY_BROKER_URL
+    DATABASE_URL
+    DATABASE_HOST
+    MINIO_STORAGE_ACCESS_KEY
+    MINIO_STORAGE_ENDPOINT
+    MINIO_STORAGE_SECRET_KEY
+
+[testenv:lint]
 deps =
     flake8
     flake8-black
@@ -31,8 +44,6 @@ commands =
     mypy {posargs:.}
 
 [testenv:format]
-skipsdist = true
-skip_install = true
 deps =
     black
     isort
@@ -40,46 +51,66 @@ commands =
     isort {posargs:.}
     black {posargs:.}
 
-[testenv:test]
-setenv =
-    PIP_FIND_LINKS = https://girder.github.io/large_image_wheels
-passenv =
-    DJANGO_CELERY_BROKER_URL
-    DJANGO_DATABASE_URL
-    DJANGO_MINIO_STORAGE_ACCESS_KEY
-    DJANGO_MINIO_STORAGE_ENDPOINT
-    DJANGO_MINIO_STORAGE_SECRET_KEY
-extras =
-    dev
-    worker
-    fmv
+[testenv:check-migrations]
 deps =
-    factory-boy
-    pytest
-    pytest-django
-    pytest-factoryboy
-    pytest-mock
-    pytest-cov
-    pytest-memprof
+    -e ./django-rgd
+    -e ./django-rgd-3d
+    -e ./django-rgd-fmv
+    -e ./django-rgd-geometry
+    -e ./django-rgd-imagery
+    -e ./example_project
+    -e ./testing-utils
 commands =
-    pytest --cov=rgd --cov=geodata {posargs}
-    coverage html -d {toxworkdir}/htmlcov
+    {envpython} ./example_project/manage.py makemigrations --check --dry-run
+
+[testenv:test-rgd]
+changedir = django-rgd/tests
+deps =
+    -r requirements.txt
+commands =
+    pytest --cov=rgd {posargs}
+    coverage html -d {toxworkdir}/htmlcov/django-rgd
     coverage xml -o {toxworkdir}/coverage.xml
 
-[testenv:check-migrations]
-setenv =
-    DJANGO_CONFIGURATION = TestingConfiguration
-    PIP_FIND_LINKS = https://girder.github.io/large_image_wheels
-passenv =
-    DJANGO_CELERY_BROKER_URL
-    DJANGO_DATABASE_URL
-    DJANGO_MINIO_STORAGE_ACCESS_KEY
-    DJANGO_MINIO_STORAGE_ENDPOINT
-    DJANGO_MINIO_STORAGE_SECRET_KEY
-extras =
-    dev
+[testenv:test-rgd-3d]
+changedir = django-rgd-3d/tests
+deps =
+    -r requirements.txt
+    -e ./django-rgd-3d[worker]
 commands =
-    {envpython} ./manage.py makemigrations --check --dry-run
+    pytest --cov=rgd --cov=rgd_3d {posargs}
+    coverage html -d {toxworkdir}/htmlcov/django-rgd-3d
+    coverage xml -o {toxworkdir}/coverage.xml
+
+[testenv:test-rgd-fmv]
+changedir = django-rgd-fmv/tests
+deps =
+    -r requirements.txt
+    -e ./django-rgd-fmv[worker]
+commands =
+    pytest --cov=rgd --cov=rgd_fmv {posargs}
+    coverage html -d {toxworkdir}/htmlcov/django-rgd-fmv
+    coverage xml -o {toxworkdir}/coverage.xml
+
+[testenv:test-rgd-geometry]
+changedir = django-rgd-geometry/tests
+deps =
+    -r requirements.txt
+    -e ./django-rgd-geometry[worker]
+commands =
+    pytest --cov=rgd --cov=rgd_geometry {posargs}
+    coverage html -d {toxworkdir}/htmlcov/django-rgd-geometry
+    coverage xml -o {toxworkdir}/coverage.xml
+
+[testenv:test-rgd-imagery]
+changedir = django-rgd-imagery/tests
+deps =
+    -r requirements.txt
+    -e ./django-rgd-imagery[worker]
+commands =
+    pytest --cov=rgd --cov=rgd_imagery {posargs}
+    coverage html -d {toxworkdir}/htmlcov/django-rgd-imagery
+    coverage xml -o {toxworkdir}/coverage.xml
 
 [flake8]
 max-line-length = 2048
@@ -102,8 +133,7 @@ ignore =
     D10,
 
 [pytest]
-DJANGO_SETTINGS_MODULE = rgd.settings
-DJANGO_CONFIGURATION = TestingConfiguration
+DJANGO_SETTINGS_MODULE = test_project.settings
 addopts = --strict-markers --showlocals --verbose --durations=0
 filterwarnings =
     ignore:.*default_app_config*.:django.utils.deprecation.RemovedInDjango41Warning
diff --git a/version.py b/version.py
new file mode 100644
index 000000000..9ff03268d
--- /dev/null
+++ b/version.py
@@ -0,0 +1,21 @@
+"""Version info for ResonantGeoData.
+
+On the default branch, use 'dev0' to denote a development version.
+For example:
+
+    version_info = 0, 2, 'dev0'
+
+---
+
+When generating pre-release wheels, use '0rcN', for example:
+
+    version_info = 0, 2, '0rc1'
+
+Which denotes the first release candidate.
+
+"""
+# major, minor, patch
+version_info = 0, 2, '0rc8'
+
+# Nice string for the version
+__version__ = '.'.join(map(str, version_info))


Test patch:
diff --git a/rgd/geodata/migrations/__init__.py b/django-rgd-3d/tests/__init__.py
similarity index 100%
rename from rgd/geodata/migrations/__init__.py
rename to django-rgd-3d/tests/__init__.py
diff --git a/django-rgd-3d/tests/conftest.py b/django-rgd-3d/tests/conftest.py
new file mode 100644
index 000000000..65b0bc358
--- /dev/null
+++ b/django-rgd-3d/tests/conftest.py
@@ -0,0 +1,10 @@
+import inspect
+
+import factory
+from pytest_factoryboy import register
+from rgd_testing_utils import factories
+from rgd_testing_utils.data_fixtures import *  # noqa
+
+for _, fac in inspect.getmembers(factories):
+    if inspect.isclass(fac) and issubclass(fac, factory.Factory):
+        register(fac)
diff --git a/django-rgd-3d/tests/factories.py b/django-rgd-3d/tests/factories.py
new file mode 100644
index 000000000..6f1d4fe0a
--- /dev/null
+++ b/django-rgd-3d/tests/factories.py
@@ -0,0 +1,11 @@
+import factory
+import factory.django
+from rgd_3d import models
+from rgd_testing_utils.factories import ChecksumFileFactory
+
+
+class PointCloudFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = models.PointCloud
+
+    file = factory.SubFactory(ChecksumFileFactory)
diff --git a/django-rgd-3d/tests/manage.py b/django-rgd-3d/tests/manage.py
new file mode 100644
index 000000000..de56cdfa5
--- /dev/null
+++ b/django-rgd-3d/tests/manage.py
@@ -0,0 +1,2 @@
+# This file is a marker for pytest-django, to make Django paths here importable
+# https://pytest-django.readthedocs.io/en/latest/managing_python_path.html#automatic-looking-for-of-django-projects
diff --git a/rgd/geodata/tests/test_point_cloud.py b/django-rgd-3d/tests/test_point_cloud.py
similarity index 62%
rename from rgd/geodata/tests/test_point_cloud.py
rename to django-rgd-3d/tests/test_point_cloud.py
index ca41c1022..047ec15cc 100644
--- a/rgd/geodata/tests/test_point_cloud.py
+++ b/django-rgd-3d/tests/test_point_cloud.py
@@ -1,8 +1,7 @@
 import pytest
-
-from rgd.geodata import models
-from rgd.geodata.datastore import datastore
-from rgd.geodata.models.threed.etl import read_point_cloud_file
+from rgd.datastore import datastore
+from rgd_3d import models
+from rgd_3d.tasks.etl import read_point_cloud_file
 
 from . import factories
 
@@ -15,11 +14,11 @@
 )
 @pytest.mark.django_db(transaction=True)
 def test_point_cloud_etl(sample_file):
-    pc_file = factories.PointCloudFileFactory(
+    pc_file = factories.PointCloudFactory(
         file__file__filename=sample_file,
         file__file__from_path=datastore.fetch(sample_file),
     )
-    entry = models.PointCloudEntry.objects.filter(source=pc_file).first()
+    entry = models.PointCloudMeta.objects.filter(source=pc_file).first()
     assert entry.vtp_data is not None
     # Testing that we can repopulate a point cloud entry
     read_point_cloud_file(pc_file.id)
diff --git a/rgd/geodata/tests/__init__.py b/django-rgd-3d/tests/test_project/__init__.py
similarity index 100%
rename from rgd/geodata/tests/__init__.py
rename to django-rgd-3d/tests/test_project/__init__.py
diff --git a/django-rgd-3d/tests/test_project/settings.py b/django-rgd-3d/tests/test_project/settings.py
new file mode 100644
index 000000000..3d2466212
--- /dev/null
+++ b/django-rgd-3d/tests/test_project/settings.py
@@ -0,0 +1,10 @@
+from rgd_testing_utils.settings import *  # noqa
+
+INSTALLED_APPS += [  # noqa
+    'rgd_3d',
+]
+
+ROOT_URLCONF = 'test_project.urls'
+
+CELERY_TASK_ALWAYS_EAGER = True
+CELERY_TASK_EAGER_PROPAGATES = True
diff --git a/django-rgd-3d/tests/test_project/urls.py b/django-rgd-3d/tests/test_project/urls.py
new file mode 100644
index 000000000..48077849e
--- /dev/null
+++ b/django-rgd-3d/tests/test_project/urls.py
@@ -0,0 +1,7 @@
+from django.urls import include, path
+
+urlpatterns = [
+    # Make this distinct from typical production values, to ensure it works dynamically
+    path('rgd_test/', include('rgd.urls')),
+    path('rgd_3d_test/', include('rgd_3d.urls')),
+]
diff --git a/django-rgd-fmv/tests/__init__.py b/django-rgd-fmv/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/django-rgd-fmv/tests/conftest.py b/django-rgd-fmv/tests/conftest.py
new file mode 100644
index 000000000..e2ee8df40
--- /dev/null
+++ b/django-rgd-fmv/tests/conftest.py
@@ -0,0 +1,17 @@
+import inspect
+
+import factory
+from pytest_factoryboy import register
+from rgd_testing_utils import factories as tfactories
+from rgd_testing_utils.data_fixtures import *  # noqa
+
+from . import factories
+from .data_fixtures import *  # noqa
+
+for _, fac in inspect.getmembers(tfactories):
+    if inspect.isclass(fac) and issubclass(fac, factory.Factory):
+        register(fac)
+
+for _, fac in inspect.getmembers(factories):
+    if inspect.isclass(fac) and issubclass(fac, factory.Factory):
+        register(fac)
diff --git a/django-rgd-fmv/tests/data_fixtures.py b/django-rgd-fmv/tests/data_fixtures.py
new file mode 100644
index 000000000..1311831e3
--- /dev/null
+++ b/django-rgd-fmv/tests/data_fixtures.py
@@ -0,0 +1,22 @@
+import pytest
+from rgd.datastore import datastore
+
+from . import factories
+
+
+@pytest.fixture
+def fmv_klv_file():
+    return factories.FMVFactory(
+        klv_file__filename='subset_metadata.klv',
+        klv_file__from_path=datastore.fetch('subset_metadata.klv'),
+    )
+
+
+@pytest.fixture
+def fmv_video_file():
+    return factories.FMVFactory(
+        file__file__filename='test_fmv.ts',
+        file__file__from_path=datastore.fetch('test_fmv.ts'),
+        klv_file=None,
+        web_video_file=None,
+    )
diff --git a/django-rgd-fmv/tests/factories.py b/django-rgd-fmv/tests/factories.py
new file mode 100644
index 000000000..769536fbe
--- /dev/null
+++ b/django-rgd-fmv/tests/factories.py
@@ -0,0 +1,21 @@
+import factory
+import factory.django
+from rgd_fmv import models
+from rgd_testing_utils.factories import ChecksumFileFactory
+
+
+class FMVFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = models.FMV
+
+    file = factory.SubFactory(ChecksumFileFactory)
+    klv_file = factory.django.FileField(filename='sample.klv')
+    web_video_file = factory.django.FileField(filename='sample.mp4')
+    frame_rate = 30
+
+    # If we have an on_commit or post_save method that modifies the model, we
+    # need to refresh it afterwards.
+    @classmethod
+    def _after_postgeneration(cls, instance, *args, **kwargs):
+        super()._after_postgeneration(instance, *args, **kwargs)
+        instance.refresh_from_db()
diff --git a/django-rgd-fmv/tests/manage.py b/django-rgd-fmv/tests/manage.py
new file mode 100644
index 000000000..de56cdfa5
--- /dev/null
+++ b/django-rgd-fmv/tests/manage.py
@@ -0,0 +1,2 @@
+# This file is a marker for pytest-django, to make Django paths here importable
+# https://pytest-django.readthedocs.io/en/latest/managing_python_path.html#automatic-looking-for-of-django-projects
diff --git a/django-rgd-fmv/tests/test_demo_data.py b/django-rgd-fmv/tests/test_demo_data.py
new file mode 100644
index 000000000..74d946331
--- /dev/null
+++ b/django-rgd-fmv/tests/test_demo_data.py
@@ -0,0 +1,25 @@
+from io import StringIO
+
+from django.core.management import call_command
+import pytest
+
+# from rgd_fmv.management.commands import demo_data
+
+
+def _call_command(name, *args, **kwargs):
+    out = StringIO()
+    call_command(
+        name,
+        *args,
+        stdout=out,
+        stderr=StringIO(),
+        **kwargs,
+    )
+    return out.getvalue()
+
+
+@pytest.mark.skip
+@pytest.mark.django_db(transaction=True)
+def test_demo_command_wasabi():
+    out = _call_command('wasabi')
+    assert out  # == demo_data.SUCCESS_MSG + '\n'
diff --git a/rgd/geodata/tests/test_fmv.py b/django-rgd-fmv/tests/test_fmv.py
similarity index 72%
rename from rgd/geodata/tests/test_fmv.py
rename to django-rgd-fmv/tests/test_fmv.py
index e8dc47ceb..79fecac3a 100644
--- a/rgd/geodata/tests/test_fmv.py
+++ b/django-rgd-fmv/tests/test_fmv.py
@@ -1,9 +1,8 @@
 import os
 
 import pytest
-
-from rgd.geodata.models.fmv import FMVEntry
-from rgd.geodata.models.mixins import Status
+from rgd.models.mixins import Status
+from rgd_fmv.models import FMVMeta
 
 NO_KWIVER = os.environ.get('NO_KWIVER', False)
 
@@ -11,8 +10,8 @@
 @pytest.mark.django_db(transaction=True)
 def test_populate_fmv_entry_from_klv_file(fmv_klv_file):
     # Since we provide the KLV file and the factory provides a dummpy MP4 file,
-    #   the ETL routine will skip over those parts and just generate the FMVEntry
-    fmv_entry = FMVEntry.objects.filter(fmv_file=fmv_klv_file).first()
+    #   the ETL routine will skip over those parts and just generate the FMVMeta
+    fmv_entry = FMVMeta.objects.filter(fmv_file=fmv_klv_file).first()
     assert fmv_entry.ground_frames is not None
 
 
@@ -20,5 +19,5 @@ def test_populate_fmv_entry_from_klv_file(fmv_klv_file):
 @pytest.mark.django_db(transaction=True)
 def test_full_fmv_etl(fmv_video_file):
     assert fmv_video_file.status == Status.SUCCEEDED, fmv_video_file.failure_reason
-    fmv_entry = FMVEntry.objects.get(fmv_file=fmv_video_file)
+    fmv_entry = FMVMeta.objects.get(fmv_file=fmv_video_file)
     assert fmv_entry.ground_frames is not None
diff --git a/django-rgd-fmv/tests/test_project/__init__.py b/django-rgd-fmv/tests/test_project/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/django-rgd-fmv/tests/test_project/settings.py b/django-rgd-fmv/tests/test_project/settings.py
new file mode 100644
index 000000000..f6c78a67b
--- /dev/null
+++ b/django-rgd-fmv/tests/test_project/settings.py
@@ -0,0 +1,10 @@
+from rgd_testing_utils.settings import *  # noqa
+
+INSTALLED_APPS += [  # noqa
+    'rgd_fmv',
+]
+
+ROOT_URLCONF = 'test_project.urls'
+
+CELERY_TASK_ALWAYS_EAGER = True
+CELERY_TASK_EAGER_PROPAGATES = True
diff --git a/django-rgd-fmv/tests/test_project/urls.py b/django-rgd-fmv/tests/test_project/urls.py
new file mode 100644
index 000000000..058f13517
--- /dev/null
+++ b/django-rgd-fmv/tests/test_project/urls.py
@@ -0,0 +1,7 @@
+from django.urls import include, path
+
+urlpatterns = [
+    # Make this distinct from typical production values, to ensure it works dynamically
+    path('rgd_test/', include('rgd.urls')),
+    path('rgd_fmv_test/', include('rgd_fmv.urls')),
+]
diff --git a/django-rgd-geometry/tests/__init__.py b/django-rgd-geometry/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/django-rgd-geometry/tests/conftest.py b/django-rgd-geometry/tests/conftest.py
new file mode 100644
index 000000000..e6e5803d0
--- /dev/null
+++ b/django-rgd-geometry/tests/conftest.py
@@ -0,0 +1,16 @@
+import inspect
+
+import factory
+from pytest_factoryboy import register
+from rgd_testing_utils import factories as tfactories
+from rgd_testing_utils.data_fixtures import *  # noqa
+
+from . import factories
+
+for _, fac in inspect.getmembers(tfactories):
+    if inspect.isclass(fac) and issubclass(fac, factory.Factory):
+        register(fac)
+
+for _, fac in inspect.getmembers(factories):
+    if inspect.isclass(fac) and issubclass(fac, factory.Factory):
+        register(fac)
diff --git a/django-rgd-geometry/tests/factories.py b/django-rgd-geometry/tests/factories.py
new file mode 100644
index 000000000..55fc11eb8
--- /dev/null
+++ b/django-rgd-geometry/tests/factories.py
@@ -0,0 +1,11 @@
+import factory
+import factory.django
+from rgd_geometry import models
+from rgd_testing_utils.factories import ChecksumFileFactory
+
+
+class GeometryArchiveFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = models.GeometryArchive
+
+    file = factory.SubFactory(ChecksumFileFactory)
diff --git a/django-rgd-geometry/tests/manage.py b/django-rgd-geometry/tests/manage.py
new file mode 100644
index 000000000..de56cdfa5
--- /dev/null
+++ b/django-rgd-geometry/tests/manage.py
@@ -0,0 +1,2 @@
+# This file is a marker for pytest-django, to make Django paths here importable
+# https://pytest-django.readthedocs.io/en/latest/managing_python_path.html#automatic-looking-for-of-django-projects
diff --git a/rgd/geodata/tests/test_geometry.py b/django-rgd-geometry/tests/test_geometry.py
similarity index 65%
rename from rgd/geodata/tests/test_geometry.py
rename to django-rgd-geometry/tests/test_geometry.py
index d84bec04a..6803fb37b 100644
--- a/rgd/geodata/tests/test_geometry.py
+++ b/django-rgd-geometry/tests/test_geometry.py
@@ -1,8 +1,7 @@
 import pytest
-
-from rgd.geodata import models
-from rgd.geodata.datastore import datastore
-from rgd.geodata.models.geometry.etl import read_geometry_archive
+from rgd.datastore import datastore
+from rgd_geometry import models
+from rgd_geometry.tasks.etl import read_geometry_archive
 
 from . import factories
 
@@ -17,9 +16,9 @@ def test_geometry_etl(sample_file):
         file__file__filename=sample_file,
         file__file__from_path=datastore.fetch(sample_file),
     )
-    entry = models.GeometryEntry.objects.filter(geometry_archive=geom_archive).first()
+    entry = models.Geometry.objects.filter(geometry_archive=geom_archive).first()
     assert entry.data is not None
     # Testing that we can repopulate a geometry archive again
     read_geometry_archive(geom_archive.id)
     # test the field file validator
-    models.geometry.validate_archive(geom_archive.file.file)
+    models.validate_archive(geom_archive.file.file)
diff --git a/django-rgd-geometry/tests/test_project/__init__.py b/django-rgd-geometry/tests/test_project/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/django-rgd-geometry/tests/test_project/settings.py b/django-rgd-geometry/tests/test_project/settings.py
new file mode 100644
index 000000000..9985be106
--- /dev/null
+++ b/django-rgd-geometry/tests/test_project/settings.py
@@ -0,0 +1,10 @@
+from rgd_testing_utils.settings import *  # noqa
+
+INSTALLED_APPS += [  # noqa
+    'rgd_geometry',
+]
+
+ROOT_URLCONF = 'test_project.urls'
+
+CELERY_TASK_ALWAYS_EAGER = True
+CELERY_TASK_EAGER_PROPAGATES = True
diff --git a/django-rgd-geometry/tests/test_project/urls.py b/django-rgd-geometry/tests/test_project/urls.py
new file mode 100644
index 000000000..162f3e3d5
--- /dev/null
+++ b/django-rgd-geometry/tests/test_project/urls.py
@@ -0,0 +1,7 @@
+from django.urls import include, path
+
+urlpatterns = [
+    # Make this distinct from typical production values, to ensure it works dynamically
+    path('rgd_test/', include('rgd.urls')),
+    path('rgd_geometry_test/', include('rgd_geometry.urls')),
+]
diff --git a/django-rgd-imagery/tests/__init__.py b/django-rgd-imagery/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/django-rgd-imagery/tests/conftest.py b/django-rgd-imagery/tests/conftest.py
new file mode 100644
index 000000000..94c8b82bf
--- /dev/null
+++ b/django-rgd-imagery/tests/conftest.py
@@ -0,0 +1,18 @@
+import inspect
+
+import factory
+from pytest_factoryboy import register
+from rgd_testing_utils import factories as tfactories
+from rgd_testing_utils.api_fixtures import *  # noqa
+from rgd_testing_utils.data_fixtures import *  # noqa
+
+from . import factories
+from .data_fixtures import *  # noqa
+
+for _, fac in inspect.getmembers(tfactories):
+    if inspect.isclass(fac) and issubclass(fac, factory.Factory):
+        register(fac)
+
+for _, fac in inspect.getmembers(factories):
+    if inspect.isclass(fac) and issubclass(fac, factory.Factory):
+        register(fac)
diff --git a/rgd/geodata/tests/data_fixtures.py b/django-rgd-imagery/tests/data_fixtures.py
similarity index 60%
rename from rgd/geodata/tests/data_fixtures.py
rename to django-rgd-imagery/tests/data_fixtures.py
index 82586e7da..86d38b097 100644
--- a/rgd/geodata/tests/data_fixtures.py
+++ b/django-rgd-imagery/tests/data_fixtures.py
@@ -1,90 +1,75 @@
 import pytest
-
-from rgd.geodata import models
-from rgd.geodata.datastore import datastore
+from rgd.datastore import datastore
+from rgd.models import FileSourceType
 
 from . import factories
 
 
-@pytest.fixture
-def checksum_file():
-    return factories.ChecksumFileFactory()
-
-
-@pytest.fixture
-def checksum_file_url():
-    return factories.ChecksumFileFactory(
-        file=None,
-        url=datastore.get_url('stars.png'),
-        type=models.FileSourceType.URL,
-    )
-
-
 @pytest.fixture
 def geotiff_image_entry():
-    imagefile = factories.ImageFileFactory(
+    image = factories.ImageFactory(
         file__file__filename='paris_france_10.tiff',
         file__file__from_path=datastore.fetch('paris_france_10.tiff'),
     )
-    return imagefile.imageentry
+    return image
 
 
 @pytest.fixture
 def astro_image():
     name = 'astro.png'
-    imagefile = factories.ImageFileFactory(
+    image = factories.ImageFactory(
         file__file__filename=name,
         file__file__from_path=datastore.fetch(name),
     )
-    return imagefile.imageentry
+    return image
 
 
 @pytest.fixture
 def sample_raster_a():
-    imagefile = factories.ImageFileFactory(
+    image = factories.ImageFactory(
         file__file__filename='20091021202517-01000100-VIS_0001.ntf',
         file__file__from_path=datastore.fetch('20091021202517-01000100-VIS_0001.ntf'),
     )
     image_set = factories.ImageSetFactory(
-        images=[imagefile.imageentry.id],
+        images=[image.id],
     )
-    raster = factories.RasterEntryFactory(
+    raster = factories.RasterFactory(
         name='20091021202517-01000100-VIS_0001.ntf',
         image_set=image_set,
     )
-    return raster.rastermetaentry
+    return raster.rastermeta
 
 
 @pytest.fixture
 def sample_raster_b():
-    imagefile = factories.ImageFileFactory(
+    image = factories.ImageFactory(
         file__file__filename='cclc_schu_100.tif',
         file__file__from_path=datastore.fetch('cclc_schu_100.tif'),
     )
     image_set = factories.ImageSetFactory(
-        images=[imagefile.imageentry.id],
+        images=[image.id],
     )
-    raster = factories.RasterEntryFactory(
+    raster = factories.RasterFactory(
         name='cclc_schu_100.tif',
         image_set=image_set,
     )
-    return raster.rastermetaentry
+    return raster.rastermeta
 
 
 @pytest.fixture
 def sample_raster_c():
-    imagefile = factories.ImageFileFactory(
+    image = factories.ImageFactory(
         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],
+        images=[image.id],
     )
-    raster = factories.RasterEntryFactory(
+    raster = factories.RasterFactory(
         name='RomanColosseum_WV2mulitband_10.tif',
         image_set=image_set,
     )
-    return raster.rastermetaentry
+    return raster.rastermeta
 
 
 @pytest.fixture
@@ -96,21 +81,48 @@ def sample_raster_multi():
         'LC08_L1TP_034032_20200429_20200509_01_T1_sr_band3.tif',
     ]
     images = [
-        factories.ImageFileFactory(
+        factories.ImageFactory(
             file__file__filename=landsat_files[0],
             file__file__from_path=datastore.fetch(f),
-        ).imageentry
+        )
         for f in landsat_files
     ]
     image_set = factories.ImageSetFactory(
         images=images,
     )
-    # Create a RasterEntry from the three band image entries
-    raster = factories.RasterEntryFactory(
+    # Create a Raster from the three band image entries
+    raster = factories.RasterFactory(
+        name='Multi File Test',
+        image_set=image_set,
+    )
+    return raster.rastermeta
+
+
+@pytest.fixture
+def sample_raster_url_single():
+    images = [
+        factories.ImageFactory(
+            file__type=FileSourceType.URL,
+            file__file=None,
+            file__url=datastore.get_url('LC08_L1TP_034032_20200429_20200509_01_T1_sr_band1.tif'),
+        )
+    ]
+    image_set = factories.ImageSetFactory(
+        images=images,
+    )
+    anc = factories.ChecksumFileFactory(
+        file=None,
+        url=datastore.get_url('stars.png'),
+        type=FileSourceType.URL,
+    )
+    # Create a Raster from the three band image entries
+    raster = factories.RasterFactory(
         name='Multi File Test',
         image_set=image_set,
     )
-    return raster.rastermetaentry
+    raster.ancillary_files.add(anc)
+    raster.save()
+    return raster.rastermeta
 
 
 @pytest.fixture
@@ -121,11 +133,11 @@ def sample_raster_url():
         'LC08_L1TP_034032_20200429_20200509_01_T1_sr_band3.tif',
     ]
     images = [
-        factories.ImageFileFactory(
-            file__type=models.FileSourceType.URL,
+        factories.ImageFactory(
+            file__type=FileSourceType.URL,
             file__file=None,
             file__url=datastore.get_url(f),
-        ).imageentry
+        )
         for f in landsat_files
     ]
     image_set = factories.ImageSetFactory(
@@ -134,41 +146,23 @@ def sample_raster_url():
     anc = factories.ChecksumFileFactory(
         file=None,
         url=datastore.get_url('stars.png'),
-        type=models.FileSourceType.URL,
+        type=FileSourceType.URL,
     )
-    # Create a RasterEntry from the three band image entries
-    raster = factories.RasterEntryFactory(
+    # Create a Raster from the three band image entries
+    raster = factories.RasterFactory(
         name='Multi File Test',
         image_set=image_set,
     )
     raster.ancillary_files.add(anc)
     raster.save()
-    return raster.rastermetaentry
-
-
-@pytest.fixture
-def fmv_klv_file():
-    return factories.FMVFileFactory(
-        klv_file__filename='subset_metadata.klv',
-        klv_file__from_path=datastore.fetch('subset_metadata.klv'),
-    )
-
-
-@pytest.fixture
-def fmv_video_file():
-    return factories.FMVFileFactory(
-        file__file__filename='test_fmv.ts',
-        file__file__from_path=datastore.fetch('test_fmv.ts'),
-        klv_file=None,
-        web_video_file=None,
-    )
+    return raster.rastermeta
 
 
 @pytest.fixture
 def elevation():
     name = 'Elevation.tif'
-    image_file = factories.ImageFileFactory(
+    image = factories.ImageFactory(
         file__file__filename=name,
         file__file__from_path=datastore.fetch(name),
     )
-    return models.ImageEntry.objects.get(image_file=image_file)
+    return image
diff --git a/django-rgd-imagery/tests/factories.py b/django-rgd-imagery/tests/factories.py
new file mode 100644
index 000000000..5fb733e54
--- /dev/null
+++ b/django-rgd-imagery/tests/factories.py
@@ -0,0 +1,57 @@
+import factory
+import factory.django
+from rgd_imagery import models
+from rgd_testing_utils.factories import ChecksumFileFactory
+
+
+class ImageFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = models.Image
+
+    file = factory.SubFactory(ChecksumFileFactory)
+
+
+class ImageSetFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = models.ImageSet
+
+    name = factory.Faker('sentence', nb_words=2)
+
+    @factory.post_generation
+    def images(self, create, extracted, **kwargs):
+        if not create:
+            return
+        if extracted:
+            for image in extracted:
+                self.images.add(image)
+
+
+class RasterFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = models.Raster
+
+    name = factory.Faker('sentence', nb_words=2)
+    image_set = factory.SubFactory(ImageSetFactory)
+
+    # If we have an on_commit or post_save method that modifies the model, we
+    # need to refresh it afterwards.
+    @classmethod
+    def _after_postgeneration(cls, instance, *args, **kwargs):
+        super()._after_postgeneration(instance, *args, **kwargs)
+        instance.refresh_from_db()
+
+
+class KWCOCOArchiveFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = models.KWCOCOArchive
+
+    name = factory.Faker('sentence', nb_words=2)
+    spec_file = factory.SubFactory(ChecksumFileFactory)
+    image_archive = factory.SubFactory(ChecksumFileFactory)
+
+    # If we have an on_commit or post_save method that modifies the model, we
+    # need to refresh it afterwards.
+    @classmethod
+    def _after_postgeneration(cls, instance, *args, **kwargs):
+        super()._after_postgeneration(instance, *args, **kwargs)
+        instance.refresh_from_db()
diff --git a/django-rgd-imagery/tests/manage.py b/django-rgd-imagery/tests/manage.py
new file mode 100644
index 000000000..de56cdfa5
--- /dev/null
+++ b/django-rgd-imagery/tests/manage.py
@@ -0,0 +1,2 @@
+# This file is a marker for pytest-django, to make Django paths here importable
+# https://pytest-django.readthedocs.io/en/latest/managing_python_path.html#automatic-looking-for-of-django-projects
diff --git a/django-rgd-imagery/tests/test_demo_data.py b/django-rgd-imagery/tests/test_demo_data.py
new file mode 100644
index 000000000..c96ce0769
--- /dev/null
+++ b/django-rgd-imagery/tests/test_demo_data.py
@@ -0,0 +1,25 @@
+from io import StringIO
+
+from django.core.management import call_command
+import pytest
+
+# from rgd_imagery.management.commands import demo_data
+
+
+def _call_command(name, *args, **kwargs):
+    out = StringIO()
+    call_command(
+        name,
+        *args,
+        stdout=out,
+        stderr=StringIO(),
+        **kwargs,
+    )
+    return out.getvalue()
+
+
+@pytest.mark.skip
+@pytest.mark.django_db(transaction=True)
+def test_demo_command_landsat():
+    out = _call_command('s3_landsat', count=1)
+    assert out  # == demo_data.SUCCESS_MSG.replace('demo', 'landsat') + '\n'
diff --git a/django-rgd-imagery/tests/test_filters.py b/django-rgd-imagery/tests/test_filters.py
new file mode 100644
index 000000000..54f1d827a
--- /dev/null
+++ b/django-rgd-imagery/tests/test_filters.py
@@ -0,0 +1,17 @@
+import pytest
+from rgd_imagery import models
+from rgd_imagery.filters import RasterMetaFilter
+
+
+@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.RasterMeta.objects.count() == 2
+    filterset = RasterMetaFilter(
+        data={
+            'num_bands_max': 2,
+        }
+    )
+    assert filterset.is_valid()
+    qs = filterset.filter_queryset(models.RasterMeta.objects.all())
+    assert qs.count() == 1
diff --git a/rgd/geodata/tests/test_imagery.py b/django-rgd-imagery/tests/test_imagery.py
similarity index 50%
rename from rgd/geodata/tests/test_imagery.py
rename to django-rgd-imagery/tests/test_imagery.py
index 49b8997e7..09b55c97f 100644
--- a/rgd/geodata/tests/test_imagery.py
+++ b/django-rgd-imagery/tests/test_imagery.py
@@ -1,10 +1,7 @@
 import pytest
-
-from rgd.geodata.datastore import datastore
-from rgd.geodata.models.common import FileSourceType
-from rgd.geodata.models.imagery import ImageFile
-from rgd.geodata.models.imagery.annotation import RLESegmentation
-from rgd.geodata.models.imagery.etl import populate_raster_footprint, read_image_file
+from rgd.datastore import datastore
+from rgd.models import FileSourceType
+from rgd_imagery.tasks.etl import load_image, populate_raster_footprint
 
 from . import factories
 
@@ -23,14 +20,14 @@
 
 
 def _make_raster_from_datastore(name):
-    imagefile = factories.ImageFileFactory(
+    image = factories.ImageFactory(
         file__file__filename=name,
         file__file__from_path=datastore.fetch(name),
     )
     image_set = factories.ImageSetFactory(
-        images=[imagefile.imageentry.id],
+        images=[image.id],
     )
-    raster = factories.RasterEntryFactory(
+    raster = factories.RasterFactory(
         name=name,
         image_set=image_set,
     )
@@ -41,7 +38,7 @@ def _make_raster_from_datastore(name):
 @pytest.mark.django_db(transaction=True)
 def test_imagefile_to_rasterentry_centroids(testfile):
     raster = _make_raster_from_datastore(testfile['name'])
-    meta = raster.rastermetaentry
+    meta = raster.rastermeta
     centroid = meta.outline.centroid
     assert centroid.x == pytest.approx(testfile['centroid']['x'], abs=TOLERANCE)
     assert centroid.y == pytest.approx(testfile['centroid']['y'], abs=TOLERANCE)
@@ -50,22 +47,22 @@ def test_imagefile_to_rasterentry_centroids(testfile):
 @pytest.mark.parametrize('testfile', SampleFiles)
 @pytest.mark.django_db(transaction=True)
 def test_imagefile_url_to_rasterentry_centroids(testfile):
-    imagefile = factories.ImageFileFactory(
+    image = factories.ImageFactory(
         file__file=None,
         file__url=datastore.get_url(testfile['name']),
         file__type=FileSourceType.URL,
     )
     image_set = factories.ImageSetFactory(
-        images=[imagefile.imageentry.id],
+        images=[image.id],
     )
-    raster = factories.RasterEntryFactory(
+    raster = factories.RasterFactory(
         name=testfile['name'],
         image_set=image_set,
     )
-    meta = raster.rastermetaentry
+    meta = raster.rastermeta
     centroid = meta.outline.centroid
     # Sanity check
-    assert imagefile.file.type == FileSourceType.URL
+    assert image.file.type == FileSourceType.URL
     # Make sure the file contents were read correctly
     assert centroid.x == pytest.approx(testfile['centroid']['x'], abs=TOLERANCE)
     assert centroid.y == pytest.approx(testfile['centroid']['y'], abs=TOLERANCE)
@@ -75,12 +72,12 @@ def test_imagefile_url_to_rasterentry_centroids(testfile):
 def test_repopulate_image_entry():
     """Only test with single image file."""
     testfile = SampleFiles[0]
-    imagefile = factories.ImageFileFactory(
+    imagefile = factories.ImageFactory(
         file__file__filename=testfile['name'],
         file__file__from_path=datastore.fetch(testfile['name']),
     )
     # Testing that we can repopulate an image entry
-    read_image_file(imagefile.id)
+    load_image(imagefile.id)
 
 
 @pytest.mark.django_db(transaction=True)
@@ -101,74 +98,6 @@ def test_multi_file_raster(sample_raster_multi):
 def test_raster_footprint(name):
     raster = _make_raster_from_datastore(name)
     populate_raster_footprint(raster.id)
-    meta = raster.rastermetaentry
+    meta = raster.rastermeta
     assert meta.footprint
     assert meta.footprint != meta.outline
-
-
-def _run_kwcoco_import(demo):
-    f_image_archive = demo['archive']
-    f_spec_file = demo['spec']
-
-    return factories.KWCOCOArchiveFactory(
-        image_archive__file__filename=f_image_archive,
-        image_archive__file__from_path=datastore.fetch(f_image_archive),
-        spec_file__file__filename=f_spec_file,
-        spec_file__file__from_path=datastore.fetch(f_spec_file),
-    )
-
-
-@pytest.mark.django_db(transaction=True)
-def test_kwcoco_basic_demo():
-    demo = {
-        'archive': 'demodata.zip',
-        'spec': 'demo.kwcoco.json',
-        'n_images': 3,
-        'n_annotations': 11,
-    }
-
-    kwds = _run_kwcoco_import(demo)
-    assert kwds.image_set.count == demo['n_images']
-    annotations = [a for anns in kwds.image_set.get_all_annotations().values() for a in anns]
-    assert len(annotations) == demo['n_annotations']
-    # Trigger save event and make sure original images were deleted
-    image_file_ids = [im.image_file.id for im in kwds.image_set.images.all()]
-    kwds.save()
-    for id in image_file_ids:
-        with pytest.raises(ImageFile.DoesNotExist):
-            ImageFile.objects.get(id=id)
-    # Now do same for delete
-    image_file_ids = [im.image_file.id for im in kwds.image_set.images.all()]
-    kwds.delete()
-    for id in image_file_ids:
-        with pytest.raises(ImageFile.DoesNotExist):
-            ImageFile.objects.get(id=id)
-
-
-@pytest.mark.django_db(transaction=True)
-def test_kwcoco_rle_demo():
-    demo = {
-        'archive': 'demo_rle.zip',
-        'spec': 'demo_rle.kwcoco.json',
-        'n_images': 2,
-        'n_annotations': 15,
-    }
-
-    kwds = _run_kwcoco_import(demo)
-    assert kwds.image_set.count == demo['n_images']
-    annotations = [a for anns in kwds.image_set.get_all_annotations().values() for a in anns]
-    assert len(annotations) == demo['n_annotations']
-
-    # Test the RLESegmentation methods
-    seg = RLESegmentation.objects.all().first()
-    image = seg.annotation.image
-    assert seg.width == image.width
-    assert seg.height == image.height
-
-    rle = seg.to_rle()
-    assert 'counts' in rle
-    assert 'size' in rle
-    assert rle['size'] == [seg.height, seg.width]
-
-    mask = seg.to_mask()
-    assert mask.shape == (seg.height, seg.width)
diff --git a/django-rgd-imagery/tests/test_kwcoco.py b/django-rgd-imagery/tests/test_kwcoco.py
new file mode 100644
index 000000000..c692bf812
--- /dev/null
+++ b/django-rgd-imagery/tests/test_kwcoco.py
@@ -0,0 +1,73 @@
+import pytest
+from rgd.datastore import datastore
+from rgd_imagery.models import Image, ImageMeta, RLESegmentation
+
+from . import factories
+
+
+def _run_kwcoco_import(demo):
+    f_image_archive = demo['archive']
+    f_spec_file = demo['spec']
+
+    return factories.KWCOCOArchiveFactory(
+        image_archive__file__filename=f_image_archive,
+        image_archive__file__from_path=datastore.fetch(f_image_archive),
+        spec_file__file__filename=f_spec_file,
+        spec_file__file__from_path=datastore.fetch(f_spec_file),
+    )
+
+
+@pytest.mark.django_db(transaction=True)
+def test_kwcoco_basic_demo():
+    demo = {
+        'archive': 'demodata.zip',
+        'spec': 'demo.kwcoco.json',
+        'n_images': 3,
+        'n_annotations': 11,
+    }
+
+    kwds = _run_kwcoco_import(demo)
+    assert kwds.image_set.count == demo['n_images']
+    annotations = [a for anns in kwds.image_set.get_all_annotations().values() for a in anns]
+    assert len(annotations) == demo['n_annotations']
+    # Trigger save event and make sure original images were deleted
+    image_file_ids = [im.id for im in kwds.image_set.images.all()]
+    kwds.save()
+    for id in image_file_ids:
+        with pytest.raises(Image.DoesNotExist):
+            Image.objects.get(id=id)
+    # Now do same for delete
+    image_file_ids = [im.id for im in kwds.image_set.images.all()]
+    kwds.delete()
+    for id in image_file_ids:
+        with pytest.raises(Image.DoesNotExist):
+            Image.objects.get(id=id)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_kwcoco_rle_demo():
+    demo = {
+        'archive': 'demo_rle.zip',
+        'spec': 'demo_rle.kwcoco.json',
+        'n_images': 2,
+        'n_annotations': 15,
+    }
+
+    kwds = _run_kwcoco_import(demo)
+    assert kwds.image_set.count == demo['n_images']
+    annotations = [a for anns in kwds.image_set.get_all_annotations().values() for a in anns]
+    assert len(annotations) == demo['n_annotations']
+
+    # Test the RLESegmentation methods
+    seg = RLESegmentation.objects.all().first()
+    image_meta = ImageMeta.objects.get(parent_image=seg.annotation.image)
+    assert seg.width == image_meta.width
+    assert seg.height == image_meta.height
+
+    rle = seg.to_rle()
+    assert 'counts' in rle
+    assert 'size' in rle
+    assert rle['size'] == [seg.height, seg.width]
+
+    mask = seg.to_mask()
+    assert mask.shape == (seg.height, seg.width)
diff --git a/django-rgd-imagery/tests/test_project/__init__.py b/django-rgd-imagery/tests/test_project/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/django-rgd-imagery/tests/test_project/settings.py b/django-rgd-imagery/tests/test_project/settings.py
new file mode 100644
index 000000000..3acf991a1
--- /dev/null
+++ b/django-rgd-imagery/tests/test_project/settings.py
@@ -0,0 +1,10 @@
+from rgd_testing_utils.settings import *  # noqa
+
+INSTALLED_APPS += [  # noqa
+    'rgd_imagery',
+]
+
+ROOT_URLCONF = 'test_project.urls'
+
+CELERY_TASK_ALWAYS_EAGER = True
+CELERY_TASK_EAGER_PROPAGATES = True
diff --git a/django-rgd-imagery/tests/test_project/urls.py b/django-rgd-imagery/tests/test_project/urls.py
new file mode 100644
index 000000000..90bc413af
--- /dev/null
+++ b/django-rgd-imagery/tests/test_project/urls.py
@@ -0,0 +1,7 @@
+from django.urls import include, path
+
+urlpatterns = [
+    # Make this distinct from typical production values, to ensure it works dynamically
+    path('rgd_test/', include('rgd.urls')),
+    path('rgd_imagery_test/', include('rgd_imagery.urls')),
+]
diff --git a/django-rgd-imagery/tests/test_rest.py b/django-rgd-imagery/tests/test_rest.py
new file mode 100644
index 000000000..79b7fc5d7
--- /dev/null
+++ b/django-rgd-imagery/tests/test_rest.py
@@ -0,0 +1,58 @@
+import pytest
+from rest_framework import status
+from rgd_imagery import models
+
+
+@pytest.mark.django_db(transaction=True)
+def test_download_image_file(admin_api_client, astro_image):
+    pk = astro_image.pk
+    response = admin_api_client.get(f'/rgd_imagery_test/api/rgd_imagery/{pk}/data')
+    assert status.is_redirect(response.status_code)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_create_get_subsampled_image(admin_api_client, astro_image):
+    """Test POST and GET for RegionImage model."""
+    payload = {
+        'source_image': astro_image.pk,
+        'sample_type': 'pixel box',
+        'sample_parameters': {'right': 100, 'left': 0, 'top': 200, 'bottom': 0},
+    }
+    response = admin_api_client.post(
+        '/rgd_imagery_test/api/image_process/imagery/subsample', payload, format='json'
+    )
+    assert response.status_code == 201
+    assert response.data
+    id = response.data['id']
+    sub = models.RegionImage.objects.get(id=id)
+    assert sub.processed_image
+    # Test the GET
+    response = admin_api_client.get(f'/rgd_imagery_test/api/image_process/imagery/subsample/{id}')
+    assert response.status_code == 200
+    assert response.data
+    # Now test to make sure the serializer prevents duplicates
+    response = admin_api_client.post(
+        '/rgd_imagery_test/api/image_process/imagery/subsample', payload, format='json'
+    )
+    assert response.status_code == 201
+    assert response.data
+    assert id == response.data['id']  # Compare against original PK
+
+
+@pytest.mark.django_db(transaction=True)
+def test_create_and_download_cog(admin_api_client, geotiff_image_entry):
+    """Test POST for ConvertedImage model."""
+    response = admin_api_client.post(
+        '/rgd_imagery_test/api/image_process/imagery/cog',
+        {'source_image': geotiff_image_entry.id},
+    )
+    assert response.status_code == 201
+    assert response.data
+    # Check that a COG was generated
+    cog = models.ConvertedImage.objects.get(source_image=geotiff_image_entry.id)
+    # NOTE: This doesn't actually verify the file is in COG format. Assumed.
+    assert cog.processed_image
+    # Also test download endpoint here:
+    pk = cog.pk
+    response = admin_api_client.get(f'/rgd_imagery_test/api/image_process/imagery/cog/{pk}/data')
+    assert status.is_redirect(response.status_code)
diff --git a/rgd/geodata/tests/test_stac.py b/django-rgd-imagery/tests/test_stac.py
similarity index 81%
rename from rgd/geodata/tests/test_stac.py
rename to django-rgd-imagery/tests/test_stac.py
index 5b84157f4..bef9f5442 100644
--- a/rgd/geodata/tests/test_stac.py
+++ b/django-rgd-imagery/tests/test_stac.py
@@ -1,7 +1,6 @@
 import pystac
 import pytest
-
-from rgd.geodata.serializers import STACRasterSerializer
+from rgd_imagery.serializers import STACRasterSerializer
 
 
 def _check_conforms_stac(data):
@@ -15,27 +14,27 @@ def _check_conforms_stac(data):
 
 
 @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')
+def test_raster_stac_serializer_simple(admin_api_client, sample_raster_url_single):
+    id = sample_raster_url_single.pk
+    response = admin_api_client.get(f'/rgd_imagery_test/api/rgd_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
+    assert len(assets) == 2
 
 
 @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')
+def test_raster_stac_serializer_multi_file_bands(admin_api_client, sample_raster_url):
+    id = sample_raster_url.pk
+    response = admin_api_client.get(f'/rgd_imagery_test/api/rgd_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
+    assert len(assets) == 4
 
 
 @pytest.mark.django_db(transaction=True)
@@ -43,7 +42,7 @@ def test_raster_stac_export_import(admin_api_client, sample_raster_url):
     sample = sample_raster_url
     data = STACRasterSerializer(sample).data
     instance = STACRasterSerializer().create(data)
-    # Check ImageFile/ImageEntry
+    # Check Image/ImageMeta
     assert (
         instance.parent_raster.image_set.images.count()
         == sample.parent_raster.image_set.images.count()
diff --git a/rgd/geodata/tests/test_subsample.py b/django-rgd-imagery/tests/test_subsample.py
similarity index 72%
rename from rgd/geodata/tests/test_subsample.py
rename to django-rgd-imagery/tests/test_subsample.py
index 4d614529e..f502491cb 100644
--- a/rgd/geodata/tests/test_subsample.py
+++ b/django-rgd-imagery/tests/test_subsample.py
@@ -1,37 +1,35 @@
 from large_image_source_gdal import GDALFileTileSource
 import pytest
-
-from rgd.geodata.datastore import datastore
-from rgd.geodata.models.imagery import ConvertedImageFile, ImageEntry, SubsampledImage
-from rgd.geodata.models.imagery.annotation import Annotation
-from rgd.geodata.models.imagery.subsample import populate_subsampled_image
+from rgd.datastore import datastore
+from rgd.models import ChecksumFile
+from rgd_imagery.models import Annotation, ConvertedImage, Image, RegionImage
+from rgd_imagery.tasks.subsample import populate_region_image
 
 from . import factories
 
 
 @pytest.mark.django_db(transaction=True)
 def test_cog_image_conversion():
-    image_file = factories.ImageFileFactory(
+    image = factories.ImageFactory(
         file__file__filename='20091021202517-01000100-VIS_0001.ntf',
         file__file__from_path=datastore.fetch('20091021202517-01000100-VIS_0001.ntf'),
     )
-    img = ImageEntry.objects.get(image_file=image_file)
-    c = ConvertedImageFile()
-    c.source_image = img
+    c = ConvertedImage()
+    c.source_image = image
     c.save()
     # Task should complete synchronously
     c.refresh_from_db()
-    assert c.converted_file
+    assert c.processed_image
 
 
-def _create_subsampled(img, sample_type, params):
-    sub = SubsampledImage()
-    sub.source_image = img
+def _create_subsampled(image, sample_type, params):
+    sub = RegionImage()
+    sub.source_image = image
     sub.sample_type = sample_type
     sub.sample_parameters = params
     sub.skip_signal = True
     sub.save()
-    populate_subsampled_image(sub)
+    populate_region_image(sub)
     sub.refresh_from_db()
     return sub
 
@@ -45,15 +43,16 @@ def _assert_bounds(new, bounds):
 
 @pytest.mark.django_db(transaction=True)
 def test_subsample_pixel_box(elevation):
-    with elevation.image_file.file.yield_local_path() as file_path:
+    with elevation.file.yield_local_path() as file_path:
         tile_source = GDALFileTileSource(str(file_path), projection='EPSG:3857', encoding='PNG')
         bounds = tile_source.getBounds()
     # Test with bbox
     sub = _create_subsampled(
         elevation, 'pixel box', {'left': 0, 'right': 100, 'bottom': 0, 'top': 200}
     )
-    assert sub.data
-    with sub.data.yield_local_path() as file_path:
+    sub.refresh_from_db()
+    assert sub.processed_image.file
+    with sub.processed_image.file.yield_local_path() as file_path:
         tile_source = GDALFileTileSource(str(file_path), projection='EPSG:3857', encoding='PNG')
         new = tile_source.getBounds()
     _assert_bounds(new, bounds)
@@ -61,7 +60,7 @@ def test_subsample_pixel_box(elevation):
 
 @pytest.mark.django_db(transaction=True)
 def test_subsample_geo_box(elevation):
-    with elevation.image_file.file.yield_local_path() as file_path:
+    with elevation.file.yield_local_path() as file_path:
         tile_source = GDALFileTileSource(str(file_path), projection='EPSG:3857', encoding='PNG')
         bounds = tile_source.getBounds()
     # Test with bbox
@@ -78,8 +77,9 @@ def test_subsample_geo_box(elevation):
             'projection': 'EPSG:4326',
         },
     )
-    assert sub.data
-    with sub.data.yield_local_path() as file_path:
+    sub.refresh_from_db()
+    assert sub.processed_image.file
+    with sub.processed_image.file.yield_local_path() as file_path:
         tile_source = GDALFileTileSource(str(file_path), projection='EPSG:3857', encoding='PNG')
         new = tile_source.getBounds()
     _assert_bounds(new, bounds)
@@ -87,7 +87,7 @@ def test_subsample_geo_box(elevation):
 
 @pytest.mark.django_db(transaction=True)
 def test_subsample_geojson(elevation):
-    with elevation.image_file.file.yield_local_path() as file_path:
+    with elevation.file.yield_local_path() as file_path:
         tile_source = GDALFileTileSource(str(file_path), projection='EPSG:3857', encoding='PNG')
         bounds = tile_source.getBounds()
     # Test with GeoJSON
@@ -106,8 +106,9 @@ def test_subsample_geojson(elevation):
         'projection': 'EPSG:4326',
     }
     sub = _create_subsampled(elevation, 'geojson', geojson)
-    assert sub.data
-    with sub.data.yield_local_path() as file_path:
+    sub.refresh_from_db()
+    assert sub.processed_image.file
+    with sub.processed_image.file.yield_local_path() as file_path:
         tile_source = GDALFileTileSource(str(file_path), projection='EPSG:3857', encoding='PNG')
         new = tile_source.getBounds()
     _assert_bounds(new, bounds)
@@ -123,7 +124,9 @@ def test_subsample_annotaion():
         spec_file__file__from_path=datastore.fetch('demo_rle.kwcoco.json'),
     )
 
-    img = ImageEntry.objects.get(name='000000242287.jpg')  # bicycle
-    a = Annotation.objects.get(image=img.id)  # Should be only one
-    sub = _create_subsampled(img, 'annotation', {'id': a.id})
-    assert sub.data
+    file = ChecksumFile.objects.get(name='000000242287.jpg')  # bicycle
+    image = Image.objects.get(file=file)
+    a = Annotation.objects.get(image=image.pk)  # Should be only one
+    sub = _create_subsampled(image, 'annotation', {'id': a.pk})
+    sub.refresh_from_db()
+    assert sub.processed_image.file
diff --git a/rgd/geodata/tests/test_tiles.py b/django-rgd-imagery/tests/test_tiles.py
similarity index 67%
rename from rgd/geodata/tests/test_tiles.py
rename to django-rgd-imagery/tests/test_tiles.py
index 05f38672d..3cb5cb47e 100644
--- a/rgd/geodata/tests/test_tiles.py
+++ b/django-rgd-imagery/tests/test_tiles.py
@@ -4,7 +4,7 @@
 @pytest.mark.django_db(transaction=True)
 def test_metadata(api_client, geotiff_image_entry):
     response = api_client.get(
-        f'/api/geoprocess/imagery/{geotiff_image_entry.pk}/tiles?projection=EPSG:3857'
+        f'/rgd_imagery_test/api/image_process/imagery/{geotiff_image_entry.pk}/tiles?projection=EPSG:3857'
     )
     metadata = response.data
     assert metadata['levels'] == 15
@@ -15,13 +15,17 @@ def test_metadata(api_client, geotiff_image_entry):
 
 @pytest.mark.django_db(transaction=True)
 def test_tile(api_client, geotiff_image_entry):
-    response = api_client.get(f'/api/geoprocess/imagery/{geotiff_image_entry.pk}/tiles/1/0/0.png')
+    response = api_client.get(
+        f'/rgd_imagery_test/api/image_process/imagery/{geotiff_image_entry.pk}/tiles/1/0/0.png'
+    )
     assert response.status_code == 200
     assert response['Content-Type'] == 'image/png'
 
 
 @pytest.mark.django_db(transaction=True)
 def test_thumbnail(api_client, geotiff_image_entry):
-    response = api_client.get(f'/api/geoprocess/imagery/{geotiff_image_entry.pk}/thumbnail')
+    response = api_client.get(
+        f'/rgd_imagery_test/api/image_process/imagery/{geotiff_image_entry.pk}/thumbnail'
+    )
     assert response.status_code == 200
     assert response['Content-Type'] == 'image/png'
diff --git a/django-rgd/tests/__init__.py b/django-rgd/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/django-rgd/tests/conftest.py b/django-rgd/tests/conftest.py
new file mode 100644
index 000000000..f3d8806bc
--- /dev/null
+++ b/django-rgd/tests/conftest.py
@@ -0,0 +1,11 @@
+import inspect
+
+import factory
+from pytest_factoryboy import register
+from rgd_testing_utils import factories
+from rgd_testing_utils.api_fixtures import *  # noqa
+from rgd_testing_utils.data_fixtures import *  # noqa
+
+for _, fac in inspect.getmembers(factories):
+    if inspect.isclass(fac) and issubclass(fac, factory.Factory):
+        register(fac)
diff --git a/django-rgd/tests/manage.py b/django-rgd/tests/manage.py
new file mode 100644
index 000000000..de56cdfa5
--- /dev/null
+++ b/django-rgd/tests/manage.py
@@ -0,0 +1,2 @@
+# This file is a marker for pytest-django, to make Django paths here importable
+# https://pytest-django.readthedocs.io/en/latest/managing_python_path.html#automatic-looking-for-of-django-projects
diff --git a/rgd/geodata/tests/test_common.py b/django-rgd/tests/test_common.py
similarity index 97%
rename from rgd/geodata/tests/test_common.py
rename to django-rgd/tests/test_common.py
index 5c59359fc..2a273f1ec 100644
--- a/rgd/geodata/tests/test_common.py
+++ b/django-rgd/tests/test_common.py
@@ -2,9 +2,8 @@
 
 from django.db import IntegrityError
 import pytest
-
-from rgd.geodata.datastore import datastore, registry
-from rgd.geodata.models import common
+from rgd.datastore import datastore, registry
+from rgd.models import common
 
 FILENAME = 'stars.png'
 
diff --git a/rgd/geodata/tests/test_datastore.py b/django-rgd/tests/test_datastore.py
similarity index 93%
rename from rgd/geodata/tests/test_datastore.py
rename to django-rgd/tests/test_datastore.py
index b4d65c8f6..520a4c17e 100644
--- a/rgd/geodata/tests/test_datastore.py
+++ b/django-rgd/tests/test_datastore.py
@@ -1,4 +1,4 @@
-from rgd.geodata import datastore
+from rgd import datastore
 
 
 def test_datastore_format():
diff --git a/django-rgd/tests/test_filters.py b/django-rgd/tests/test_filters.py
new file mode 100644
index 000000000..1e308807a
--- /dev/null
+++ b/django-rgd/tests/test_filters.py
@@ -0,0 +1,56 @@
+import pytest
+from rgd import models
+from rgd.filters import SpatialEntryFilter
+
+
+@pytest.mark.django_db(transaction=True)
+def test_q_distance(spatial_asset_a, spatial_asset_b):
+    assert models.SpatialEntry.objects.count() == 2
+    # Make sure all are returned sorted by distance
+    filterset = SpatialEntryFilter(
+        data={
+            'q': f'SRID=4326;{spatial_asset_a.outline.wkt}',
+        }
+    )
+    assert filterset.is_valid()
+    qs = filterset.filter_queryset(models.SpatialEntry.objects.all())
+    assert qs.count() == 2
+    assert qs.first().spatial_id == spatial_asset_a.spatial_id
+    filterset = SpatialEntryFilter(
+        data={
+            'q': f'SRID=4326;{spatial_asset_b.outline.wkt}',
+        }
+    )
+    assert filterset.is_valid()
+    qs = filterset.filter_queryset(models.SpatialEntry.objects.all())
+    assert qs.count() == 2
+    assert qs.first().spatial_id == spatial_asset_b.spatial_id
+
+
+@pytest.mark.django_db(transaction=True)
+def test_footprint_intersects(spatial_asset_a, spatial_asset_b):
+    assert models.SpatialEntry.objects.count() == 2
+    filterset = SpatialEntryFilter(
+        data={
+            'q': f'SRID=4326;{spatial_asset_a.outline.wkt}',
+            'predicate': 'intersects',
+        }
+    )
+    assert filterset.is_valid()
+    qs = filterset.filter_queryset(models.SpatialEntry.objects.all())
+    assert qs.count() == 1
+
+
+@pytest.mark.django_db(transaction=True)
+def test_geojson_intersects(spatial_asset_a, spatial_asset_b):
+    assert models.SpatialEntry.objects.count() == 2
+    filterset = SpatialEntryFilter(
+        data={
+            'q': f'{spatial_asset_a.outline.geojson}',
+            'predicate': 'intersects',
+        }
+    )
+    assert filterset.is_valid()
+    qs = filterset.filter_queryset(models.SpatialEntry.objects.all())
+    assert qs.count() == 1
+    assert qs.first().spatial_id == spatial_asset_a.spatial_id
diff --git a/django-rgd/tests/test_permissions.py b/django-rgd/tests/test_permissions.py
new file mode 100644
index 000000000..694ffd072
--- /dev/null
+++ b/django-rgd/tests/test_permissions.py
@@ -0,0 +1,22 @@
+from django.conf import settings
+import pytest
+from rgd import models
+from rgd.permissions import filter_read_perm
+
+
+@pytest.mark.django_db(transaction=True)
+def test_unassigned_permissions_complex(user_factory, user, spatial_asset_a, spatial_asset_b):
+    # TODO: reimplement with multi raster file fixture also
+    prior = getattr(settings, 'RGD_GLOBAL_READ_ACCESS', None)
+    settings.RGD_GLOBAL_READ_ACCESS = False
+    admin = user_factory(is_superuser=True)
+    admin_q = filter_read_perm(admin, models.SpatialEntry.objects.all())
+    basic_q = filter_read_perm(user, models.SpatialEntry.objects.all())
+    assert len(admin_q) == 2
+    assert len(basic_q) == 0
+    settings.RGD_GLOBAL_READ_ACCESS = True
+    admin_q = filter_read_perm(admin, models.SpatialEntry.objects.all())
+    basic_q = filter_read_perm(user, models.SpatialEntry.objects.all())
+    assert len(admin_q) == len(basic_q)
+    assert set(admin_q) == set(basic_q)
+    settings.RGD_GLOBAL_READ_ACCESS = prior
diff --git a/django-rgd/tests/test_project/__init__.py b/django-rgd/tests/test_project/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/django-rgd/tests/test_project/settings.py b/django-rgd/tests/test_project/settings.py
new file mode 100644
index 000000000..7d2cef0e5
--- /dev/null
+++ b/django-rgd/tests/test_project/settings.py
@@ -0,0 +1,6 @@
+from rgd_testing_utils.settings import *  # noqa
+
+ROOT_URLCONF = 'test_project.urls'
+
+CELERY_TASK_ALWAYS_EAGER = True
+CELERY_TASK_EAGER_PROPAGATES = True
diff --git a/django-rgd/tests/test_project/urls.py b/django-rgd/tests/test_project/urls.py
new file mode 100644
index 000000000..f3ccb1ab4
--- /dev/null
+++ b/django-rgd/tests/test_project/urls.py
@@ -0,0 +1,6 @@
+from django.urls import include, path
+
+urlpatterns = [
+    # Make this distinct from typical production values, to ensure it works dynamically
+    path('rgd_test/', include('rgd.urls')),
+]
diff --git a/django-rgd/tests/test_rest.py b/django-rgd/tests/test_rest.py
new file mode 100644
index 000000000..7960c1f8b
--- /dev/null
+++ b/django-rgd/tests/test_rest.py
@@ -0,0 +1,47 @@
+import pytest
+from rest_framework import status
+
+
+@pytest.mark.django_db(transaction=True)
+def test_get_status(admin_api_client, checksum_file):
+    model = 'ChecksumFile'
+    id = checksum_file.id
+    response = admin_api_client.get(f'/rgd_test/api/rgd/status/{model}/{id}')
+    assert response.status_code == 200
+    assert response.data
+    with pytest.raises(AttributeError):
+        admin_api_client.get(f'/rgd_test/api/rgd/status/Foo/{id}')
+
+
+@pytest.mark.django_db(transaction=True)
+def test_download_checksum_file(admin_api_client, checksum_file):
+    pk = checksum_file.pk
+    response = admin_api_client.get(f'/rgd_test/api/rgd/checksum_file/{pk}/data')
+    assert status.is_redirect(response.status_code)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_download_checksum_file_url(admin_api_client, checksum_file_url):
+    pk = checksum_file_url.pk
+    response = admin_api_client.get(f'/rgd_test/api/rgd/checksum_file/{pk}/data')
+    assert status.is_redirect(response.status_code)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_get_checksum_file(admin_api_client, checksum_file):
+    pk = checksum_file.pk
+    content = admin_api_client.get(f'/rgd_test/api/rgd/checksum_file/{pk}').data
+    assert content
+    # Check that a hyperlink is given to the file data
+    # NOTE: tried using the URLValidator from django but it thinks this URL is invalid
+    assert content['file'].startswith('http')
+
+
+@pytest.mark.django_db(transaction=True)
+def test_get_spatial_entry(api_client, spatial_asset_a):
+    """Test individual GET for SpatialEntry model."""
+    pk = spatial_asset_a.spatial_id
+    response = api_client.get(f'/rgd_test/api/rgd/spatial_entry/{pk}')
+    assert response.status_code == 200
+    assert response.data
+    assert response.data['outline']
diff --git a/django-rgd/tests/test_version.py b/django-rgd/tests/test_version.py
new file mode 100644
index 000000000..d38e0e1de
--- /dev/null
+++ b/django-rgd/tests/test_version.py
@@ -0,0 +1,5 @@
+import rgd
+
+
+def test_rgd_version():
+    assert rgd.__version__  # Make sure not None
diff --git a/rgd-client/tests/test_version.py b/rgd-client/tests/test_version.py
new file mode 100644
index 000000000..ace72d6a4
--- /dev/null
+++ b/rgd-client/tests/test_version.py
@@ -0,0 +1,5 @@
+import rgd_client
+
+
+def test_rgd_version():
+    assert rgd_client.__version__  # Make sure not None
diff --git a/rgd/geodata/tests/factories.py b/rgd/geodata/tests/factories.py
deleted file mode 100644
index f5936edcb..000000000
--- a/rgd/geodata/tests/factories.py
+++ /dev/null
@@ -1,120 +0,0 @@
-from django.contrib.auth.models import User
-import factory
-import factory.django
-
-from rgd.geodata import models
-
-
-class UserFactory(factory.django.DjangoModelFactory):
-    class Meta:
-        model = User
-
-    username = factory.Sequence(lambda n: f'user_{n}')
-    email = factory.Faker('safe_email')
-    first_name = factory.Faker('first_name')
-    last_name = factory.Faker('last_name')
-
-
-class ChecksumFileFactory(factory.django.DjangoModelFactory):
-    class Meta:
-        model = models.ChecksumFile
-
-    file = factory.django.FileField(filename='sample.dat')
-
-    # If we have an on_commit or post_save method that modifies the model, we
-    # need to refresh it afterwards.
-    @classmethod
-    def _after_postgeneration(cls, instance, *args, **kwargs):
-        super()._after_postgeneration(instance, *args, **kwargs)
-        instance.refresh_from_db()
-
-
-class ImageFileFactory(factory.django.DjangoModelFactory):
-    class Meta:
-        model = models.ImageFile
-
-    file = factory.SubFactory(ChecksumFileFactory)
-    # creator = factory.SubFactory(UserFactory)
-    # modifier = factory.SubFactory(UserFactory)
-
-
-class ImageSetFactory(factory.django.DjangoModelFactory):
-    class Meta:
-        model = models.ImageSet
-
-    name = factory.Faker('sentence', nb_words=2)
-
-    @factory.post_generation
-    def images(self, create, extracted, **kwargs):
-        if not create:
-            return
-        if extracted:
-            for image in extracted:
-                self.images.add(image)
-
-
-class RasterEntryFactory(factory.django.DjangoModelFactory):
-    class Meta:
-        model = models.RasterEntry
-
-    name = factory.Faker('sentence', nb_words=2)
-    image_set = factory.SubFactory(ImageSetFactory)
-
-    # If we have an on_commit or post_save method that modifies the model, we
-    # need to refresh it afterwards.
-    @classmethod
-    def _after_postgeneration(cls, instance, *args, **kwargs):
-        super()._after_postgeneration(instance, *args, **kwargs)
-        instance.refresh_from_db()
-
-
-class GeometryArchiveFactory(factory.django.DjangoModelFactory):
-    class Meta:
-        model = models.GeometryArchive
-
-    file = factory.SubFactory(ChecksumFileFactory)
-
-
-class KWCOCOArchiveFactory(factory.django.DjangoModelFactory):
-    class Meta:
-        model = models.KWCOCOArchive
-
-    name = factory.Faker('sentence', nb_words=2)
-    spec_file = factory.SubFactory(ChecksumFileFactory)
-    image_archive = factory.SubFactory(ChecksumFileFactory)
-
-    # If we have an on_commit or post_save method that modifies the model, we
-    # need to refresh it afterwards.
-    @classmethod
-    def _after_postgeneration(cls, instance, *args, **kwargs):
-        super()._after_postgeneration(instance, *args, **kwargs)
-        instance.refresh_from_db()
-
-
-class FMVFileFactory(factory.django.DjangoModelFactory):
-    class Meta:
-        model = models.FMVFile
-
-    file = factory.SubFactory(ChecksumFileFactory)
-    klv_file = factory.django.FileField(filename='sample.klv')
-    web_video_file = factory.django.FileField(filename='sample.mp4')
-    frame_rate = 30
-
-    # If we have an on_commit or post_save method that modifies the model, we
-    # need to refresh it afterwards.
-    @classmethod
-    def _after_postgeneration(cls, instance, *args, **kwargs):
-        super()._after_postgeneration(instance, *args, **kwargs)
-        instance.refresh_from_db()
-
-
-class PointCloudFileFactory(factory.django.DjangoModelFactory):
-    class Meta:
-        model = models.PointCloudFile
-
-    file = factory.SubFactory(ChecksumFileFactory)
-
-
-# https://factoryboy.readthedocs.io/en/latest/recipes.html#simple-many-to-many-relationship
-# For generating lat-lon coords, this may be helpful:
-# https://faker.readthedocs.io/en/latest/providers/faker.providers.geo.html
diff --git a/rgd/geodata/tests/test_api.py b/rgd/geodata/tests/test_api.py
deleted file mode 100644
index d8a077881..000000000
--- a/rgd/geodata/tests/test_api.py
+++ /dev/null
@@ -1,100 +0,0 @@
-import pytest
-from rest_framework import status
-
-from rgd.geodata import models
-
-
-@pytest.mark.django_db(transaction=True)
-def test_get_status(admin_api_client, astro_image):
-    model = 'ImageFile'
-    id = astro_image.image_file.imagefile.id
-    response = admin_api_client.get(f'/api/geodata/status/{model}/{id}')
-    assert response.status_code == 200
-    assert response.data
-    with pytest.raises(AttributeError):
-        admin_api_client.get(f'/api/geodata/status/Foo/{id}')
-
-
-@pytest.mark.django_db(transaction=True)
-def test_download_checksum_file(admin_api_client, checksum_file):
-    pk = checksum_file.pk
-    response = admin_api_client.get(f'/api/geodata/common/checksum_file/{pk}/data')
-    assert status.is_redirect(response.status_code)
-
-
-@pytest.mark.django_db(transaction=True)
-def test_download_checksum_file_url(admin_api_client, checksum_file_url):
-    pk = checksum_file_url.pk
-    response = admin_api_client.get(f'/api/geodata/common/checksum_file/{pk}/data')
-    assert status.is_redirect(response.status_code)
-
-
-@pytest.mark.django_db(transaction=True)
-def test_download_image_entry_file(admin_api_client, astro_image):
-    pk = astro_image.pk
-    response = admin_api_client.get(f'/api/geodata/imagery/{pk}/data')
-    assert status.is_redirect(response.status_code)
-
-
-@pytest.mark.django_db(transaction=True)
-def test_get_checksum_file(admin_api_client, checksum_file):
-    pk = checksum_file.pk
-    content = admin_api_client.get(f'/api/geodata/common/checksum_file/{pk}').data
-    assert content
-    # Check that a hyperlink is given to the file data
-    # NOTE: tried using the URLValidator from django but it thinks this URL is invalid
-    assert content['file'].startswith('http')
-
-
-@pytest.mark.django_db(transaction=True)
-def test_get_spatial_entry(api_client, sample_raster_a):
-    """Test individual GET for SpatialEntry model."""
-    pk = sample_raster_a.rastermetaentry.spatial_id
-    response = api_client.get(f'/api/geodata/common/spatial_entry/{pk}')
-    assert response.status_code == 200
-    assert response.data
-    assert response.data['outline']
-
-
-@pytest.mark.django_db(transaction=True)
-def test_create_get_subsampled_image(admin_api_client, astro_image):
-    """Test POST and GET for SubsampledImage model."""
-    payload = {
-        'source_image': astro_image.pk,
-        'sample_type': 'pixel box',
-        'sample_parameters': {'right': 100, 'left': 0, 'top': 200, 'bottom': 0},
-    }
-    response = admin_api_client.post('/api/geoprocess/imagery/subsample', payload)
-    assert response.status_code == 201
-    assert response.data
-    id = response.data['id']
-    sub = models.imagery.SubsampledImage.objects.get(id=id)
-    assert sub.data
-    # Test the GET
-    response = admin_api_client.get(f'/api/geoprocess/imagery/subsample/{id}')
-    assert response.status_code == 200
-    assert response.data
-    # Now test to make sure the serializer prevents duplicates
-    response = admin_api_client.post('/api/geoprocess/imagery/subsample', payload)
-    assert response.status_code == 201
-    assert response.data
-    assert id == response.data['id']  # Compare against original PK
-
-
-@pytest.mark.django_db(transaction=True)
-def test_create_and_download_cog(admin_api_client, geotiff_image_entry):
-    """Test POST for ConvertedImageFile model."""
-    response = admin_api_client.post(
-        '/api/geoprocess/imagery/cog',
-        {'source_image': geotiff_image_entry.id},
-    )
-    assert response.status_code == 201
-    assert response.data
-    # Check that a COG was generated
-    cog = models.imagery.ConvertedImageFile.objects.get(source_image=geotiff_image_entry.id)
-    # NOTE: This doesn't actually verify the file is in COG format. Assumed.
-    assert cog.converted_file
-    # Also test download endpoint here:
-    pk = cog.pk
-    response = admin_api_client.get(f'/api/geoprocess/imagery/cog/{pk}/data')
-    assert status.is_redirect(response.status_code)
diff --git a/rgd/geodata/tests/test_client.py b/rgd/geodata/tests/test_client.py
deleted file mode 100644
index 60698d202..000000000
--- a/rgd/geodata/tests/test_client.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from pathlib import Path
-import re
-
-import pkg_resources
-import pytest
-
-RGDC_VERSION_REGEX = r'__version__ = \'(.*)\''
-
-
-@pytest.fixture
-def rgdc_version():
-    base_dir = Path(__file__).parent.parent.parent.parent
-    version_file = base_dir / 'rgdc' / 'rgdc' / 'version.py'
-
-    with open(version_file) as file_in:
-        file_content = file_in.read()
-        version = re.search(RGDC_VERSION_REGEX, file_content)
-
-    return version.group(1)
-
-
-def test_client_version_match(rgdc_version):
-    rgd_version = next(
-        pkg.version for pkg in pkg_resources.working_set if pkg.key == 'resonantgeodata'
-    )
-
-    assert rgd_version == rgdc_version
diff --git a/rgd/geodata/tests/test_demo_data.py b/rgd/geodata/tests/test_demo_data.py
deleted file mode 100644
index dadb32960..000000000
--- a/rgd/geodata/tests/test_demo_data.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from io import StringIO
-
-from django.core.management import call_command
-import pytest
-
-from rgd.geodata.datastore import registry
-from rgd.geodata.management.commands import demo_data
-
-
-def _call_command(name, *args, **kwargs):
-    out = StringIO()
-    call_command(
-        name,
-        *args,
-        stdout=out,
-        stderr=StringIO(),
-        **kwargs,
-    )
-    return out.getvalue()
-
-
-@pytest.mark.skip
-@pytest.mark.django_db(transaction=True)
-def test_load_all_demo_data():
-    out = _call_command('demo_data')
-    assert out == demo_data.SUCCESS_MSG + '\n'
-
-
-def test_entries_in_datastore():
-    def is_in_datastore(f):
-        if isinstance(f, (list, tuple)):
-            return all([is_in_datastore(i) for i in f])
-        return f in registry
-
-    for f in (
-        demo_data.RASTER_FILES
-        + demo_data.SHAPE_FILES
-        + demo_data.FMV_FILES
-        + demo_data.KWCOCO_ARCHIVES
-    ):
-        assert is_in_datastore(f), f'`{f}` not in registry.'
-
-
-@pytest.mark.skip
-@pytest.mark.django_db(transaction=True)
-def test_demo_command_landsat():
-    out = _call_command('s3_landsat', count=1)
-    assert out == demo_data.SUCCESS_MSG.replace('demo', 'landsat') + '\n'
-
-
-@pytest.mark.skip
-@pytest.mark.django_db(transaction=True)
-def test_demo_command_wasabi():
-    out = _call_command('wasabi')
-    assert out == demo_data.SUCCESS_MSG + '\n'
diff --git a/rgd/geodata/tests/test_filters.py b/rgd/geodata/tests/test_filters.py
deleted file mode 100644
index 812bcd7f1..000000000
--- a/rgd/geodata/tests/test_filters.py
+++ /dev/null
@@ -1,71 +0,0 @@
-import pytest
-
-from rgd.geodata import models
-from rgd.geodata.filters import RasterMetaEntryFilter, SpatialEntryFilter
-
-
-@pytest.mark.django_db(transaction=True)
-def test_q_distance(sample_raster_a, sample_raster_b):
-    assert models.SpatialEntry.objects.count() == 2
-    # Make sure all are returned sorted by distance
-    filterset = SpatialEntryFilter(
-        data={
-            'q': f'SRID=4326;{sample_raster_a.outline.wkt}',
-        }
-    )
-    assert filterset.is_valid()
-    qs = filterset.filter_queryset(models.SpatialEntry.objects.all())
-    assert qs.count() == 2
-    assert qs.first().spatial_id == sample_raster_a.spatial_id
-    filterset = SpatialEntryFilter(
-        data={
-            'q': f'SRID=4326;{sample_raster_b.outline.wkt}',
-        }
-    )
-    assert filterset.is_valid()
-    qs = filterset.filter_queryset(models.SpatialEntry.objects.all())
-    assert qs.count() == 2
-    assert qs.first().spatial_id == sample_raster_b.spatial_id
-
-
-@pytest.mark.django_db(transaction=True)
-def test_raster_intersects(sample_raster_a, sample_raster_b):
-    assert models.SpatialEntry.objects.count() == 2
-    filterset = SpatialEntryFilter(
-        data={
-            'q': f'SRID=4326;{sample_raster_a.outline.wkt}',
-            'predicate': 'intersects',
-        }
-    )
-    assert filterset.is_valid()
-    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
-
-
-@pytest.mark.django_db(transaction=True)
-def test_geojson_intersects(sample_raster_a, sample_raster_b):
-    assert models.SpatialEntry.objects.count() == 2
-    filterset = SpatialEntryFilter(
-        data={
-            'q': f'{sample_raster_a.outline.geojson}',
-            'predicate': 'intersects',
-        }
-    )
-    assert filterset.is_valid()
-    qs = filterset.filter_queryset(models.SpatialEntry.objects.all())
-    assert qs.count() == 1
-    assert qs.first().spatial_id == sample_raster_a.spatial_id
diff --git a/rgd/geodata/tests/test_permissions.py b/rgd/geodata/tests/test_permissions.py
deleted file mode 100644
index 32b8f1619..000000000
--- a/rgd/geodata/tests/test_permissions.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from django.conf import settings
-import pytest
-
-from rgd.geodata import models
-from rgd.geodata.permissions import filter_read_perm
-
-
-@pytest.mark.django_db(transaction=True)
-def test_unassigned_permissions_complex(user_factory, user, sample_raster_a, sample_raster_multi):
-    prior = settings.RGD_GLOBAL_READ_ACCESS
-    settings.RGD_GLOBAL_READ_ACCESS = False
-    admin = user_factory(is_superuser=True)
-    admin_q = filter_read_perm(admin, models.RasterMetaEntry.objects.all())
-    basic_q = filter_read_perm(user, models.RasterMetaEntry.objects.all())
-    assert len(admin_q) == 2
-    assert len(basic_q) == 0
-    settings.RGD_GLOBAL_READ_ACCESS = True
-    admin_q = filter_read_perm(admin, models.RasterMetaEntry.objects.all())
-    basic_q = filter_read_perm(user, models.RasterMetaEntry.objects.all())
-    assert len(admin_q) == len(basic_q)
-    assert set(admin_q) == set(basic_q)
-    settings.RGD_GLOBAL_READ_ACCESS = prior
diff --git a/testing-utils/rgd_testing_utils/__init__.py b/testing-utils/rgd_testing_utils/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/rgd/geodata/tests/conftest.py b/testing-utils/rgd_testing_utils/api_fixtures.py
similarity index 63%
rename from rgd/geodata/tests/conftest.py
rename to testing-utils/rgd_testing_utils/api_fixtures.py
index e133c6044..4e9fc8a8a 100644
--- a/rgd/geodata/tests/conftest.py
+++ b/testing-utils/rgd_testing_utils/api_fixtures.py
@@ -1,13 +1,6 @@
-import inspect
-
-import factory
 import pytest
-from pytest_factoryboy import register
 from rest_framework.test import APIClient
 
-from . import factories
-from .data_fixtures import *  # noqa
-
 
 @pytest.fixture
 def api_client() -> APIClient:
@@ -27,8 +20,3 @@ def authenticated_api_client(user) -> APIClient:
     client = APIClient()
     client.force_authenticate(user=user)
     return client
-
-
-for _, fac in inspect.getmembers(factories):
-    if inspect.isclass(fac) and issubclass(fac, factory.Factory):
-        register(fac)
diff --git a/testing-utils/rgd_testing_utils/data_fixtures.py b/testing-utils/rgd_testing_utils/data_fixtures.py
new file mode 100644
index 000000000..759910ad7
--- /dev/null
+++ b/testing-utils/rgd_testing_utils/data_fixtures.py
@@ -0,0 +1,48 @@
+from django.contrib.gis.geos import GEOSGeometry
+import pytest
+from rgd import models
+from rgd.datastore import datastore
+
+from . import factories
+
+
+@pytest.fixture
+def checksum_file():
+    return factories.ChecksumFileFactory()
+
+
+@pytest.fixture
+def checksum_file_url():
+    return factories.ChecksumFileFactory(
+        file=None,
+        url=datastore.get_url('stars.png'),
+        type=models.FileSourceType.URL,
+    )
+
+
+@pytest.fixture
+def spatial_asset_a():
+    geom = GEOSGeometry(
+        'POLYGON ((-84.14505029585798 39.8080495412844, -84.07694970414201 39.8080495412844, -84.07694970414201 39.75395045871559, -84.14505029585798 39.75395045871559, -84.14505029585798 39.8080495412844))'
+    )
+    e = models.SpatialAsset()
+    e.footprint = geom
+    e.outline = geom
+    e.save()
+    e.files.add(factories.ChecksumFileFactory())
+    e.save()
+    return e
+
+
+@pytest.fixture
+def spatial_asset_b():
+    geom = GEOSGeometry(
+        'POLYGON ((-77.16435685136094 42.60406117923981, -76.53961844004117 42.60406117923981, -76.53961844004117 42.20103756066038, -77.16435685136094 42.20103756066038, -77.16435685136094 42.60406117923981))'
+    )
+    e = models.SpatialAsset()
+    e.footprint = geom
+    e.outline = geom
+    e.save()
+    e.files.add(factories.ChecksumFileFactory())
+    e.save()
+    return e
diff --git a/testing-utils/rgd_testing_utils/factories.py b/testing-utils/rgd_testing_utils/factories.py
new file mode 100644
index 000000000..86ce426f8
--- /dev/null
+++ b/testing-utils/rgd_testing_utils/factories.py
@@ -0,0 +1,28 @@
+from django.contrib.auth.models import User
+import factory
+import factory.django
+from rgd import models
+
+
+class UserFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = User
+
+    username = factory.Sequence(lambda n: f'user_{n}')
+    email = factory.Faker('safe_email')
+    first_name = factory.Faker('first_name')
+    last_name = factory.Faker('last_name')
+
+
+class ChecksumFileFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = models.ChecksumFile
+
+    file = factory.django.FileField(filename='sample.dat')
+
+    # If we have an on_commit or post_save method that modifies the model, we
+    # need to refresh it afterwards.
+    @classmethod
+    def _after_postgeneration(cls, instance, *args, **kwargs):
+        super()._after_postgeneration(instance, *args, **kwargs)
+        instance.refresh_from_db()
diff --git a/testing-utils/rgd_testing_utils/settings.py b/testing-utils/rgd_testing_utils/settings.py
new file mode 100644
index 000000000..e8b39e28c
--- /dev/null
+++ b/testing-utils/rgd_testing_utils/settings.py
@@ -0,0 +1,127 @@
+import os
+from typing import List
+
+try:
+    import re
+
+    import osgeo
+
+    libsdir = os.path.join(os.path.dirname(os.path.dirname(osgeo._gdal.__file__)), 'GDAL.libs')
+    libs = {re.split(r'-|\.', name)[0]: os.path.join(libsdir, name) for name in os.listdir(libsdir)}
+    GDAL_LIBRARY_PATH = libs['libgdal']
+    GEOS_LIBRARY_PATH = libs['libgeos_c']
+except Exception:
+    # TODO: Log that we aren't using the expected GDAL wheel?
+    pass
+
+SECRET_KEY = 'example-secret'
+
+DEBUG = True
+
+ALLOWED_HOSTS: List[str] = []
+
+INSTALLED_APPS = [
+    'django.contrib.admin',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.messages',
+    'django.contrib.sites',
+    'django.contrib.staticfiles',
+    'rest_framework',
+    'rest_framework.authtoken',
+    'allauth',
+    'allauth.account',
+    'allauth.socialaccount',
+    'oauth2_provider',
+    's3_file_field',
+    # RGD Apps
+    'django.contrib.gis',
+    'rgd',
+]
+
+MIDDLEWARE = [
+    'django.middleware.security.SecurityMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.common.CommonMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.contrib.gis.db.backends.postgis',
+        'NAME': 'django',
+        'USER': 'postgres',
+        'PASSWORD': 'postgres',
+        'HOST': os.environ.get('DATABASE_HOST', 'postgres'),
+        'PORT': '5432',
+    }
+}
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'context_processors': [
+                'django.template.context_processors.debug',
+                'django.template.context_processors.request',
+                'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
+            ]
+        },
+    }
+]
+
+SITE_ID = 1
+
+STATIC_URL = '/static/'
+
+CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'amqp://rabbitmq:5672/')
+os.environ.setdefault(
+    'CELERY_BROKER_URL', 'amqp://rabbitmq:5672/'
+)  # Have to set environ variable for it to be register by celery's backing services
+CELERY_RESULT_BACKEND = None
+CELERY_BROKER_POOL_LIMIT = 1
+CELERY_BROKER_HEARTBEAT = None
+CELERY_BROKER_CONNECTION_TIMEOUT = 30
+CELERY_EVENT_QUEUE_EXPIRES = 60
+CELERY_TASK_ACKS_LATE = False if DEBUG else True
+CELERY_WORKER_CONCURRENCY = 1 if DEBUG else None
+CELERY_WORKER_SEND_TASK_EVENTS = True
+
+DEFAULT_FILE_STORAGE = 'minio_storage.storage.MinioMediaStorage'
+MINIO_STORAGE_ENDPOINT = os.environ.get('MINIO_STORAGE_ENDPOINT', 'minio:9000')
+MINIO_STORAGE_MEDIA_URL = os.environ.get(
+    'MINIO_STORAGE_MEDIA_URL', 'http://localhost:9000/django-storage'
+)
+MINIO_STORAGE_USE_HTTPS = False
+MINIO_STORAGE_ACCESS_KEY = os.environ.get('MINIO_STORAGE_ACCESS_KEY', 'minioAccessKey')
+MINIO_STORAGE_SECRET_KEY = os.environ.get('MINIO_STORAGE_SECRET_KEY', 'minioSecretKey')
+MINIO_STORAGE_MEDIA_BUCKET_NAME = 'django-storage'
+MINIO_STORAGE_AUTO_CREATE_MEDIA_BUCKET = True
+MINIO_STORAGE_AUTO_CREATE_MEDIA_POLICY = 'READ_WRITE'
+MINIO_STORAGE_MEDIA_USE_PRESIGNED = True
+
+LOGGING = {
+    'version': 1,
+    'disable_existing_loggers': False,
+    'formatters': {
+        'simple': {
+            'format': '[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s',
+            'datefmt': '%d/%b/%Y %H:%M:%S',
+        },
+    },
+    'handlers': {
+        'console': {'level': 'INFO', 'class': 'logging.StreamHandler', 'formatter': 'simple'},
+    },
+    'loggers': {
+        '': {
+            'level': 'INFO',
+            'handlers': ['console'],
+        },
+    },
+}
diff --git a/testing-utils/setup.py b/testing-utils/setup.py
new file mode 100644
index 000000000..f9d6e58e7
--- /dev/null
+++ b/testing-utils/setup.py
@@ -0,0 +1,29 @@
+from setuptools import find_packages, setup
+
+setup(
+    name='testing-utils',
+    version='0.0.0',
+    description='',
+    long_description_content_type='text/markdown',
+    license='Apache 2.0',
+    author='Kitware, Inc.',
+    author_email='rgd@kitware.com',
+    url='https://github.com/ResonantGeoData/ResonantGeoData',
+    keywords='',
+    classifiers=[
+        'Development Status :: 3 - Alpha',
+        'Environment :: Web Environment',
+        'Framework :: Django :: 3.0',
+        'Framework :: Django',
+        'Intended Audience :: Developers',
+        'License :: OSI Approved :: Apache Software License',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python',
+    ],
+    python_requires='>=3.8',
+    packages=find_packages(),
+    include_package_data=True,
+    install_requires=[],
+)