Skip to content

Commit

Permalink
Initial REST API Solution
Browse files Browse the repository at this point in the history
  • Loading branch information
swainn committed May 8, 2020
1 parent e07e34a commit 8f7c71a
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 51 deletions.
21 changes: 13 additions & 8 deletions tethysapp/earth_engine/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,28 +27,33 @@ def url_maps(self):
UrlMap(
name='home',
url='earth-engine',
controller='earth_engine.controllers.home'
controller='earth_engine.controllers.home.home'
),
UrlMap(
name='about',
url='earth-engine/about',
controller='earth_engine.controllers.home.about'
),
UrlMap(
name='viewer',
url='earth-engine/viewer',
controller='earth_engine.controllers.viewer'
controller='earth_engine.controllers.viewer.viewer'
),
UrlMap(
name='get_image_collection',
url='earth-engine/viewer/get-image-collection',
controller='earth_engine.controllers.get_image_collection'
controller='earth_engine.controllers.viewer.get_image_collection'
),
UrlMap(
name='get_time_series_plot',
url='earth-engine/viewer/get-time-series-plot',
controller='earth_engine.controllers.get_time_series_plot'
controller='earth_engine.controllers.viewer.get_time_series_plot'
),
UrlMap(
name='about',
url='earth-engine/about',
controller='earth_engine.controllers.about'
)
name='rest_get_time_series',
url='earth-engine/api/get-time-series',
controller='earth_engine.controllers.rest.get_time_series'
),
)

return url_maps
Empty file.
23 changes: 23 additions & 0 deletions tethysapp/earth_engine/controllers/home.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import logging
from django.shortcuts import render
from tethys_sdk.permissions import login_required

log = logging.getLogger(f'tethys.apps.{__name__}')


@login_required()
def home(request):
"""
Controller for the app home page.
"""
context = {}
return render(request, 'earth_engine/home.html', context)


@login_required()
def about(request):
"""
Controller for the app about page.
"""
context = {}
return render(request, 'earth_engine/about.html', context)
192 changes: 192 additions & 0 deletions tethysapp/earth_engine/controllers/rest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import logging
import datetime as dt
import geojson
from simplejson import JSONDecodeError
from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseServerError
from rest_framework.authentication import TokenAuthentication
from rest_framework.decorators import api_view, authentication_classes
from ..gee.products import EE_PRODUCTS
from ..gee.methods import get_time_series_from_image_collection
from ..helpers import compute_dates_for_product

log = logging.getLogger(f'tethys.apps.{__name__}')


@api_view(['GET', 'POST'])
@authentication_classes((TokenAuthentication,))
def get_time_series(request):
"""
Controller for the get-time-series REST endpoint.
"""
# Get request parameters.
if request.method == 'GET':
data = request.GET.copy()
elif request.method == 'POST':
data = request.POST.copy()
else:
return HttpResponseBadRequest('Only GET and POST methods are supported.')

platform = data.get('platform', None)
sensor = data.get('sensor', None)
product = data.get('product', None)
start_date_str = data.get('start_date', None)
end_date_str = data.get('end_date', None)
reducer = data.get('reducer', 'median')
index = data.get('index', None)
scale_str = data.get('scale', 250)
orient = data.get('orient', 'list')
geometry_str = data.get('geometry', None)

# validate given parameters
# platform
if not platform or platform not in EE_PRODUCTS:
valid_platform_str = '", "'.join(EE_PRODUCTS.keys())
return HttpResponseBadRequest(f'The "platform" parameter is required. Valid platforms '
f'include: "{valid_platform_str}".')

# sensors
if not sensor or sensor not in EE_PRODUCTS[platform]:
valid_sensor_str = '", "'.join(EE_PRODUCTS[platform].keys())
return HttpResponseBadRequest(f'The "sensor" parameter is required. Valid sensors for the "{platform}" '
f'platform include: "{valid_sensor_str}".')

# product
if not product or product not in EE_PRODUCTS[platform][sensor]:
valid_product_str = '", "'.join(EE_PRODUCTS[platform][sensor].keys())
return HttpResponseBadRequest(f'The "product" parameter is required. Valid products for the "{platform} '
f'{sensor}" sensor include: "{valid_product_str}".')

selected_product = EE_PRODUCTS[platform][sensor][product]

# index
# if index not provided, get default index from product properties
if not index:
index = selected_product['index']

# if index is still None (not defined for the product) it is not supported currently
if index is None:
return HttpResponseBadRequest(
f'Retrieving time series for "{platform} {sensor} {product}" is not supported at this time.'
)

# get valid dates for selected product
product_dates = compute_dates_for_product(selected_product)

# assign default start date if not provided
if not start_date_str:
start_date_str = product_dates['default_start_date']

# assign default start date if not provided
if not end_date_str:
end_date_str = product_dates['default_end_date']

# convert to datetime objects for validation
try:
start_date_dt = dt.datetime.strptime(start_date_str, '%Y-%m-%d')
end_date_dt = dt.datetime.strptime(end_date_str, '%Y-%m-%d')
except ValueError:
return HttpResponseBadRequest(
'Invalid date format. Please use "YYYY-MM-DD".'
)

beg_valid_date_range = dt.datetime.strptime(product_dates['beg_valid_date_range'], '%Y-%m-%d')
end_valid_date_range = dt.datetime.strptime(product_dates['end_valid_date_range'], '%Y-%m-%d')

# start_date in valid range
if start_date_dt < beg_valid_date_range or start_date_dt > end_valid_date_range:
return HttpResponseBadRequest(
f'The date {start_date_str} is not a valid "start_date" for "{platform} {sensor} {product}". '
f'It must occur between {product_dates["beg_valid_date_range"]} '
f'and {product_dates["end_valid_date_range"]}.'
)

# end_date in valid range
if end_date_dt < beg_valid_date_range or end_date_dt > end_valid_date_range:
return HttpResponseBadRequest(
f'The date {end_date_str} is not a valid "end_date" for "{platform} {sensor} {product}". '
f'It must occur between {product_dates["beg_valid_date_range"]} '
f'and {product_dates["end_valid_date_range"]}.'
)

# start_date before end_date
if start_date_dt > end_date_dt:
return HttpResponseBadRequest(
f'The "start_date" must occur before the "end_date". Dates given: '
f'start_date = {start_date_str}; end_date = {end_date_str}.'
)

# reducer
valid_reducers = ('median', 'mosaic', 'mode', 'mean', 'min', 'max', 'sum', 'count', 'product')
if reducer not in valid_reducers:
valid_reducer_str = '", "'.join(valid_reducers)
return HttpResponseBadRequest(
f'The value "{reducer}" is not valid for parameter "reducer". '
f'Must be one of: "{valid_reducer_str}". Defaults to "median" '
f'if not given.'
)

# orient
valid_orient_vals = ('dict', 'list', 'series', 'split', 'records', 'index')
if orient not in valid_orient_vals:
valid_orient_str = '", "'.join(valid_orient_vals)
return HttpResponseBadRequest(
f'The value "{orient}" is not valid for parameter "orient". '
f'Must be one of: "{valid_orient_str}". Defaults to "dict" '
f'if not given.'
)

# scale
try:
scale = float(scale_str)
except ValueError:
return HttpResponseBadRequest(
f'The "scale" parameter must be a valid number, but "{scale_str}" was given.'
)

# geometry
bad_geometry_msg = 'The "geometry" parameter is required and must be a valid geojson string.'
if not geometry_str:
return HttpResponseBadRequest(bad_geometry_msg)

try:
geometry = geojson.loads(geometry_str)
except JSONDecodeError:
return HttpResponseBadRequest(bad_geometry_msg)

try:
time_series = get_time_series_from_image_collection(
platform=platform,
sensor=sensor,
product=product,
index_name=index,
scale=scale,
geometry=geometry,
date_from=start_date_str,
date_to=end_date_str,
reducer=reducer,
orient=orient
)
except ValueError as e:
return HttpResponseBadRequest(str(e))
except Exception:
log.exception('An unexpected error occurred during execution of get_time_series_from_image_collection.')
return HttpResponseServerError('An unexpected error occurred. Please review your parameters and try again.')

# compose response object.
response_data = {
'time_series': time_series,
'parameters': {
'platform': platform,
'sensor': sensor,
'product': product,
'index': index,
'start_date': start_date_str,
'end_date': end_date_str,
'reducer': reducer,
'orient': orient,
'scale': scale,
'geometry': geometry
}
}

return JsonResponse(response_data)
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,15 @@
from tethys_sdk.gizmos import SelectInput, DatePicker, Button, MapView, MVView, PlotlyView, MVDraw
from tethys_sdk.permissions import login_required
from tethys_sdk.workspaces import user_workspace
from .helpers import generate_figure, find_shapefile, write_boundary_shapefile, prep_boundary_dir
from .gee.methods import get_image_collection_asset, get_time_series_from_image_collection, upload_shapefile_to_gee, \
from ..helpers import generate_figure, find_shapefile, write_boundary_shapefile, prep_boundary_dir, \
compute_dates_for_product
from ..gee.methods import get_image_collection_asset, get_time_series_from_image_collection, upload_shapefile_to_gee, \
get_boundary_fc_props_for_user
from .gee.products import EE_PRODUCTS
from ..gee.products import EE_PRODUCTS

log = logging.getLogger(f'tethys.apps.{__name__}')


@login_required()
def home(request):
"""
Controller for the app home page.
"""
context = {}
return render(request, 'earth_engine/home.html', context)


@login_required()
def about(request):
"""
Controller for the app about page.
"""
context = {}
return render(request, 'earth_engine/about.html', context)


@login_required()
@user_workspace
def viewer(request, user_workspace):
Expand Down Expand Up @@ -85,19 +68,8 @@ def viewer(request, user_workspace):
options=product_options
)

# Hardcode initial end date to today (since all of our datasets extend to present)
today = dt.datetime.today()
initial_end_date = today.strftime('%Y-%m-%d')

# Initial start date will a set number of days before the end date
# NOTE: This assumes the start date of the dataset is at least 30+ days prior to today
initial_end_date_dt = dt.datetime.strptime(initial_end_date, '%Y-%m-%d')
initial_start_date_dt = initial_end_date_dt - dt.timedelta(days=30)
initial_start_date = initial_start_date_dt.strftime('%Y-%m-%d')

# Build date controls
first_product_start_date = first_product.get('start_date', None)
first_product_end_date = first_product.get('end_date', None) or initial_end_date
# Get initial default dates and date ranges for date picker controls
first_product_dates = compute_dates_for_product(first_product)

start_date = DatePicker(
name='start_date',
Expand All @@ -106,9 +78,9 @@ def viewer(request, user_workspace):
start_view='decade',
today_button=True,
today_highlight=True,
start_date=first_product_start_date,
end_date=first_product_end_date,
initial=initial_start_date,
start_date=first_product_dates['beg_valid_date_range'],
end_date=first_product_dates['end_valid_date_range'],
initial=first_product_dates['default_start_date'],
autoclose=True
)

Expand All @@ -119,9 +91,9 @@ def viewer(request, user_workspace):
start_view='decade',
today_button=True,
today_highlight=True,
start_date=first_product_start_date,
end_date=first_product_end_date,
initial=initial_end_date,
start_date=first_product_dates['beg_valid_date_range'],
end_date=first_product_dates['end_valid_date_range'],
initial=first_product_dates['default_end_date'],
autoclose=True
)

Expand Down
10 changes: 7 additions & 3 deletions tethysapp/earth_engine/gee/methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def get_image_collection_asset(request, platform, sensor, product, date_from=Non


def get_time_series_from_image_collection(platform, sensor, product, index_name, scale=30, geometry=None,
date_from=None, date_to=None, reducer='median'):
date_from=None, date_to=None, reducer='median', orient='df'):
"""
Derive time series at given geometry.
"""
Expand All @@ -103,7 +103,7 @@ def get_time_series_from_image_collection(platform, sensor, product, index_name,
collection_name = ee_product['collection']

if not isinstance(geometry, geojson.GeometryCollection):
raise ValueError('Geometry must be a valid geojson.GeometryCollection')
raise ValueError('Geometry must be a valid GeoJSON GeometryCollection.')

for geom in geometry.geometries:
log.debug(f'Computing Time Series for Geometry of Type: {geom.type}')
Expand Down Expand Up @@ -146,7 +146,11 @@ def get_index(image):
values = index_collection_agg.getInfo()
log.debug('Values acquired.')
df = pd.DataFrame(values, columns=['Time', index_name.replace("_", " ")])
time_series.append(df)

if orient == 'df':
time_series.append(df)
else:
time_series.append(df.to_dict(orient=orient))

except EEException:
log.exception('An error occurred while attempting to retrieve the time series.')
Expand Down
Loading

0 comments on commit 8f7c71a

Please sign in to comment.