diff --git a/bmsapp/urls.py b/bmsapp/urls.py index 6b4b7858e2254cb59e5c204062a2ff3170440e63..b5378fec855ab95689c526d994a1595fee027e26 100644 --- a/bmsapp/urls.py +++ b/bmsapp/urls.py @@ -4,8 +4,9 @@ URLs for the BMS Application from django.conf.urls import url from django.urls import re_path -from . import views -from . import views_api_v1 +from bmsapp import views +from bmsapp import views_api_v1 +from bmsapp import views_api_v2 # Could work on simplifying many of these by using the new "path" function urlpatterns = [ @@ -35,9 +36,14 @@ urlpatterns = [ # Views related to the API, version 1 re_path(r'^api/v1/version/$', views_api_v1.api_version), re_path(r'^api/v1/readings/(.+)/$', views_api_v1.sensor_readings), - re_path(r'^api/v1/readings/$', views_api_v1.sensor_readings_multiple), re_path(r'^api/v1/sensors/$', views_api_v1.sensor_list), + # Views related to the API, version 2 + re_path(r'^api/v2/version/$', views_api_v2.api_version), + re_path(r'^api/v2/readings/$', views_api_v2.sensor_readings_multiple), + re_path(r'^api/v2/sensors/$', views_api_v2.sensors), + re_path(r'^api/v2/buildings/$', views_api_v2.buildings), + # catches URLs that don't match the above patterns. Assumes they give a template name to render. re_path(r'^([^.]+)/$', views.wildcard, name='wildcard'), ] diff --git a/bmsapp/views_api_v1.py b/bmsapp/views_api_v1.py index b75bf2d1e78bb1f0b5903ac0f88d27866582296f..22a8a0ffacb4a56d7a1ea2899acf93a36a2193c5 100644 --- a/bmsapp/views_api_v1.py +++ b/bmsapp/views_api_v1.py @@ -324,149 +324,6 @@ def sensor_readings(request, sensor_id): } return JsonResponse(result, status=500) -def sensor_readings_multiple(request): - """API Method. Returns readings from multiple sensors, perhaps time-averaged - and filtered by a date/time range. - - Parameters - ---------- - request: Django request object - - The 'request' object can have the following query parameters: - sensor_id: The Sensor ID of a sensor to include. This parameter can occur - multiple times to request data from multiple sensors. - start_ts: (optional) A date/time indicating the earliest reading to return. - If not present, the earliest reading in the database is returned. - Format is a string date/time, interpretable by dateutil.parser.parse. - end_ts: (optional) A date/time indicating the latest reading to return. - If not present records through the latest record in the database are returned. - Format is a string date/time, interpretable by dateutil.parser.parse. - timezone: (optional) A timezone name, present in the pytz.timezone database - see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones - e.g. "US/Alaska". The timestamps for the sensor readings are returned - consistent with this timezone. Also, 'start_ts' and 'end_ts' are interpreted - as being in this timezone. If this parameter is not provided, the timezone of - the first building associated with the requested sensor is used. If no - building is associated with the sensor, UTC is the assumed timezone. - averaging: (optional) If provided, sensor readings are averaged into evenly spaced - time intervals as indicated by this parameter. The 'averaging' time interval - must be provided as a string such as '4H' (4 hours), '2D' (2 days), or any interval - describable by Pandas time offset notation: - http://pandas.pydata.org/pandas-docs/stable/timeseries.html#offset-aliases - label_offset: (optional) Only used if an 'averaging' parameter is provided. This - parameter controls what point in the time averaging interval is used to produce - the timestamp for the returned reading. 'label_offset' uses the same format as - 'averaging', i.e. Pandas time offset notation, and the value is the time distance - from the start of averaging interval to the location of the timestamp. For example, - a value of '30min' means place the timestamp 30 minutes after the start of the - interval. - If no 'label_offset' is provided, the 'label_offset' is assumed to be 0, i.e. - the starting edge of the averaging interval is marked by the timestamp. Note - that the convention in all of the BMON timeseries plots is to place the timestamp - at the *center* of the averaging interval; that is *not* the default in this - function because of the difficulty in automatically calculating the proper - label_offset for the middle of the interval. - - Returns - ------- - A JSON response containing an indicator of success or failure, the readings organized - how they would be exported from a Pandas DataFrame using the "to_json(orient='split')" - method, and the timezone of the timestamps returned. - - """ - try: - - db = bmsdata.BMSdata() # reading database - - messages = {} # used to store input validity messages. - - # get the list of requested sensors - sensor_ids = request.GET.getlist('sensor_id') - - # must be at least one sensor. - if len(sensor_ids) == 0: - messages['sensor_id'] = 'There must be at least one requested sensor.' - - # check to see if any are invalid - sensors_not_valid = [] - for sensor_id in sensor_ids: - if not db.sensor_id_exists(sensor_id): - sensors_not_valid.append(sensor_id) - if len(sensors_not_valid): - messages['sensor_id'] = f"Sensors {', '.join(sensors_not_valid)} not present in the reading database." - - # Check the input parameters and get the values - param_messages, start_ts, end_ts, timezone, averaging, label_offset = \ - check_sensor_reading_params(request) - messages.update(param_messages) - - # check for extra, improper query parameters - messages.update(invalid_query_params(request, - ['sensor_id', 'timezone', 'start_ts', 'end_ts', 'averaging', 'label_offset'])) - - if messages: - # Input errors occurred - return fail_payload(messages) - - # if there is no requested timezone (or an invalid one), use the - # the most common timezone from the buildings associated with the list of sensors. - if timezone is None: - timezone = pytz.timezone('UTC') # default timezone if no valid building tz present - tzs = [] - for sensor in sensor_ids: - for bldg in sensor_info(sensor)['buildings']: - tzs.append(bldg['timezone']) - most_common_tz = Counter(tzs).most_common(1) - if most_common_tz: - tz_name, tz_count = most_common_tz[0] - try: - timezone = pytz.timezone(tz_name) - except: - # stick with default - pass - - # record the name of the final timezone - tz_name = str(timezone) - - # ---- Get the Sensor Readings - # if start and end timestamps are present, convert to Unix Epoch values - if start_ts: - ts_aware = timezone.localize(start_ts) - start_ts = ts_aware.timestamp() - - if end_ts: - ts_aware = timezone.localize(end_ts) - end_ts = ts_aware.timestamp() - - # get the sensor readings - df = db.dataframeForMultipleIDs(sensor_ids, start_ts=start_ts, end_ts=end_ts, tz=timezone) - - # if averaging is requested, do it! - if averaging and len(df) > 0: - df = df.resample(rule = averaging, loffset = label_offset, label = 'left').mean().dropna() - - # make a dictionary that is formatted with orientation 'split', which is the most - # compact form to send the DataFrame - result = { - 'status': 'success', - 'data': { - 'readings': df.to_dict(orient='split'), - 'reading_timezone': tz_name, - } - } - - return JsonResponse(result) - - except Exception as e: - # A processing error occurred. - _logger.exception('Error retrieving sensor readings.') - result = { - 'status': 'error', - 'message': str(e) - } - return JsonResponse(result, status=500) - - def sensor_list(request): """API Method. Returns a list of all the sensors in the reading database, including sensor properties, if available. diff --git a/bmsapp/views_api_v2.py b/bmsapp/views_api_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..a5a663c400838ac3bc77d62257df63c128ed5c24 --- /dev/null +++ b/bmsapp/views_api_v2.py @@ -0,0 +1,183 @@ +"""Version 2.x of the API. Relies on views from API v1. +""" +import logging +from collections import Counter + +import pytz +from django.http import JsonResponse +from dateutil.parser import parse + +from bmsapp import models +from bmsapp.readingdb import bmsdata +from bmsapp.views_api_v1 import ( + fail_payload, + invalid_query_params, + sensor_info, + check_sensor_reading_params +) + +# Version number of this API +API_VERSION = 2.0 + +# Make a logger for this module +_logger = logging.getLogger('bms.' + __name__) + +def api_version(request): + """API method that returns the version number of the API + """ + result = { + 'status': 'success', + 'data': { + 'version': API_VERSION, + } + } + + return JsonResponse(result) + +def sensor_readings_multiple(request): + """API Method. Returns readings from multiple sensors, perhaps time-averaged + and filtered by a date/time range. + + Parameters + ---------- + request: Django request object + + The 'request' object can have the following query parameters: + sensor_id: The Sensor ID of a sensor to include. This parameter can occur + multiple times to request data from multiple sensors. + start_ts: (optional) A date/time indicating the earliest reading to return. + If not present, the earliest reading in the database is returned. + Format is a string date/time, interpretable by dateutil.parser.parse. + end_ts: (optional) A date/time indicating the latest reading to return. + If not present records through the latest record in the database are returned. + Format is a string date/time, interpretable by dateutil.parser.parse. + timezone: (optional) A timezone name, present in the pytz.timezone database + see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + e.g. "US/Alaska". The timestamps for the sensor readings are returned + consistent with this timezone. Also, 'start_ts' and 'end_ts' are interpreted + as being in this timezone. If this parameter is not provided, the timezone of + the first building associated with the requested sensor is used. If no + building is associated with the sensor, UTC is the assumed timezone. + averaging: (optional) If provided, sensor readings are averaged into evenly spaced + time intervals as indicated by this parameter. The 'averaging' time interval + must be provided as a string such as '4H' (4 hours), '2D' (2 days), or any interval + describable by Pandas time offset notation: + http://pandas.pydata.org/pandas-docs/stable/timeseries.html#offset-aliases + label_offset: (optional) Only used if an 'averaging' parameter is provided. This + parameter controls what point in the time averaging interval is used to produce + the timestamp for the returned reading. 'label_offset' uses the same format as + 'averaging', i.e. Pandas time offset notation, and the value is the time distance + from the start of averaging interval to the location of the timestamp. For example, + a value of '30min' means place the timestamp 30 minutes after the start of the + interval. + If no 'label_offset' is provided, the 'label_offset' is assumed to be 0, i.e. + the starting edge of the averaging interval is marked by the timestamp. Note + that the convention in all of the BMON timeseries plots is to place the timestamp + at the *center* of the averaging interval; that is *not* the default in this + function because of the difficulty in automatically calculating the proper + label_offset for the middle of the interval. + + Returns + ------- + A JSON response containing an indicator of success or failure, the readings organized + how they would be exported from a Pandas DataFrame using the "to_json(orient='split')" + method, and the timezone of the timestamps returned. + + """ + try: + + db = bmsdata.BMSdata() # reading database + + messages = {} # used to store input validity messages. + + # get the list of requested sensors + sensor_ids = request.GET.getlist('sensor_id') + + # must be at least one sensor. + if len(sensor_ids) == 0: + messages['sensor_id'] = 'There must be at least one requested sensor.' + + # check to see if any are invalid + sensors_not_valid = [] + for sensor_id in sensor_ids: + if not db.sensor_id_exists(sensor_id): + sensors_not_valid.append(sensor_id) + if len(sensors_not_valid): + messages['sensor_id'] = f"Sensors {', '.join(sensors_not_valid)} not present in the reading database." + + # Check the input parameters and get the values + param_messages, start_ts, end_ts, timezone, averaging, label_offset = \ + check_sensor_reading_params(request) + messages.update(param_messages) + + # check for extra, improper query parameters + messages.update(invalid_query_params(request, + ['sensor_id', 'timezone', 'start_ts', 'end_ts', 'averaging', 'label_offset'])) + + if messages: + # Input errors occurred + return fail_payload(messages) + + # if there is no requested timezone (or an invalid one), use the + # the most common timezone from the buildings associated with the list of sensors. + if timezone is None: + timezone = pytz.timezone('UTC') # default timezone if no valid building tz present + tzs = [] + for sensor in sensor_ids: + for bldg in sensor_info(sensor)['buildings']: + tzs.append(bldg['timezone']) + most_common_tz = Counter(tzs).most_common(1) + if most_common_tz: + tz_name, tz_count = most_common_tz[0] + try: + timezone = pytz.timezone(tz_name) + except: + # stick with default + pass + + # record the name of the final timezone + tz_name = str(timezone) + + # ---- Get the Sensor Readings + # if start and end timestamps are present, convert to Unix Epoch values + if start_ts: + ts_aware = timezone.localize(start_ts) + start_ts = ts_aware.timestamp() + + if end_ts: + ts_aware = timezone.localize(end_ts) + end_ts = ts_aware.timestamp() + + # get the sensor readings + df = db.dataframeForMultipleIDs(sensor_ids, start_ts=start_ts, end_ts=end_ts, tz=timezone) + + # if averaging is requested, do it! + if averaging and len(df) > 0: + df = df.resample(rule = averaging, loffset = label_offset, label = 'left').mean().dropna() + + # make a dictionary that is formatted with orientation 'split', which is the most + # compact form to send the DataFrame + result = { + 'status': 'success', + 'data': { + 'readings': df.to_dict(orient='split'), + 'reading_timezone': tz_name, + } + } + + return JsonResponse(result) + + except Exception as e: + # A processing error occurred. + _logger.exception('Error retrieving sensor readings.') + result = { + 'status': 'error', + 'message': str(e) + } + return JsonResponse(result, status=500) + +def sensors(request): + pass + +def buildings(request): + pass