Merged master
diff --git a/xos/api/examples/cord/add_device_to_subscriber.sh b/xos/api/examples/cord/add_device_to_subscriber.sh
new file mode 100755
index 0000000..260b652
--- /dev/null
+++ b/xos/api/examples/cord/add_device_to_subscriber.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+source ./config.sh
+source ./util.sh
+
+ACCOUNT_NUM=1238
+MAC="19:28:37:46:55"
+
+SUBSCRIBER_ID=$(lookup_account_num $ACCOUNT_NUM)
+if [[ $? != 0 ]]; then
+    exit -1
+fi
+
+DATA=$(cat <<EOF
+{"mac": "$MAC"}
+EOF
+)
+
+curl -H "Accept: application/json; indent=4" -H "Content-Type: application/json" -u $AUTH -X POST -d "$DATA" $HOST/api/tenant/cord/subscriber/$SUBSCRIBER_ID/devices/
diff --git a/xos/api/examples/cord/all.sh b/xos/api/examples/cord/all.sh
new file mode 100644
index 0000000..88fce47
--- /dev/null
+++ b/xos/api/examples/cord/all.sh
@@ -0,0 +1,28 @@
+echo add_subscriber
+./add_subscriber.sh
+echo -e "\n update_subscriber"
+./update_subscriber.sh
+echo -e "\n add_volt_to_subscriber"
+./add_volt_to_subscriber.sh
+echo -e "\n get_subscriber"
+./get_subscriber.sh
+echo -e "\n delete_volt_from_subscriber"
+./delete_volt_from_subscriber.sh
+echo -e "\n add_device_to_subscriber"
+./add_device_to_subscriber.sh
+echo -e "\n set_subscriber_device_feature"
+./set_subscriber_device_feature.sh
+echo -e "\n set_subscriber_device_identity"
+./set_subscriber_device_identity.sh
+echo -e "\n get_subscriber_device_feature"
+./get_subscriber_device_feature.sh
+echo -e "\n get_subscriber_device_identity"
+./get_subscriber_device_identity.sh
+echo -e "\n get_subscriber"
+./get_subscriber.sh
+echo -e "\n list_subscriber_devices"
+./list_subscriber_devices.sh
+echo -e "\n delete_subscriber_device"
+./delete_subscriber_device.sh
+echo -e "\n delete_subscriber"
+./delete_subscriber.sh
diff --git a/xos/api/examples/cord/delete_subscriber_device.sh b/xos/api/examples/cord/delete_subscriber_device.sh
new file mode 100755
index 0000000..13c357f
--- /dev/null
+++ b/xos/api/examples/cord/delete_subscriber_device.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+source ./config.sh
+source ./util.sh
+
+ACCOUNT_NUM=1238
+MAC="19:28:37:46:55"
+
+SUBSCRIBER_ID=$(lookup_account_num $ACCOUNT_NUM)
+if [[ $? != 0 ]]; then
+    exit -1
+fi
+
+curl -H "Accept: application/json; indent=4" -H "Content-Type: application/json" -u $AUTH -X DELETE $HOST/api/tenant/cord/subscriber/$SUBSCRIBER_ID/devices/$MAC/
diff --git a/xos/api/examples/cord/get_subscriber_device_feature.sh b/xos/api/examples/cord/get_subscriber_device_feature.sh
new file mode 100755
index 0000000..7c02c05
--- /dev/null
+++ b/xos/api/examples/cord/get_subscriber_device_feature.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+source ./config.sh
+source ./util.sh
+
+ACCOUNT_NUM=1238
+MAC="19:28:37:46:55"
+
+SUBSCRIBER_ID=$(lookup_account_num $ACCOUNT_NUM)
+if [[ $? != 0 ]]; then
+    exit -1
+fi
+
+curl -H "Accept: application/json; indent=4" -H "Content-Type: application/json" -u $AUTH -X GET $HOST/api/tenant/cord/subscriber/$SUBSCRIBER_ID/devices/$MAC/features/uplink_speed/
diff --git a/xos/api/examples/cord/get_subscriber_device_identity.sh b/xos/api/examples/cord/get_subscriber_device_identity.sh
new file mode 100755
index 0000000..e5cff59
--- /dev/null
+++ b/xos/api/examples/cord/get_subscriber_device_identity.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+source ./config.sh
+source ./util.sh
+
+ACCOUNT_NUM=1238
+MAC="19:28:37:46:55"
+
+SUBSCRIBER_ID=$(lookup_account_num $ACCOUNT_NUM)
+if [[ $? != 0 ]]; then
+    exit -1
+fi
+
+curl -H "Accept: application/json; indent=4" -H "Content-Type: application/json" -u $AUTH -X GET $HOST/api/tenant/cord/subscriber/$SUBSCRIBER_ID/devices/$MAC/identity/name/
diff --git a/xos/api/examples/cord/set_subscriber_device_feature.sh b/xos/api/examples/cord/set_subscriber_device_feature.sh
new file mode 100755
index 0000000..73d84b0
--- /dev/null
+++ b/xos/api/examples/cord/set_subscriber_device_feature.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+source ./config.sh
+source ./util.sh
+
+ACCOUNT_NUM=1238
+MAC="19:28:37:46:55"
+
+SUBSCRIBER_ID=$(lookup_account_num $ACCOUNT_NUM)
+if [[ $? != 0 ]]; then
+    exit -1
+fi
+
+DATA=$(cat <<EOF
+{"uplink_speed": 111111}
+EOF
+)
+
+curl -H "Accept: application/json; indent=4" -H "Content-Type: application/json" -u $AUTH -X PUT -d "$DATA" $HOST/api/tenant/cord/subscriber/$SUBSCRIBER_ID/devices/$MAC/features/uplink_speed/
diff --git a/xos/api/examples/cord/set_subscriber_device_identity.sh b/xos/api/examples/cord/set_subscriber_device_identity.sh
new file mode 100755
index 0000000..faaeba1
--- /dev/null
+++ b/xos/api/examples/cord/set_subscriber_device_identity.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+source ./config.sh
+source ./util.sh
+
+ACCOUNT_NUM=1238
+MAC="19:28:37:46:55"
+
+SUBSCRIBER_ID=$(lookup_account_num $ACCOUNT_NUM)
+if [[ $? != 0 ]]; then
+    exit -1
+fi
+
+DATA=$(cat <<EOF
+{"name": "foo"}
+EOF
+)
+
+curl -H "Accept: application/json; indent=4" -H "Content-Type: application/json" -u $AUTH -X PUT -d "$DATA" $HOST/api/tenant/cord/subscriber/$SUBSCRIBER_ID/devices/$MAC/identity/name/
diff --git a/xos/api/import_methods.py b/xos/api/import_methods.py
index fbcd990..ec07be6 100644
--- a/xos/api/import_methods.py
+++ b/xos/api/import_methods.py
@@ -91,7 +91,10 @@
     # Only add an index_view if 1) the is not already an index view, and
     # 2) we have found some methods in this directory.
     if (not has_index_view) and (urlpatterns):
-        urlpatterns.append(url('^' + api_path + '/$', XOSIndexViewSet.as_view({'get': 'list'}, view_urls=view_urls, subdirs=subdirs, api_path=api_path), name=api_path+"_index"))
+        # The browseable API uses the classname as the breadcrumb and page
+        # title, so try to create index views with descriptive classnames
+        viewset = type("IndexOf"+api_path.split("/")[-1].title(), (XOSIndexViewSet,), {})
+        urlpatterns.append(url('^' + api_path + '/$', viewset.as_view({'get': 'list'}, view_urls=view_urls, subdirs=subdirs, api_path=api_path), name=api_path+"_index"))
 
     return urlpatterns
 
diff --git a/xos/api/tenant/cord/subscriber.py b/xos/api/tenant/cord/subscriber.py
index b33c7ad..eab6cb3 100644
--- a/xos/api/tenant/cord/subscriber.py
+++ b/xos/api/tenant/cord/subscriber.py
@@ -87,6 +87,55 @@
     def save(self, *args, **kwargs):
         super(CordSubscriberNew, self).save(*args, **kwargs)
 
+class CordDevice(object):
+    def __init__(self, d={}, subscriber=None):
+        self.d = d
+        self.subscriber = subscriber
+
+    @property
+    def mac(self):
+        return self.d.get("mac", None)
+
+    @mac.setter
+    def mac(self, value):
+        self.d["mac"] = value
+
+    @property
+    def identity(self):
+        return {"name": self.d.get("name", None)}
+
+    @identity.setter
+    def identity(self, value):
+        self.d["name"] = value.get("name", None)
+
+    @property
+    def features(self):
+        return {"uplink_speed": self.d.get("uplink_speed", None),
+                "downlink_speed": self.d.get("downlink_speed", None)}
+
+    @features.setter
+    def features(self, value):
+        self.d["uplink_speed"] = value.get("uplink_speed", None)
+        self.d["downlink_speed"] = value.get("downlink_speed", None)
+
+    def update_features(self, value):
+        d=self.features
+        d.update(value)
+        self.features = d
+
+    def update_identity(self, value):
+        d=self.identity
+        d.update(value)
+        self.identity = d
+
+    def save(self):
+        if self.subscriber:
+            dev=self.subscriber.find_device(self.mac)
+            if dev:
+                self.subscriber.update_device(**self.d)
+            else:
+                self.subscriber.create_device(**self.d)
+
 # Add some structure to the REST API by subdividing the object into
 # features, identity, and related.
 
@@ -101,6 +150,21 @@
     account_num = serializers.CharField(required=False)
     name = serializers.CharField(required=False)
 
+class DeviceFeatureSerializer(serializers.Serializer):
+    uplink_speed = serializers.IntegerField(required=False)
+    downlink_speed = serializers.IntegerField(required=False)
+
+class DeviceIdentitySerializer(serializers.Serializer):
+    name = serializers.CharField(required=False)
+
+class DeviceSerializer(serializers.Serializer):
+    mac = serializers.CharField(required=True)
+    identity = DeviceIdentitySerializer(required=False)
+    features = DeviceFeatureSerializer(required=False)
+
+    class Meta:
+        fields = ('mac', 'identity', 'features')
+
 class CordSubscriberSerializer(PlusModelSerializer):
         id = ReadOnlyField()
         humanReadableName = serializers.SerializerMethodField("getHumanReadableName")
@@ -132,7 +196,11 @@
     custom_serializers = {"set_features": FeatureSerializer,
                           "set_feature": FeatureSerializer,
                           "set_identities": IdentitySerializer,
-                          "set_identity": IdentitySerializer}
+                          "set_identity": IdentitySerializer,
+                          "get_devices": DeviceSerializer,
+                          "add_device": DeviceSerializer,
+                          "get_device_feature": DeviceFeatureSerializer,
+                          "set_device_feature": DeviceFeatureSerializer}
 
     @classmethod
     def get_urlpatterns(self, api_path="^"):
@@ -142,6 +210,11 @@
         patterns.append( self.detail_url("identity/$", {"get": "get_identities", "put": "set_identities"}, "identities") )
         patterns.append( self.detail_url("identity/(?P<identity>[a-zA-Z0-9\-_]+)/$", {"get": "get_identity", "put": "set_identity"}, "get_identity") )
 
+        patterns.append( self.detail_url("devices/$", {"get": "get_devices", "post": "add_device"}, "devicees") )
+        patterns.append( self.detail_url("devices/(?P<mac>[a-zA-Z0-9\-_:]+)/$", {"get": "get_device", "delete": "delete_device"}, "getset_device") )
+        patterns.append( self.detail_url("devices/(?P<mac>[a-zA-Z0-9\-_:]+)/features/(?P<feature>[a-zA-Z0-9\-_]+)/$", {"get": "get_device_feature", "put": "set_device_feature"}, "getset_device_feature") )
+        patterns.append( self.detail_url("devices/(?P<mac>[a-zA-Z0-9\-_:]+)/identity/(?P<identity>[a-zA-Z0-9\-_]+)/$", {"get": "get_device_identity", "put": "set_device_identity"}, "getset_device_identity") )
+
         patterns.append( url(self.api_path + "account_num_lookup/(?P<account_num>[0-9\-]+)/$", self.as_view({"get": "account_num_detail"}), name="account_num_detail") )
 
         patterns.append( url(self.api_path + "ssidmap/(?P<ssid>[0-9\-]+)/$", self.as_view({"get": "ssiddetail"}), name="ssiddetail") )
@@ -208,6 +281,82 @@
         subscriber.save()
         return Response({identity: IdentitySerializer(subscriber.identity).data[identity]})
 
+    def get_devices(self, request, pk=None):
+        subscriber = self.get_object()
+        result = []
+        for device in subscriber.devices:
+            device = CordDevice(device, subscriber)
+            result.append(DeviceSerializer(device).data)
+        return Response(result)
+
+    def add_device(self, request, pk=None):
+        subscriber = self.get_object()
+        ser = DeviceSerializer(subscriber.devices, data=request.data)
+        ser.is_valid(raise_exception = True)
+        newdevice = CordDevice(subscriber.create_device(**ser.validated_data), subscriber)
+        subscriber.save()
+        return Response(DeviceSerializer(newdevice).data)
+
+    def get_device(self, request, pk=None, mac=None):
+        subscriber = self.get_object()
+        device = subscriber.find_device(mac)
+        if not device:
+            return Response("Failed to find device %s" % mac, status=status.HTTP_404_NOT_FOUND)
+        return Response(DeviceSerializer(CordDevice(device, subscriber)).data)
+
+    def delete_device(self, request, pk=None, mac=None):
+        subscriber = self.get_object()
+        device = subscriber.find_device(mac)
+        if not device:
+            return Response("Failed to find device %s" % mac, status=status.HTTP_404_NOT_FOUND)
+        subscriber.delete_device(mac)
+        subscriber.save()
+        return Response("Okay")
+
+    def get_device_feature(self, request, pk=None, mac=None, feature=None):
+        subscriber = self.get_object()
+        device = subscriber.find_device(mac)
+        if not device:
+            return Response("Failed to find device %s" % mac, status=status.HTTP_404_NOT_FOUND)
+        return Response({feature: DeviceFeatureSerializer(CordDevice(device, subscriber).features).data[feature]})
+
+    def set_device_feature(self, request, pk=None, mac=None, feature=None):
+        subscriber = self.get_object()
+        device = subscriber.find_device(mac)
+        if not device:
+            return Response("Failed to find device %s" % mac, status=status.HTTP_404_NOT_FOUND)
+        if [feature] != request.data.keys():
+             raise serializers.ValidationError("feature %s does not match keys in request body (%s)" % (feature, ",".join(request.data.keys())))
+        device = CordDevice(device, subscriber)
+        ser = DeviceFeatureSerializer(device.features, data=request.data)
+        ser.is_valid(raise_exception = True)
+        device.update_features(ser.validated_data)
+        device.save()
+        subscriber.save()
+        return Response({feature: DeviceFeatureSerializer(device.features).data[feature]})
+
+    def get_device_identity(self, request, pk=None, mac=None, identity=None):
+        subscriber = self.get_object()
+        device = subscriber.find_device(mac)
+        if not device:
+            return Response("Failed to find device %s" % mac, status=status.HTTP_404_NOT_FOUND)
+        return Response({identity: DeviceIdentitySerializer(CordDevice(device, subscriber).identity).data[identity]})
+
+    def set_device_identity(self, request, pk=None, mac=None, identity=None):
+        subscriber = self.get_object()
+        device = subscriber.find_device(mac)
+        if not device:
+            return Response("Failed to find device %s" % mac, status=status.HTTP_404_NOT_FOUND)
+        if [identity] != request.data.keys():
+             raise serializers.ValidationError("identity %s does not match keys in request body (%s)" % (feature, ",".join(request.data.keys())))
+        device = CordDevice(device, subscriber)
+        ser = DeviceIdentitySerializer(device.identity, data=request.data)
+        ser.is_valid(raise_exception = True)
+        device.update_identity(ser.validated_data)
+        device.save()
+        subscriber.save()
+        return Response({identity: DeviceIdentitySerializer(device.identity).data[identity]})
+
     def account_num_detail(self, pk=None, account_num=None):
         object_list = CordSubscriberNew.get_tenant_objects().all()
         object_list = [x for x in object_list if x.service_specific_id == account_num]
diff --git a/xos/core/xoslib/methods/cordsubscriber.py b/xos/core/xoslib/methods/cordsubscriber.py
index f0bee26..0be9b33 100644
--- a/xos/core/xoslib/methods/cordsubscriber.py
+++ b/xos/core/xoslib/methods/cordsubscriber.py
@@ -118,7 +118,7 @@
     def get(self, request, format=None):
         instances=[]
         for subscriber in CordSubscriber.get_tenant_objects().all():
-            for user in subscriber.users:
+            for user in subscriber.devices:
                 instances.append( serialize_user(subscriber, user) )
 
         return Response(instances)
@@ -140,7 +140,7 @@
     def get(self, request, format=None, pk=0):
         parts = pk.split("-")
         subscriber = CordSubscriber.get_tenant_objects().filter(id=parts[0])
-        for user in subscriber.users:
+        for user in subscriber.devices:
             return Response( [ serialize_user(subscriber, user) ] )
         raise XOSNotFound("Failed to find user %s" % pk)
 
@@ -236,7 +236,7 @@
 
     def get_users(self, request, pk=None):
         subscriber = self.get_object()
-        return Response(subscriber.users)
+        return Response(subscriber.devices)
 
     def get_user_level(self, request, pk=None, uid=None):
         subscriber = self.get_object()
@@ -278,7 +278,7 @@
 
     def clear_users(self, request, pk=None):
         subscriber = self.get_object()
-        subscriber.users = []
+        subscriber.devices = []
         subscriber.save()
 
         return Response( "Okay" )
@@ -322,7 +322,7 @@
 
     def setup_demo_subscriber(self, subscriber):
         # nuke the users and start over
-        subscriber.users = []
+        subscriber.devices = []
         subscriber.create_user(name="Mom's PC",      mac="010203040506", level="PG_13")
         subscriber.create_user(name="Dad's PC",      mac="90E2Ba82F975", level="PG_13")
         subscriber.create_user(name="Jack's Laptop", mac="685B359D91D5", level="PG_13")
diff --git a/xos/services/cord/models.py b/xos/services/cord/models.py
index c57d9fb..48c9597 100644
--- a/xos/services/cord/models.py
+++ b/xos/services/cord/models.py
@@ -47,7 +47,7 @@
                           ("url_filter_rules", "allow all"),
                           ("url_filter_level", "PG"),
                           ("cdn_enable", False),
-                          ("users", []),
+                          ("devices", []),
                           ("is_demo_user", False),
 
                           ("uplink_speed", 1000000000),  # 1 gigabit, a reasonable default?
@@ -95,58 +95,65 @@
             raise Exception("invalid status %s" % value)
         self.set_attribute("status", value)
 
-    def find_user(self, uid):
-        uid = int(uid)
-        for user in self.users:
-            if user["id"] == uid:
-                return user
+    def find_device(self, mac):
+        for device in self.devices:
+            if device["mac"] == mac:
+                return device
         return None
 
-    def update_user(self, uid, **kwargs):
+    def update_device(self, mac, **kwargs):
         # kwargs may be "level" or "mac"
         #    Setting one of these to None will cause None to be stored in the db
-        uid = int(uid)
-        users = self.users
-        for user in users:
-            if user["id"] == uid:
+        devices = self.devices
+        for device in devices:
+            if device["mac"] == mac:
                 for arg in kwargs.keys():
-                    user[arg] = kwargs[arg]
-                    self.users = users
-                return user
-        raise ValueError("User %d not found" % uid)
+                    device[arg] = kwargs[arg]
+                self.devices = devices
+                return device
+        raise ValueError("Device with mac %s not found" % mac)
 
-    def create_user(self, **kwargs):
-        if "name" not in kwargs:
-            raise XOSMissingField("The name field is required")
+    def create_device(self, **kwargs):
+        if "mac" not in kwargs:
+            raise XOSMissingField("The mac field is required")
 
-        for user in self.users:
-            if kwargs["name"] == user["name"]:
-                raise XOSDuplicateKey("User %s already exists" % kwargs["name"])
+        if self.find_device(kwargs['mac']):
+                raise XOSDuplicateKey("Device with mac %s already exists" % kwargs["mac"])
 
-        uids = [x["id"] for x in self.users]
-        if uids:
-            uid = max(uids)+1
-        else:
-            uid = 0
-        newuser = kwargs.copy()
-        newuser["id"] = uid
+        device = kwargs.copy()
 
-        users = self.users
-        users.append(newuser)
-        self.users = users
+        devices = self.devices
+        devices.append(device)
+        self.devices = devices
 
-        return newuser
+        return device
 
-    def delete_user(self, uid):
-        uid = int(uid)
-        users = self.users
-        for user in users:
-            if user["id"]==uid:
-                users.remove(user)
-                self.users = users
+    def delete_device(self, mac):
+        devices = self.devices
+        for device in devices:
+            if device["mac"]==mac:
+                devices.remove(device)
+                self.devices = devices
                 return
 
-        raise ValueError("Users %d not found" % uid)
+        raise ValueError("Device with mac %s not found" % mac)
+
+    #--------------------------------------------------------------------------
+    # Deprecated -- devices used to be called users
+
+    def find_user(self, uid):
+        return self.find_device(uid)
+
+    def update_user(self, uid, **kwargs):
+        return self.update_device(uid, **kwargs)
+
+    def create_user(self, **kwargs):
+        return self.create_device(**kwargs)
+
+    def delete_user(self, uid):
+        return self.delete_user(uid)
+
+    # ------------------------------------------------------------------------
 
     @property
     def services(self):
diff --git a/xos/tosca/resources/CORDUser.py b/xos/tosca/resources/CORDUser.py
index 705a895..ff2dc8f 100644
--- a/xos/tosca/resources/CORDUser.py
+++ b/xos/tosca/resources/CORDUser.py
@@ -27,7 +27,7 @@
         sub = self.get_subscriber_root(throw_exception=False)
         if not sub:
            return []
-        for user in sub.users:
+        for user in sub.devices:
             if user["name"] == self.obj_name:
                 result.append(user)
         return result
@@ -43,7 +43,7 @@
         xos_args = self.get_xos_args()
         sub = self.get_subscriber_root()
 
-        sub.create_user(**xos_args)
+        sub.create_device(**xos_args)
         sub.save()
 
         self.info("Created CORDUser %s for Subscriber %s" % (self.obj_name, sub.name))