PageRenderTime 56ms CodeModel.GetById 3ms app.highlight 45ms RepoModel.GetById 1ms app.codeStats 0ms

/xbeewifiapp/apps/dashboard/views.py

https://github.com/ecybertech/xbeewificloudkit
Python | 1106 lines | 867 code | 71 blank | 168 comment | 63 complexity | 4bf26001b803470a8036b1e6fb6dc553 MD5 | raw file
   1#
   2# This Source Code Form is subject to the terms of the Mozilla Public License,
   3# v. 2.0. If a copy of the MPL was not distributed with this file, You can
   4# obtain one at http://mozilla.org/MPL/2.0/.
   5#
   6# Copyright (c) 2013 Digi International Inc., All Rights Reserved.
   7#
   8
   9import logging
  10from django.shortcuts import render_to_response
  11from django.contrib.auth import get_user_model
  12from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt
  13from django.contrib.auth import login, logout, authenticate
  14from rest_framework.decorators import api_view, authentication_classes,\
  15    permission_classes
  16from rest_framework.views import APIView
  17from rest_framework.response import Response
  18from rest_framework.reverse import reverse
  19from rest_framework import viewsets
  20from rest_framework import permissions
  21from rest_framework import status
  22from models import Dashboard
  23from serializers import DashboardSerializer, UserSerializer
  24from permissions import IsOwner
  25from authentication import MonitorBasicAuthentication
  26from django.conf import settings
  27from signals import MONITOR_TOPIC_SIGNAL_MAP
  28from util import get_credentials, is_key_in_nested_dict
  29from xbeewifiapp.libs.digi.devicecloud import DeviceCloudConnector
  30from requests.exceptions import HTTPError, ConnectionError
  31import re
  32from datetime import datetime, timedelta
  33from urllib import unquote
  34from distutils.util import strtobool
  35import base64
  36from xbee import compare_config_with_stock
  37
  38logger = logging.getLogger(__name__)
  39
  40
  41# Ensure the csrf cookie is set here, client may do posts via ajax on pages
  42# with no templated form
  43@ensure_csrf_cookie
  44def placeholder(request):
  45    return render_to_response('placeholder.html')
  46
  47
  48def socket_test(request):
  49    return render_to_response('sockettest.html',
  50                              {'request': request, 'user': request.user})
  51
  52
  53# API-Related Views
  54
  55@csrf_exempt
  56@api_view(['PUT'])
  57@authentication_classes((MonitorBasicAuthentication,))
  58@permission_classes(())
  59def monitor_receiver(request):
  60    """
  61    Push Monitor endpoint - Recieves data from Device Cloud
  62    """
  63    # Because we have no permission class, any authenticated user can access
  64    # this view.
  65    # Further restrict only to the device cloud user specified in settings
  66    # Note that this User object is a one-off, where we set the credentials
  67    # directly in special basic auth class and is not persisted to the db
  68    secret_user = settings.SECRET_DEVICE_CLOUD_MONITOR_AUTH_USER
  69    secret_pass = settings.SECRET_DEVICE_CLOUD_MONITOR_AUTH_PASS
  70    if (request.user.username != secret_user
  71            or request.user.password != secret_pass):
  72        return Response(status=status.HTTP_403_FORBIDDEN)
  73
  74    logger.info('Recieved Device Cloud Push')
  75
  76    # Iterate through payload, sending messages keyed off topic
  77    try:
  78        messages = request.DATA['Document']['Msg']
  79    except KeyError:
  80        return Response(status=status.HTTP_400_BAD_REQUEST)
  81
  82    # Msg may be a list or object, depending on if multiple events.
  83    # Handle single case by making list of len 1
  84    if type(messages) is not list:
  85        messages = [messages]
  86
  87    monitor_has_listeners = False
  88    for msg in messages:
  89        try:
  90            topic_full = msg['topic']
  91        except KeyError:
  92            return Response(status=status.HTTP_400_BAD_REQUEST)
  93
  94        # reported topics come in a /##/Topic/Sub/topic/...,
  95        # grab parts we care about
  96        (topic, subtopic) = topic_full.split('/', 2)[1:]
  97        subtopic = unquote(subtopic)
  98
  99        try:
 100            signal_map = MONITOR_TOPIC_SIGNAL_MAP[topic]
 101        except KeyError:
 102            logger.warning(
 103                'No signal map exists for monitor topic %s!' % topic)
 104
 105        # Each topic may be handled differently. For example, datapoint signals
 106        # are keyed off device id
 107        signal = None
 108        args = {}
 109
 110        if topic == 'DataPoint':
 111            # Attempt to extract a device_id from the subtopic
 112            reg_pattern = "\S*(?P<dev_id>((-?([0-9A-F]{8})){4}))"
 113            match = re.match(reg_pattern, subtopic)
 114            if match:
 115                device_id = match.groupdict()['dev_id']
 116                signal = signal_map[device_id]
 117                args['device_id'] = device_id
 118                args['data'] = msg
 119            else:
 120                logger.warning(
 121                    'Error - No deviceId found in DataPoint subtopic!')
 122        elif topic == 'DeviceCore':
 123            # Device id not in topic, need to parse out from
 124            # message body directly...
 125            try:
 126                device_id = msg['DeviceCore']['devConnectwareId']
 127                signal = signal_map[device_id]
 128                args['device_id'] = device_id
 129                args['data'] = msg
 130            except KeyError:
 131                logger.warning('No DeviceId found in DeviceCore event')
 132        else:
 133            logger.warning('No handler for push topic type %s!' % topic)
 134
 135        # If we have no receivers, monitor should be marked inactive
 136        # As of 2.10, Device Cloud will retry up to 16 min apart over 24 hours,
 137        # then flag
 138        if signal is not None and len(signal.receivers):
 139            monitor_has_listeners = True
 140            logger.debug(
 141                "%d registered receivers found for this push, sending signal"
 142                % len(signal.receivers))
 143            signal.send_robust(sender=None, **args)
 144
 145    if monitor_has_listeners:
 146        logger.info('Push event with receivers handled')
 147        return Response()
 148    else:
 149        # TODO what status code to return? DC will use anything > 3xx
 150        logger.info("Received a push with no receivers, responding with 503 " +
 151                    "to make monitor inactive")
 152        return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 153
 154
 155# *************
 156
 157@api_view(['GET'])
 158def monitor_setup(request, device_id):
 159    """
 160    View to handle monitor setup for a device
 161
 162    Will query for existing monitors, create a new one if none found, else
 163    kickstart existing monitor.
 164
 165    Returns the monitor information from Device Cloud
 166    ------------------------------------------
 167    """
 168    username, password, cloud_fqdn = get_credentials(request)
 169
 170    if not username or not password or not cloud_fqdn:
 171        return Response(status=status.HTTP_400_BAD_REQUEST)
 172
 173    conn = DeviceCloudConnector(username, password, cloud_fqdn)
 174
 175    endpoint_url = reverse(monitor_receiver, request=request)
 176    # Device cloud won't allow monitors pointing to localhost, etc,
 177    # so don't even try
 178    if 'localhost' in endpoint_url or '127.0.0.1' in endpoint_url:
 179        logger.error('Rejecting attempt to create monitor to ' + endpoint_url)
 180        return Response(status=status.HTTP_400_BAD_REQUEST)
 181
 182    try:
 183        monitors = conn.get_datapoint_monitor_for_device(device_id,
 184                                                         endpoint_url)
 185
 186        if monitors['resultSize'] == "0":
 187            # No existing monitors found for this device on this account,
 188            # create a new one
 189            # NOTE: The full url is generated by information passed in the
 190            # request. If the same backend is being routed to from multiple
 191            # places (reverse proxies, etc), each will generate a different url
 192            logger.info('Creating a new DataPoint monitor for device %s'
 193                        % device_id)
 194            resp = conn.create_datapoint_monitor(
 195                device_id,
 196                endpoint_url,
 197                settings.SECRET_DEVICE_CLOUD_MONITOR_AUTH_USER,
 198                settings.SECRET_DEVICE_CLOUD_MONITOR_AUTH_PASS,
 199                description="XBee Wi-Fi Cloud Kit Monitor")
 200        else:
 201            # Should only have one monitor for a given device/topic
 202            if len(monitors['items']) > 1:
 203                logger.warning("Found multiple monitors for this device! " +
 204                               "This should not happen!")
 205
 206            monitor = monitors['items'][0]
 207            logger.info(
 208                'Found an existing DataPoint monitor for %s, kicking it'
 209                % device_id)
 210            conn.kick_monitor(monitor['monId'])
 211            # Return the original info
 212            resp = monitors
 213
 214    except HTTPError, e:
 215        return Response(status=e.response.status_code, data=e.response.text)
 216    except ConnectionError, e:
 217        return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 218
 219    return Response(data=resp)
 220
 221
 222@api_view(['GET'])
 223def monitor_devicecore_setup(request):
 224    """
 225    View to handle DeviceCore monitor setup for an user
 226
 227    Will query for existing monitors, create a new one if none found, else
 228    kickstart existing monitor.
 229
 230    Returns the monitor information from Device Cloud
 231    ------------------------------------------
 232    """
 233    username, password, cloud_fqdn = get_credentials(request)
 234
 235    if not username or not password or not cloud_fqdn:
 236        return Response(status=status.HTTP_400_BAD_REQUEST)
 237
 238    conn = DeviceCloudConnector(username, password, cloud_fqdn)
 239
 240    endpoint_url = reverse(monitor_receiver, request=request)
 241    # Device cloud won't allow monitors pointing to localhost, etc,
 242    # so don't even try
 243    if 'localhost' in endpoint_url or '127.0.0.1' in endpoint_url:
 244        logger.error('Rejecting attempt to create monitor to ' + endpoint_url)
 245        return Response(status=status.HTTP_400_BAD_REQUEST)
 246
 247    try:
 248        monitors = conn.get_devicecore_monitor(endpoint_url)
 249
 250        if monitors['resultSize'] == "0":
 251            # No existing monitors found for this device on this account,
 252            # create a new one
 253            # NOTE: The full url is generated by information passed in the
 254            # request. If the same backend is being routed to from multiple
 255            # places (reverse proxies, etc), each will generate a different
 256            # url.
 257            logger.info('Creating a new DeviceCore monitor for user %s'
 258                        % username)
 259            resp = conn.create_devicecore_monitor(
 260                endpoint_url,
 261                settings.SECRET_DEVICE_CLOUD_MONITOR_AUTH_USER,
 262                settings.SECRET_DEVICE_CLOUD_MONITOR_AUTH_PASS,
 263                description="XBee Wi-Fi Cloud Kit Monitor")
 264        else:
 265            # Should only have one monitor for a given device/topic
 266            if len(monitors['items']) > 1:
 267                logger.warning("Found multiple monitors for user %s! " +
 268                               "This should not happen!" % username)
 269
 270            monitor = monitors['items'][0]
 271            logger.info(
 272                'Found an existing DeviceCore monitor for user, kicking it')
 273            conn.kick_monitor(monitor['monId'])
 274            # Return the original info
 275            resp = monitors
 276
 277    except HTTPError, e:
 278        return Response(status=e.response.status_code, data=e.response.text)
 279    except ConnectionError, e:
 280        return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 281
 282    return Response(data=resp)
 283
 284
 285@api_view(['GET'])
 286@permission_classes(())
 287def api_root(request, format=None):
 288    """
 289    Welcome to the XBee Wi-Fi Cloud Kit API Explorer
 290    ------------------------------------------------
 291
 292    From here, you can browse and interact with API resources right in your
 293    browser, using the same calls made by the frontend. Unauthenticated users
 294    will have limited visibility into the system, users can log in via the
 295    login API or via the link in the header.
 296
 297    From the API root, you will find links for the following resources:
 298
 299    * `login/logout` - Used for establishing session based authentication
 300    * `user` - View the user resource associated with your account
 301    * `devices` - Explore device information for status, data, and
 302                        configuration
 303    * `dashboards` - Explore the dashboard resource rendered by the frontend
 304
 305    """
 306    return Response({
 307        'login': reverse('api_login', request=request, format=format),
 308        'logout': reverse('api_logout', request=request, format=format),
 309        'user': reverse('deviceclouduser-list',
 310                        request=request, format=format),
 311        'dashboards': reverse('dashboard-list', request=request,
 312                              format=format),
 313        'devices': reverse('devices-list', request=request, format=format),
 314    })
 315
 316
 317@ensure_csrf_cookie
 318@api_view(['POST'])
 319@authentication_classes(())
 320@permission_classes(())
 321def login_user(request):
 322    """
 323    View to log user into the app for session-based auth
 324    ------------------------------------------
 325
 326    *POST* - form encoded data or json containing the following required
 327                fields:
 328
 329    * `username` - device cloud username
 330    * `password` - device cloud password
 331    * `cloud_fqdn` - cloud server fully qualified domain name (ex
 332                        _login.etherios.com_)
 333
 334    The following fields are optional:
 335
 336    * `persistent_session` - boolean value, default False. Specifies whether
 337                            session is remembered, or should expire when
 338                            browser is closed.
 339
 340    """
 341    try:
 342        username = request.DATA['username']
 343        password = request.DATA['password']
 344    except KeyError:
 345        return Response(status=status.HTTP_400_BAD_REQUEST)
 346
 347    # Check that values are non-empty
 348    if not username or not password:
 349        return Response(status=status.HTTP_401_UNAUTHORIZED)
 350
 351    try:
 352        cloud_fqdn = request.DATA['cloud_fqdn']
 353    except KeyError:
 354        if 'DEFAULT_CLOUD_SERVER' in settings.LIB_DIGI_DEVICECLOUD:
 355            cloud_fqdn = settings.LIB_DIGI_DEVICECLOUD['DEFAULT_CLOUD_SERVER']
 356        else:
 357            return Response(status=status.HTTP_400_BAD_REQUEST)
 358
 359    # generate combo username/cloud expected by auth
 360    usercloudid = username + \
 361        settings.LIB_DIGI_DEVICECLOUD['USERNAME_CLOUD_DELIMETER'] + cloud_fqdn
 362    if username and password and cloud_fqdn:
 363        user = authenticate(username=usercloudid,
 364                            password=password)
 365        if user is not None:
 366            login(request, user)
 367
 368            # If specified, set the session cookie to expire when user's Web
 369            # Browser is closed
 370            persist_session = request.DATA.get('persistent_session', None)
 371            if (type(persist_session) is str or
 372                    type(persist_session) is unicode):
 373                persist_session = strtobool(persist_session)
 374
 375            if not persist_session:
 376                request.session.set_expiry(0)
 377
 378            return Response()
 379        else:
 380            return Response(status=status.HTTP_401_UNAUTHORIZED)
 381    else:
 382        return Response(status=status.HTTP_400_BAD_REQUEST)
 383
 384
 385@api_view(['GET'])
 386@authentication_classes(())
 387@permission_classes(())
 388def logout_user(request):
 389    """
 390    View to log user out of the app, clearing any session data
 391    ------------------------------------------
 392    """
 393    logout(request)
 394    return Response()
 395
 396
 397class DashboardsViewSet(viewsets.ModelViewSet):
 398    """
 399    This endpoint presents Dashboard resources
 400    ------------------------------------------
 401
 402    The `widgets` field contains a json object defining dashboard state.
 403
 404    _Authentication Required_ - Authenticated user will have access only to
 405    Dashboards they own
 406    """
 407    serializer_class = DashboardSerializer
 408    permission_classes = (permissions.IsAuthenticated, IsOwner,)
 409
 410    def get_queryset(self):
 411        # Filter query to only dashboards user owns
 412        return Dashboard.objects.filter(owner=self.request.user)
 413
 414    def pre_save(self, obj):
 415        obj.owner = self.request.user
 416
 417
 418class UserViewSet(viewsets.ReadOnlyModelViewSet):
 419    """
 420    This endpoint presents the users in the system.
 421    ------------------------------------------
 422
 423    _Authentication Required_ - Authenticated user will only have read-only
 424    access their own profile
 425    """
 426
 427    User = get_user_model()
 428
 429    serializer_class = UserSerializer
 430    permission_classes = (permissions.IsAuthenticated,)
 431
 432    def get_queryset(self):
 433        # Filter query to show currently authenticated user
 434        return self.User.objects.filter(
 435            username=self.request.user.username,
 436            cloud_fqdn=self.request.user.cloud_fqdn)
 437
 438
 439class DevicesList(APIView):
 440    """
 441    View to list devices belonging to the user
 442    ------------------------------------------
 443
 444    *GET* - List devices from the user's Device Cloud account
 445
 446    *POST* - Provision a new device to user's Device Cloud account.
 447                Required field:
 448
 449    `mac` - MAC address of the module to provision
 450
 451     _Authentication Required_
 452    """
 453
 454    def get(self, request, format=None):
 455        """
 456        Return a list of Xbee WiFi devices on the authenticated user's Device
 457        Cloud account
 458        """
 459        username, password, cloud_fqdn = get_credentials(request)
 460
 461        if not username or not password or not cloud_fqdn:
 462            return Response(status=status.HTTP_400_BAD_REQUEST)
 463
 464        conn = DeviceCloudConnector(username, password, cloud_fqdn)
 465
 466        try:
 467            devices = conn.get_device_list(
 468                device_types=settings.SUPPORTED_DEVICE_TYPES)
 469        except HTTPError, e:
 470            return Response(status=e.response.status_code,
 471                            data=e.response.text)
 472        except ConnectionError, e:
 473            return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 474
 475        if 'items' in devices:
 476            # Save a cached local list of devices for this user, which we can
 477            # check to control access to signals, etc
 478            request.session['user_devices'] = \
 479                [device['devConnectwareId'] for device in devices['items']]
 480            # Inject a url to each item pointing to the individual view for
 481            # that device
 482            for device in devices['items']:
 483                device['url'] = reverse(
 484                    'devices-detail',
 485                    kwargs={'device_id': str(device['devConnectwareId'])},
 486                    request=request)
 487
 488        return Response(data=devices)
 489
 490    def post(self, request, format=None):
 491        """
 492        Provision a new device to authenticated user's Device Cloud account
 493        """
 494        username, password, cloud_fqdn = get_credentials(request)
 495
 496        if not username or not password or not cloud_fqdn:
 497            return Response(status=status.HTTP_400_BAD_REQUEST)
 498
 499        conn = DeviceCloudConnector(username, password, cloud_fqdn)
 500
 501        if 'mac' in request.DATA:
 502            mac = request.DATA['mac']
 503        else:
 504            return Response(status.HTTP_400_BAD_REQUEST,
 505                            data="MAC address field required")
 506
 507        try:
 508            resp = conn.provision_device(mac)
 509        except HTTPError, e:
 510            return Response(status=e.response.status_code,
 511                            data=e.response.text)
 512        except ConnectionError, e:
 513            return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 514
 515        # clear out the session device list cache
 516        if 'user_devices' in request.session:
 517            del request.session['user_devices']
 518
 519        return Response(data=resp)
 520
 521
 522class DevicesDetail(APIView):
 523    """
 524    View to show details for an individual devices
 525    ------------------------------------------
 526
 527    *GET* - Show DeviceCore data for the the specified device
 528
 529     _Authentication Required_
 530    """
 531
 532    def get(self, request, device_id=None, format=None):
 533        """
 534        Return a single Xbee WiFi devices, and provide links to data and config
 535        views
 536        """
 537
 538        username, password, cloud_fqdn = get_credentials(request)
 539
 540        if not username or not password or not cloud_fqdn:
 541            return Response(status=status.HTTP_400_BAD_REQUEST)
 542
 543        conn = DeviceCloudConnector(username, password, cloud_fqdn)
 544
 545        try:
 546            device = conn.get_device_list(device_id=device_id)
 547        except HTTPError, e:
 548            return Response(status=e.response.status_code,
 549                            data=e.response.text)
 550        except ConnectionError, e:
 551            return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 552
 553        if 'items' in device:
 554            # Inject a url pointing to the config and data views
 555            for dev in device['items']:
 556                dev['io-url'] = reverse(
 557                    'device-io',
 558                    kwargs={'device_id': str(dev['devConnectwareId'])},
 559                    request=request)
 560                dev['config-url'] = reverse(
 561                    'device-config',
 562                    kwargs={'device_id': str(dev['devConnectwareId'])},
 563                    request=request)
 564                dev['serial-url'] = reverse(
 565                    'device-serial',
 566                    kwargs={'device_id': str(dev['devConnectwareId'])},
 567                    request=request)
 568                dev['data-url'] = reverse(
 569                    'device-datastream-list',
 570                    kwargs={'device_id': str(dev['devConnectwareId'])},
 571                    request=request)
 572
 573        return Response(data=device)
 574
 575
 576class DeviceIO(APIView):
 577    """
 578    View to handle changing I/O state of the device.
 579    ----------------------------------------
 580
 581    *PUT* - Change output levels. Takes name/state pairs.
 582        When name matches DIO#, state is expected to be a boolean for new
 583        output level (ex. {"DIO0":true}).
 584        For convenience, two character AT commands can also be used for
 585        changing persistent InputOutput settings via rci set_setting command,
 586        state is passed as-is (ex. {"M0":"0x100"})
 587    """
 588    # Fast string stripping, see http://stackoverflow.com/a/1280823
 589    delchars = ''.join(c for c in map(chr, range(256)) if not c.isalnum())
 590
 591    def put(self, request, device_id):
 592
 593        io_command_pairs = []
 594        io_set_setting_pairs = []
 595        io_serial_data_values = []
 596
 597        # Sanitize and sort inputs
 598        for name, value in request.DATA.iteritems():
 599            # May get '/' seperators, mixed case, etc. Strip out non-alphanum
 600            # chars, make uppercase.
 601            name = str(name).translate(None, self.delchars).upper()
 602
 603            # Check for DIO and extract the bit position of this pin
 604            match = re.match(r"(DIO)(?P<bit>[0-9]+)", name)
 605            if match:
 606                bit = match.group('bit')
 607
 608                # Convert boolean-ish values to True/False
 609                if type(value) == str or type(value) == unicode:
 610                    try:
 611                        value = bool(strtobool(value))
 612                    except ValueError:
 613                        # Try to catch "high"/"low"
 614                        if value.lower() == "high":
 615                            value = True
 616                        elif value.lower() == "low":
 617                            value = False
 618                        else:
 619                            return Response(status=status.HTTP_400_BAD_REQUEST)
 620                io_command_pairs.append((int(bit), value))
 621            # Else see if it looks like a traditional AT command
 622            elif len(name) == 2:  # M0, etc AT command, used for PWM setting
 623                # Some commands require hex strings, others integers, others
 624                # arbitrary text...ug
 625                try:
 626                    # Hex
 627                    if name in ['M0', 'M1', 'IC', 'PR', 'PD', 'DS']:
 628                        val_str = hex(int(value))
 629                    elif (name in ['LT', 'RP', 'IR', 'IF'] or
 630                            name.startswith('T') or name.startswith('Q')):
 631                        val_str = str(int(value))
 632                    else:
 633                        # Use as is
 634                        val_str = str(value)
 635                except ValueError:
 636                    return Response(status=status.HTTP_400_BAD_REQUEST)
 637
 638                io_set_setting_pairs.append((name, val_str))
 639            # Handle serial output. Currently don't support sending to
 640            # different targets, so combine all
 641            # serial messages into a single payload
 642            elif name.startswith("SERIAL"):
 643                io_serial_data_values.append(value)
 644            else:
 645                # Unknown command provided
 646                return Response(status=status.HTTP_400_BAD_REQUEST)
 647
 648        username, password, cloud_fqdn = get_credentials(request)
 649
 650        if not username or not password or not cloud_fqdn:
 651            return Response(status=status.HTTP_400_BAD_REQUEST)
 652
 653        conn = DeviceCloudConnector(username, password, cloud_fqdn)
 654
 655        resp = {}
 656
 657        try:
 658            # For IO command, need to generate two bitmasks - enable and level
 659            if len(io_command_pairs):
 660                enable_mask = 0
 661                output_mask = 0
 662                for bit, value in io_command_pairs:
 663                    enable_mask |= 1 << int(bit)
 664                    output_mask |= value << int(bit)
 665                resp = conn.set_output(device_id,
 666                                       hex(enable_mask), hex(output_mask))
 667
 668            if len(io_set_setting_pairs):
 669                # Because these settings belong to a single known group, we can
 670                # construct the request for the user
 671                new_settings = {'InputOutput': {}}
 672                for name, val in io_set_setting_pairs:
 673                    new_settings['InputOutput'][name] = val
 674                resp = conn.set_device_settings(device_id, new_settings)
 675
 676            if len(io_serial_data_values):
 677                data = "".join(io_serial_data_values)
 678                data = base64.b64encode(data)
 679                resp = conn.send_serial_data(device_id, data)
 680
 681        except HTTPError, e:
 682            return Response(status=e.response.status_code,
 683                            data=e.response.text)
 684        except ConnectionError, e:
 685            return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 686
 687        if is_key_in_nested_dict(resp, 'error'):
 688            return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR,
 689                            data=resp)
 690
 691        return Response(data=resp)
 692
 693
 694class DeviceConfig(APIView):
 695    """
 696    View to show settings configuration for an individual device
 697    If a settings group is specified (e.g. /config/<group>), query will be
 698    limited in scope to just that group
 699    ------------------------------------------
 700
 701    *GET* - Show SCI/RCI query_setting for the the specified device.
 702
 703      - Query params: ?cache="true/false" - Whether to return cached settings,
 704        or query the device. Default false.
 705
 706    *PUT* - Set device settings via SCI/RCI set_setting. Accepts a json object
 707            of the form `{"setting_group" : {"key":"value", ...}, ...}`
 708
 709     _Authentication Required_
 710    """
 711
 712    def get(self, request, device_id=None, format=None):
 713        """
 714        Query Device Cloud to return current device settings
 715        """
 716        username, password, cloud_fqdn = get_credentials(request)
 717
 718        if not username or not password or not cloud_fqdn:
 719            return Response(status=status.HTTP_400_BAD_REQUEST)
 720
 721        conn = DeviceCloudConnector(username, password, cloud_fqdn)
 722
 723        cache = bool(strtobool(request.QUERY_PARAMS.get('cache', 'False')))
 724
 725        try:
 726            settings = conn.get_device_settings(device_id, cache=cache)
 727        except HTTPError, e:
 728            return Response(status=e.response.status_code,
 729                            data=e.response.text)
 730        except ConnectionError, e:
 731            return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 732
 733        # If we're at the top level, for each setting group found, inject a
 734        # link to that subview
 735        try:
 736            device = settings['sci_reply']['send_message']['device']
 737            settings_resp = device['rci_reply']['query_setting']
 738            for group_name, dict in settings_resp.items():
 739                settings_resp[str(group_name) + '-url'] = reverse(
 740                    'device-config-group',
 741                    kwargs={
 742                        'device_id': device_id,
 743                        'settings_group': str(group_name)
 744                    },
 745                    request=request)
 746        except KeyError:
 747            settings_resp = {}
 748
 749        # We need a bit of extra parsing to determine if the request really
 750        # succeeded. Device cloud always returns 200, errors are shown as
 751        # elements in the body. Our (overly) simple scheme will check for the
 752        # presence of any error element
 753        if is_key_in_nested_dict(settings, 'error'):
 754            return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR,
 755                            data=settings)
 756
 757        # Compare the config with Cloud Kit stock settings, return diff
 758        resp = settings_resp
 759        settings['config-kit-stock-values'] = compare_config_with_stock(resp)
 760        settings['config-kit-stock-apply-url'] = reverse(
 761            'device-config-stock',
 762            kwargs={'device_id': device_id},
 763            request=request)
 764
 765        return Response(data=settings)
 766
 767    def put(self, request, device_id=None):
 768        # Basic sanity check on the values we're trying to send
 769        for group, settings in request.DATA.items():
 770            if not type(settings) == dict:
 771                return Response(status=status.HTTP_400_BAD_REQUEST)
 772            else:
 773                for key, val in settings.items():
 774                    if not isinstance(val, (int, float, bool, str, unicode)):
 775                        return Response(status=status.HTTP_400_BAD_REQUEST)
 776
 777        username, password, cloud_fqdn = get_credentials(request)
 778
 779        if not username or not password or not cloud_fqdn:
 780            return Response(status=status.HTTP_400_BAD_REQUEST)
 781
 782        conn = DeviceCloudConnector(username, password, cloud_fqdn)
 783
 784        try:
 785            settings = conn.set_device_settings(device_id,
 786                                                settings=request.DATA)
 787        except HTTPError, e:
 788            return Response(status=e.response.status_code,
 789                            data=e.response.text)
 790        except ConnectionError, e:
 791            return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 792
 793        # We need a bit of extra parsing to determine if the request really
 794        # succeeded. Device cloud always returns 200, errors are shown as
 795        # elements in the body. Our (overly) simple scheme will check for the
 796        # presence of any error element
 797        if is_key_in_nested_dict(settings, 'error'):
 798            return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR,
 799                            data=settings)
 800
 801        return Response(data=settings)
 802
 803
 804class DeviceConfigGroup(APIView):
 805    """
 806    View to show a specific settings group configuration for an individual
 807    device
 808    ------------------------------------------
 809
 810    *GET* - Show SCI/RCI query_setting for the the specified device & settings
 811            group
 812
 813      - Query params: ?cache="true/false" - Whether to return cached settings,
 814        or query the device. Default false.
 815
 816    *PUT* - Set device settings via SCI/RCI set_setting. Takes key/value pairs
 817            of setting name/value
 818
 819     _Authentication Required_
 820    """
 821
 822    def get(self, request, device_id, settings_group, format=None):
 823        """
 824        Query Device Cloud to return current device settings
 825        """
 826        username, password, cloud_fqdn = get_credentials(request)
 827
 828        if not username or not password or not cloud_fqdn:
 829            return Response(status=status.HTTP_400_BAD_REQUEST)
 830
 831        conn = DeviceCloudConnector(username, password, cloud_fqdn)
 832
 833        cache = bool(strtobool(request.QUERY_PARAMS.get('cache', 'False')))
 834
 835        try:
 836            settings = conn.get_device_settings(
 837                device_id, settings_group=settings_group, cache=cache)
 838        except HTTPError, e:
 839            return Response(status=e.response.status_code,
 840                            data=e.response.text)
 841        except ConnectionError, e:
 842            return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 843
 844        # We need a bit of extra parsing to determine if the request really
 845        # succeeded. Device cloud always returns 200, errors are shown as
 846        # elements in the body. Our (overly) simple scheme will check for the
 847        # presence of any error element
 848        if is_key_in_nested_dict(settings, 'error'):
 849            return Response(status=status.HTTP_504_GATEWAY_TIMEOUT,
 850                            data=settings)
 851
 852        return Response(data=settings)
 853
 854    def put(self, request, device_id, settings_group, format=None):
 855        """
 856        Apply new settings to the device
 857        """
 858        # Because these settings belong to a single known group, we can
 859        # construct the request for the user
 860        new_settings = {settings_group: request.DATA}
 861
 862        username, password, cloud_fqdn = get_credentials(request)
 863
 864        if not username or not password or not cloud_fqdn:
 865            return Response(status=status.HTTP_400_BAD_REQUEST)
 866
 867        conn = DeviceCloudConnector(username, password, cloud_fqdn)
 868
 869        try:
 870            settings = conn.set_device_settings(device_id, new_settings)
 871        except HTTPError, e:
 872            return Response(status=e.response.status_code,
 873                            data=e.response.text)
 874        except ConnectionError, e:
 875            return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 876
 877        # We need a bit of extra parsing to determine if the request really
 878        # succeeded. Device cloud always returns 200, errors are shown as
 879        # elements in the body. Our (overly) simple scheme will check for the
 880        # presence of any error element
 881        if is_key_in_nested_dict(settings, 'error'):
 882            return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR,
 883                            data=settings)
 884
 885        return Response(data=settings)
 886
 887
 888class DeviceConfigStock(APIView):
 889    """
 890    Apply the default Kit configuration to the XBee module
 891    ------------------------------------------
 892
 893    *PUT* - Set device settings to Kit defaults. No request content required.
 894
 895     _Authentication Required_
 896    """
 897
 898    def put(self, request, device_id=None):
 899        # First query for existing config, calculate diff
 900        username, password, cloud_fqdn = get_credentials(request)
 901
 902        if not username or not password or not cloud_fqdn:
 903            return Response(status=status.HTTP_400_BAD_REQUEST)
 904
 905        conn = DeviceCloudConnector(username, password, cloud_fqdn)
 906
 907        try:
 908            settings = conn.get_device_settings(device_id)
 909        except HTTPError, e:
 910            return Response(status=e.response.status_code,
 911                            data=e.response.text)
 912        except ConnectionError, e:
 913            return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 914
 915        # Check for any errors in query
 916        if is_key_in_nested_dict(settings, 'error'):
 917            return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR,
 918                            data=settings)
 919
 920        try:
 921            device = settings['sci_reply']['send_message']['device']
 922            settings_resp = device['rci_reply']['query_setting']
 923        except KeyError:
 924            return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR,
 925                            data=settings)
 926
 927        # Compare the config with Cloud Kit stock settings, return diff
 928        settings_diff = compare_config_with_stock(settings_resp)
 929
 930        # If there is a difference, send the new config to device
 931        if settings_diff:
 932            try:
 933                settings_response = conn.set_device_settings(
 934                    device_id, settings=settings_diff)
 935            except HTTPError, e:
 936                return Response(status=e.response.status_code,
 937                                data=e.response.text)
 938            except ConnectionError, e:
 939                return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
 940
 941            # Check for any errors in response
 942            if is_key_in_nested_dict(settings_response, 'error'):
 943                return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR,
 944                                data=settings_response)
 945
 946            return Response(data=settings_response)
 947        else:
 948            return Response(status=status.HTTP_200_OK)
 949
 950
 951class DeviceSerial(APIView):
 952    """
 953    Send data out serial port of devices
 954    ------------------------------------------
 955
 956    *POST* - Send binary data to module's serial port.
 957
 958    The following field is required:
 959
 960    * `data` - payload
 961
 962    The following field is optional:
 963
 964    * `is_base64` - Whether 'data' is Base64 encoded. Defaults to false, in
 965                    which case server will perform encoding.
 966
 967     _Authentication Required_
 968    """
 969
 970    def post(self, request, device_id):
 971        """
 972        Send data to device serial
 973        """
 974
 975        try:
 976            data = request.DATA['data']
 977        except KeyError:
 978            return Response(status=status.HTTP_400_BAD_REQUEST)
 979
 980        try:
 981            is_encoded = request.DATA['is_base64']
 982            needs_encoding = not strtobool(is_encoded)
 983        except KeyError:
 984            needs_encoding = True
 985        except ValueError:
 986            return Response(status=status.HTTP_400_BAD_REQUEST)
 987
 988        if needs_encoding:
 989            data = base64.b64encode(data)
 990
 991        username, password, cloud_fqdn = get_credentials(request)
 992
 993        if not username or not password or not cloud_fqdn:
 994            return Response(status=status.HTTP_400_BAD_REQUEST)
 995
 996        conn = DeviceCloudConnector(username, password, cloud_fqdn)
 997
 998        try:
 999            response = conn.send_serial_data(device_id, data)
1000        except HTTPError, e:
1001            return Response(status=e.response.status_code,
1002                            data=e.response.text)
1003        except ConnectionError, e:
1004            return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
1005
1006        # We need a bit of extra parsing to determine if the request really
1007        # succeeded. Device cloud always returns 200, errors are shown as
1008        # elements in the body. Our (overly) simple scheme will check for the
1009        # presence of any error element
1010        if is_key_in_nested_dict(response, 'error'):
1011            return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR,
1012                            data=response)
1013
1014        return Response(data=response)
1015
1016
1017class DevicesDatastreamList(APIView):
1018    """
1019    View to show Data Streams available for an individual device
1020    ------------------------------------------
1021
1022    *GET* - Show available Data Streams for this device
1023
1024     _Authentication Required_
1025    """
1026
1027    def get(self, request, device_id=None, format=None):
1028        """
1029        Query Device Cloud to return current device settings
1030        """
1031        username, password, cloud_fqdn = get_credentials(request)
1032
1033        if not username or not password or not cloud_fqdn:
1034            return Response(status=status.HTTP_400_BAD_REQUEST)
1035
1036        conn = DeviceCloudConnector(username, password, cloud_fqdn)
1037
1038        try:
1039            data_streams = conn.get_datastream_list(device_id=device_id)
1040        except HTTPError, e:
1041            return Response(status=e.response.status_code,
1042                            data=e.response.text)
1043        except ConnectionError, e:
1044            return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
1045
1046        if 'items' in data_streams:
1047            # Inject a url pointing to the config and data views
1048            for stream in data_streams['items']:
1049                stream['datapoint-url'] = reverse(
1050                    'device-datapoint-list',
1051                    kwargs={
1052                        'device_id': device_id,
1053                        'stream_id': str(stream['streamId'])
1054                    },
1055                    request=request)
1056
1057        return Response(data=data_streams)
1058
1059
1060class DevicesDatapointList(APIView):
1061    """
1062    View to show DataPoints in a given DataStream
1063    ------------------------------------------
1064
1065    *GET* - Show historical DataPoints for the specified stream
1066
1067    Optional Query Parameters:
1068
1069    * `startTime` - POSIX timestamp in seconds. Defaults to 5 minutes in the
1070                    past.
1071
1072     _Authentication Required_
1073    """
1074
1075    def get(self, request, device_id=None, stream_id=None, format=None):
1076        """
1077        Query Device Cloud for DataPoints
1078        """
1079        username, password, cloud_fqdn = get_credentials(request)
1080
1081        if not username or not password or not cloud_fqdn:
1082            return Response(status=status.HTTP_400_BAD_REQUEST)
1083
1084        conn = DeviceCloudConnector(username, password, cloud_fqdn)
1085
1086        # Only show the data from the last x minutes
1087        if 'startTime' in request.GET:
1088            try:
1089                time = datetime.utcfromtimestamp(
1090                    float(request.GET['startTime']))
1091            except ValueError:
1092                return Response(status=status.HTTP_400_BAD_REQUEST)
1093        else:
1094            time = datetime.utcnow() - timedelta(minutes=5)
1095        time_no_micro = time.replace(microsecond=0)
1096        iso_time = time_no_micro.isoformat()+'z'
1097
1098        try:
1099            data_points = conn.get_datapoints(stream_id, iso_time)
1100        except HTTPError, e:
1101            return Response(status=e.response.status_code,
1102                            data=e.response.text)
1103        except ConnectionError, e:
1104            return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
1105
1106        return Response(data=data_points)