Browse Source

Merge branch 'develop' into virtualization

Jeremy Stretch 7 years ago
parent
commit
3009863877

+ 37 - 3
docs/api/overview.md

@@ -1,8 +1,42 @@
 NetBox v2.0 and later includes a full-featured REST API that allows its data model to be read and manipulated externally.
 NetBox v2.0 and later includes a full-featured REST API that allows its data model to be read and manipulated externally.
 
 
+# What is a REST API?
+
+REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP to create, retrieve, update, and delete objects from a database. (This set of operations is commonly referred to as CRUD.) Each type of operation is associated with a particular HTTP verb:
+
+* `GET`: Retrieve an object or list of objects
+* `POST`: Create an object
+* `PUT` / `PATCH`: Modify an existing object
+* `DELETE`: Delete an existing object
+
+The NetBox API represents all objects in [JavaScript Object Notation (JSON)](http://www.json.org/). This makes it very easy to interact with NetBox data on the command line with common tools. For example, we can request an IP address from NetBox and output the JSON using `curl` and `jq`. (Piping the output through `jq` isn't strictly required but makes it much easier to read.)
+
+```
+$ curl -s http://localhost:8000/api/ipam/ip-addresses/2954/ | jq '.'
+{
+  "custom_fields": {},
+  "nat_outside": null,
+  "nat_inside": null,
+  "description": "An example IP address",
+  "id": 2954,
+  "family": 4,
+  "address": "5.101.108.132/26",
+  "vrf": null,
+  "tenant": null,
+  "status": {
+    "label": "Active",
+    "value": 1
+  },
+  "role": null,
+  "interface": null
+}
+```
+
+Each attribute of the NetBox object is expressed as a field in the dictionary. Fields may include their own nested objects, as in the case of the `status` field above. Every object includes a primary key named `id` which uniquely identifies it in the database.
+
 # URL Hierarchy
 # URL Hierarchy
 
 
-NetBox's entire REST API is housed under the API root, `/api/`. The API's URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example, the provider and circuit objects are located under the "circuits" application:
+NetBox's entire API is housed under the API root at `https://<hostname>/api/`. The URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example, the provider and circuit objects are located under the "circuits" application:
 
 
 * /api/circuits/providers/
 * /api/circuits/providers/
 * /api/circuits/circuits/
 * /api/circuits/circuits/
@@ -13,9 +47,9 @@ Likewise, the site, rack, and device objects are located under the "DCIM" applic
 * /api/dcim/racks/
 * /api/dcim/racks/
 * /api/dcim/devices/
 * /api/dcim/devices/
 
 
-The full hierarchy of available endpoints can be viewed by navigating to the API root (e.g. /api/) in a web browser.
+The full hierarchy of available endpoints can be viewed by navigating to the API root in a web browser.
 
 
-Each model generally has two URLs associated with it: a list URL and a detail URL. The list URL is used to request a list of multiple objects or to create a new object. The detail URL is used to retrieve, update, or delete an existing object. All objects are referenced by their numeric primary key (ID).
+Each model generally has two views associated with it: a list view and a detail view. The list view is used to request a list of multiple objects or to create a new object. The detail view is used to retrieve, update, or delete an existing object. All objects are referenced by their numeric primary key (`id`).
 
 
 * /api/dcim/devices/ - List devices or create a new device
 * /api/dcim/devices/ - List devices or create a new device
 * /api/dcim/devices/123/ - Retrieve, update, or delete the device with ID 123
 * /api/dcim/devices/123/ - Retrieve, update, or delete the device with ID 123

+ 1 - 1
docs/installation/netbox.md

@@ -102,7 +102,7 @@ Python 2:
 As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3:
 As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3:
 
 
 ```no-highlight
 ```no-highlight
-# pip install napalm
+# pip3 install napalm
 ```
 ```
 
 
 # Configuration
 # Configuration

+ 15 - 7
docs/installation/web-server.md

@@ -1,13 +1,9 @@
-# Web Server Installation
-
 We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence.
 We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence.
 
 
 !!! info
 !!! info
     For the sake of brevity, only Ubuntu 16.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed.
     For the sake of brevity, only Ubuntu 16.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed.
 
 
-```no-highlight
+# Web Server Installation
-# apt-get install -y gunicorn supervisor
-```
 
 
 ## Option A: nginx
 ## Option A: nginx
 
 
@@ -55,7 +51,7 @@ Restart the nginx service to use the new configuration.
 # service nginx restart
 # service nginx restart
 ```
 ```
 
 
-To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-14-04).
+To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-16-04).
 
 
 ## Option B: Apache
 ## Option B: Apache
 
 
@@ -100,10 +96,16 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c
 # service apache2 restart
 # service apache2 restart
 ```
 ```
 
 
-To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-apache-with-let-s-encrypt-on-ubuntu-14-04).
+To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-apache-with-let-s-encrypt-on-ubuntu-16-04).
 
 
 # gunicorn Installation
 # gunicorn Installation
 
 
+Install gunicorn using `pip3` (Python 3) or `pip` (Python 2):
+
+```no-highlight
+# pip3 install gunicorn
+```
+
 Save the following configuration in the root netbox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
 Save the following configuration in the root netbox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
 
 
 ```no-highlight
 ```no-highlight
@@ -116,6 +118,12 @@ user = 'www-data'
 
 
 # supervisord Installation
 # supervisord Installation
 
 
+Install supervisor:
+
+```no-highlight
+# apt-get install -y supervisor
+```
+
 Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
 Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
 
 
 ```no-highlight
 ```no-highlight

+ 3 - 3
netbox/circuits/api/serializers.py

@@ -6,7 +6,7 @@ from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
 from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ModelValidationMixin
+from utilities.api import ValidatedModelSerializer
 
 
 
 
 #
 #
@@ -45,7 +45,7 @@ class WritableProviderSerializer(CustomFieldModelSerializer):
 # Circuit types
 # Circuit types
 #
 #
 
 
-class CircuitTypeSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class CircuitTypeSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
@@ -111,7 +111,7 @@ class CircuitTerminationSerializer(serializers.ModelSerializer):
         ]
         ]
 
 
 
 
-class WritableCircuitTerminationSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableCircuitTerminationSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination

+ 22 - 22
netbox/dcim/api/serializers.py

@@ -15,7 +15,7 @@ from dcim.models import (
 )
 )
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer, ModelValidationMixin
+from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
 
 
 
 
 #
 #
@@ -38,7 +38,7 @@ class RegionSerializer(serializers.ModelSerializer):
         fields = ['id', 'name', 'slug', 'parent']
         fields = ['id', 'name', 'slug', 'parent']
 
 
 
 
-class WritableRegionSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableRegionSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Region
         model = Region
@@ -100,7 +100,7 @@ class NestedRackGroupSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'slug']
         fields = ['id', 'url', 'name', 'slug']
 
 
 
 
-class WritableRackGroupSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableRackGroupSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = RackGroup
         model = RackGroup
@@ -111,7 +111,7 @@ class WritableRackGroupSerializer(ModelValidationMixin, serializers.ModelSeriali
 # Rack roles
 # Rack roles
 #
 #
 
 
-class RackRoleSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class RackRoleSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
@@ -216,7 +216,7 @@ class RackReservationSerializer(serializers.ModelSerializer):
         fields = ['id', 'rack', 'units', 'created', 'user', 'description']
         fields = ['id', 'rack', 'units', 'created', 'user', 'description']
 
 
 
 
-class WritableRackReservationSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableRackReservationSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = RackReservation
         model = RackReservation
@@ -227,7 +227,7 @@ class WritableRackReservationSerializer(ModelValidationMixin, serializers.ModelS
 # Manufacturers
 # Manufacturers
 #
 #
 
 
-class ManufacturerSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class ManufacturerSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
@@ -292,7 +292,7 @@ class ConsolePortTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
         fields = ['id', 'device_type', 'name']
 
 
 
 
-class WritableConsolePortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableConsolePortTemplateSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = ConsolePortTemplate
         model = ConsolePortTemplate
@@ -311,7 +311,7 @@ class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
         fields = ['id', 'device_type', 'name']
 
 
 
 
-class WritableConsoleServerPortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
@@ -330,7 +330,7 @@ class PowerPortTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
         fields = ['id', 'device_type', 'name']
 
 
 
 
-class WritablePowerPortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritablePowerPortTemplateSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
@@ -349,7 +349,7 @@ class PowerOutletTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
         fields = ['id', 'device_type', 'name']
 
 
 
 
-class WritablePowerOutletTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritablePowerOutletTemplateSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
@@ -369,7 +369,7 @@ class InterfaceTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
         fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
 
 
 
 
-class WritableInterfaceTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableInterfaceTemplateSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
@@ -388,7 +388,7 @@ class DeviceBayTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
         fields = ['id', 'device_type', 'name']
 
 
 
 
-class WritableDeviceBayTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableDeviceBayTemplateSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = DeviceBayTemplate
         model = DeviceBayTemplate
@@ -399,7 +399,7 @@ class WritableDeviceBayTemplateSerializer(ModelValidationMixin, serializers.Mode
 # Device roles
 # Device roles
 #
 #
 
 
-class DeviceRoleSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class DeviceRoleSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
@@ -418,7 +418,7 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer):
 # Platforms
 # Platforms
 #
 #
 
 
-class PlatformSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class PlatformSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
@@ -516,7 +516,7 @@ class ConsoleServerPortSerializer(serializers.ModelSerializer):
         read_only_fields = ['connected_console']
         read_only_fields = ['connected_console']
 
 
 
 
-class WritableConsoleServerPortSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableConsoleServerPortSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
@@ -536,7 +536,7 @@ class ConsolePortSerializer(serializers.ModelSerializer):
         fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
         fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
 
 
 
 
-class WritableConsolePortSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableConsolePortSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
@@ -556,7 +556,7 @@ class PowerOutletSerializer(serializers.ModelSerializer):
         read_only_fields = ['connected_port']
         read_only_fields = ['connected_port']
 
 
 
 
-class WritablePowerOutletSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritablePowerOutletSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
@@ -576,7 +576,7 @@ class PowerPortSerializer(serializers.ModelSerializer):
         fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
         fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
 
 
 
 
-class WritablePowerPortSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritablePowerPortSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
@@ -664,7 +664,7 @@ class PeerInterfaceSerializer(serializers.ModelSerializer):
         ]
         ]
 
 
 
 
-class WritableInterfaceSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableInterfaceSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
@@ -694,7 +694,7 @@ class NestedDeviceBaySerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name']
         fields = ['id', 'url', 'name']
 
 
 
 
-class WritableDeviceBaySerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableDeviceBaySerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = DeviceBay
         model = DeviceBay
@@ -717,7 +717,7 @@ class InventoryItemSerializer(serializers.ModelSerializer):
         ]
         ]
 
 
 
 
-class WritableInventoryItemSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableInventoryItemSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
@@ -749,7 +749,7 @@ class NestedInterfaceConnectionSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'connection_status']
         fields = ['id', 'url', 'connection_status']
 
 
 
 
-class WritableInterfaceConnectionSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableInterfaceConnectionSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = InterfaceConnection
         model = InterfaceConnection

+ 12 - 0
netbox/dcim/models.py

@@ -890,6 +890,18 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
             except DeviceType.DoesNotExist:
             except DeviceType.DoesNotExist:
                 pass
                 pass
 
 
+        # Validate primary IPv4 address
+        if self.primary_ip4 and (self.primary_ip4.interface is None or self.primary_ip4.interface.device != self):
+            raise ValidationError({
+                'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4),
+            })
+
+        # Validate primary IPv6 address
+        if self.primary_ip6 and (self.primary_ip6.interface is None or self.primary_ip6.interface.device != self):
+            raise ValidationError({
+                'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6),
+            })
+
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 
         is_new = not bool(self.pk)
         is_new = not bool(self.pk)

+ 1 - 1
netbox/dcim/views.py

@@ -862,7 +862,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
         device = get_object_or_404(Device, pk=pk)
         device = get_object_or_404(Device, pk=pk)
         interfaces = Interface.objects.order_naturally(
         interfaces = Interface.objects.order_naturally(
             device.device_type.interface_ordering
             device.device_type.interface_ordering
-        ).filter(
+        ).connectable().filter(
             device=device
             device=device
         ).select_related(
         ).select_related(
             'connected_as_a', 'connected_as_b'
             'connected_as_a', 'connected_as_b'

+ 2 - 11
netbox/extras/api/customfields.py

@@ -10,6 +10,7 @@ from django.db import transaction
 from extras.models import (
 from extras.models import (
     CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue,
     CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue,
 )
 )
+from utilities.api import ValidatedModelSerializer
 
 
 
 
 #
 #
@@ -68,7 +69,7 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
         return data
         return data
 
 
 
 
-class CustomFieldModelSerializer(serializers.ModelSerializer):
+class CustomFieldModelSerializer(ValidatedModelSerializer):
     """
     """
     Extends ModelSerializer to render any CustomFields and their values associated with an object.
     Extends ModelSerializer to render any CustomFields and their values associated with an object.
     """
     """
@@ -111,16 +112,6 @@ class CustomFieldModelSerializer(serializers.ModelSerializer):
                 defaults={'serialized_value': custom_field.serialize_value(value)},
                 defaults={'serialized_value': custom_field.serialize_value(value)},
             )
             )
 
 
-    def validate(self, data):
-        """
-        Enforce model validation (see utilities.api.ModelValidationMixin)
-        """
-        model_data = data.copy()
-        model_data.pop('custom_fields', None)
-        instance = self.Meta.model(**model_data)
-        instance.clean()
-        return data
-
     def create(self, validated_data):
     def create(self, validated_data):
 
 
         custom_fields = validated_data.pop('custom_fields', None)
         custom_fields = validated_data.pop('custom_fields', None)

+ 2 - 2
netbox/extras/api/serializers.py

@@ -10,7 +10,7 @@ from extras.models import (
     ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction,
     ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction,
 )
 )
 from users.api.serializers import NestedUserSerializer
 from users.api.serializers import NestedUserSerializer
-from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ModelValidationMixin
+from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer
 
 
 
 
 #
 #
@@ -104,7 +104,7 @@ class ImageAttachmentSerializer(serializers.ModelSerializer):
         return serializer(obj.parent, context={'request': self.context['request']}).data
         return serializer(obj.parent, context={'request': self.context['request']}).data
 
 
 
 
-class WritableImageAttachmentSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class WritableImageAttachmentSerializer(ValidatedModelSerializer):
     content_type = ContentTypeFieldSerializer()
     content_type = ContentTypeFieldSerializer()
 
 
     class Meta:
     class Meta:

+ 1 - 1
netbox/extras/models.py

@@ -285,7 +285,7 @@ class TopologyMap(models.Model):
 
 
             # Add each device to the graph
             # Add each device to the graph
             devices = []
             devices = []
-            for query in device_set.split(';'):  # Split regexes on semicolons
+            for query in device_set.strip(';').split(';'):  # Split regexes on semicolons
                 devices += Device.objects.filter(name__regex=query).select_related('device_role')
                 devices += Device.objects.filter(name__regex=query).select_related('device_role')
             for d in devices:
             for d in devices:
                 bg_color = '#{}'.format(d.device_role.color)
                 bg_color = '#{}'.format(d.device_role.color)

+ 4 - 4
netbox/ipam/api/serializers.py

@@ -11,7 +11,7 @@ from ipam.models import (
     PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF,
     PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF,
 )
 )
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer, ModelValidationMixin
+from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
 
 
 
 
 #
 #
@@ -45,7 +45,7 @@ class WritableVRFSerializer(CustomFieldModelSerializer):
 # Roles
 # Roles
 #
 #
 
 
-class RoleSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class RoleSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Role
         model = Role
@@ -64,7 +64,7 @@ class NestedRoleSerializer(serializers.ModelSerializer):
 # RIRs
 # RIRs
 #
 #
 
 
-class RIRSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class RIRSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = RIR
         model = RIR
@@ -303,7 +303,7 @@ class ServiceSerializer(serializers.ModelSerializer):
         fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
         fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
 
 
 
 
-# TODO: Figure out how to use ModelValidationMixin with ManyToManyFields. Calling clean() yields a ValueError.
+# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
 class WritableServiceSerializer(serializers.ModelSerializer):
 class WritableServiceSerializer(serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:

+ 2 - 3
netbox/ipam/tables.py

@@ -45,9 +45,9 @@ ROLE_ACTIONS = """
 
 
 PREFIX_LINK = """
 PREFIX_LINK = """
 {% if record.has_children %}
 {% if record.has_children %}
-    <span style="padding-left: {{ record.depth }}0px "><i class="fa fa-caret-right"></i></a>
+    <span class="text-nowrap" style="padding-left: {{ record.depth }}0px "><i class="fa fa-caret-right"></i></a>
 {% else %}
 {% else %}
-    <span style="padding-left: {{ record.depth }}9px">
+    <span class="text-nowrap" style="padding-left: {{ record.depth }}9px">
 {% endif %}
 {% endif %}
     <a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
     <a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
 </span>
 </span>
@@ -80,7 +80,6 @@ IPADDRESS_LINK = """
 IPADDRESS_DEVICE = """
 IPADDRESS_DEVICE = """
 {% if record.interface %}
 {% if record.interface %}
     <a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
     <a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
-    ({{ record.interface.name }})
 {% else %}
 {% else %}
     &mdash;
     &mdash;
 {% endif %}
 {% endif %}

+ 3 - 2
netbox/netbox/settings.py

@@ -13,7 +13,7 @@ except ImportError:
     )
     )
 
 
 
 
-VERSION = '2.1.3-dev'
+VERSION = '2.1.4-dev'
 
 
 # Import required configuration parameters
 # Import required configuration parameters
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
@@ -222,6 +222,7 @@ SECRETS_MIN_PUBKEY_SIZE = 2048
 # Django REST framework (API)
 # Django REST framework (API)
 REST_FRAMEWORK_VERSION = VERSION[0:3]  # Use major.minor as API version
 REST_FRAMEWORK_VERSION = VERSION[0:3]  # Use major.minor as API version
 REST_FRAMEWORK = {
 REST_FRAMEWORK = {
+    'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
     'DEFAULT_AUTHENTICATION_CLASSES': (
     'DEFAULT_AUTHENTICATION_CLASSES': (
         'rest_framework.authentication.SessionAuthentication',
         'rest_framework.authentication.SessionAuthentication',
         'utilities.api.TokenAuthentication',
         'utilities.api.TokenAuthentication',
@@ -234,9 +235,9 @@ REST_FRAMEWORK = {
         'utilities.api.TokenPermissions',
         'utilities.api.TokenPermissions',
     ),
     ),
     'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
     'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
-    'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
     'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
     'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
     'PAGE_SIZE': PAGINATE_COUNT,
     'PAGE_SIZE': PAGINATE_COUNT,
+    'VIEW_NAME_FUNCTION': 'utilities.api.get_view_name',
 }
 }
 
 
 # Django debug toolbar
 # Django debug toolbar

+ 2 - 2
netbox/secrets/api/serializers.py

@@ -5,14 +5,14 @@ from rest_framework.validators import UniqueTogetherValidator
 
 
 from dcim.api.serializers import NestedDeviceSerializer
 from dcim.api.serializers import NestedDeviceSerializer
 from secrets.models import Secret, SecretRole
 from secrets.models import Secret, SecretRole
-from utilities.api import ModelValidationMixin
+from utilities.api import ValidatedModelSerializer
 
 
 
 
 #
 #
 # SecretRoles
 # SecretRoles
 #
 #
 
 
-class SecretRoleSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class SecretRoleSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = SecretRole
         model = SecretRole

+ 9 - 3
netbox/templates/dcim/device_status.html

@@ -80,13 +80,19 @@ $(document).ready(function() {
             $('#model').html(json['get_facts']['model']);
             $('#model').html(json['get_facts']['model']);
             $('#serial_number').html(json['get_facts']['serial_number']);
             $('#serial_number').html(json['get_facts']['serial_number']);
             $('#os_version').html(json['get_facts']['os_version']);
             $('#os_version').html(json['get_facts']['os_version']);
-            $('#uptime').html(json['get_facts']['uptime']);
+            // Calculate uptime
+            var uptime = json['get_facts']['uptime'];
+            console.log(uptime);
+            var uptime_days = Math.floor(uptime / 86400);
+            var uptime_hours = Math.floor(uptime % 86400 / 3600);
+            var uptime_minutes = Math.floor(uptime % 3600 / 60);
+            $('#uptime').html(uptime_days + "d " + uptime_hours + "h " + uptime_minutes + "m");
             $.each(json['get_environment']['cpu'], function(name, obj) {
             $.each(json['get_environment']['cpu'], function(name, obj) {
                 var row="<tr><td>" + name + "</td><td>" + obj['%usage'] + "%</td></tr>";
                 var row="<tr><td>" + name + "</td><td>" + obj['%usage'] + "%</td></tr>";
                 $("#cpu").after(row)
                 $("#cpu").after(row)
             });
             });
-            $('#memory').after("<tr><td>Used</td><td>" + json['get_environment']['memory']['used_ram'] + "MB</td></tr>");
+            $('#memory').after("<tr><td>Used</td><td>" + json['get_environment']['memory']['used_ram'] + "</td></tr>");
-            $('#memory').after("<tr><td>Available</td><td>" + json['get_environment']['memory']['available_ram'] + "MB</td></tr>");
+            $('#memory').after("<tr><td>Available</td><td>" + json['get_environment']['memory']['available_ram'] + "</td></tr>");
             $.each(json['get_environment']['temperature'], function(name, obj) {
             $.each(json['get_environment']['temperature'], function(name, obj) {
                 var style = "success";
                 var style = "success";
                 if (obj['is_alert']) {
                 if (obj['is_alert']) {

+ 33 - 0
netbox/templates/dcim/rack_list.html

@@ -25,3 +25,36 @@
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}
+
+{% block javascript %}
+<script type="text/javascript">
+$(document).ready(function() {
+
+    var site_list = $('#id_site');
+    var rack_group_list = $('#id_group_id');
+
+    // Update rack group and rack options based on selected site
+    site_list.change(function() {
+        var selected_sites = $(this).val();
+        if (selected_sites) {
+
+            // Update rack group options
+            rack_group_list.empty();
+            $.ajax({
+                url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
+                dataType: 'json',
+                success: function (response, status) {
+                    $.each(response["results"], function (index, group) {
+                        var option = $("<option></option>").attr("value", group.id).text(group.name);
+                        rack_group_list.append(option);
+                    });
+                }
+            });
+
+        }
+    });
+
+});
+</script>
+{% endblock %}
+

+ 2 - 2
netbox/tenancy/api/serializers.py

@@ -4,14 +4,14 @@ from rest_framework import serializers
 
 
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from utilities.api import ModelValidationMixin
+from utilities.api import ValidatedModelSerializer
 
 
 
 
 #
 #
 # Tenant groups
 # Tenant groups
 #
 #
 
 
-class TenantGroupSerializer(ModelValidationMixin, serializers.ModelSerializer):
+class TenantGroupSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = TenantGroup
         model = TenantGroup

+ 35 - 12
netbox/utilities/api.py

@@ -8,7 +8,8 @@ from rest_framework.compat import is_authenticated
 from rest_framework.exceptions import APIException
 from rest_framework.exceptions import APIException
 from rest_framework.pagination import LimitOffsetPagination
 from rest_framework.pagination import LimitOffsetPagination
 from rest_framework.permissions import BasePermission, DjangoModelPermissions, SAFE_METHODS
 from rest_framework.permissions import BasePermission, DjangoModelPermissions, SAFE_METHODS
-from rest_framework.serializers import Field, ValidationError
+from rest_framework.serializers import Field, ModelSerializer, ValidationError
+from rest_framework.views import get_view_name as drf_get_view_name
 
 
 from users.models import Token
 from users.models import Token
 
 
@@ -80,6 +81,21 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
 # Serializers
 # Serializers
 #
 #
 
 
+class ValidatedModelSerializer(ModelSerializer):
+    """
+    Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation.
+    """
+    def validate(self, attrs):
+        if self.instance is None:
+            instance = self.Meta.model(**attrs)
+        else:
+            instance = self.instance
+            for k, v in attrs.items():
+                setattr(instance, k, v)
+        instance.clean()
+        return attrs
+
+
 class ChoiceFieldSerializer(Field):
 class ChoiceFieldSerializer(Field):
     """
     """
     Represent a ChoiceField as {'value': <DB value>, 'label': <string>}.
     Represent a ChoiceField as {'value': <DB value>, 'label': <string>}.
@@ -121,17 +137,6 @@ class ContentTypeFieldSerializer(Field):
 # Mixins
 # Mixins
 #
 #
 
 
-class ModelValidationMixin(object):
-    """
-    Enforce a model's validation through clean() when validating serializer data. This is necessary to ensure we're
-    employing the same validation logic via both forms and the API.
-    """
-    def validate(self, attrs):
-        instance = self.Meta.model(**attrs)
-        instance.clean()
-        return attrs
-
-
 class WritableSerializerMixin(object):
 class WritableSerializerMixin(object):
     """
     """
     Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).
     Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).
@@ -192,3 +197,21 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
                 pass
                 pass
 
 
         return self.default_limit
         return self.default_limit
+
+
+#
+# Miscellaneous
+#
+
+def get_view_name(view_cls, suffix=None):
+    """
+    Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`.
+    """
+    if hasattr(view_cls, 'queryset'):
+        name = view_cls.queryset.model._meta.verbose_name
+        name = ' '.join([w[0].upper() + w[1:] for w in name.split()])  # Capitalize each word
+        if suffix:
+            name = "{} {}".format(name, suffix)
+        return name
+
+    return drf_get_view_name(view_cls, suffix)

+ 1 - 1
netbox/utilities/forms.py

@@ -459,7 +459,7 @@ class BootstrapMixin(forms.BaseForm):
             if field.widget.__class__ not in exempt_widgets:
             if field.widget.__class__ not in exempt_widgets:
                 css = field.widget.attrs.get('class', '')
                 css = field.widget.attrs.get('class', '')
                 field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip()
                 field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip()
-            if field.required:
+            if field.required and not isinstance(field.widget, forms.FileInput):
                 field.widget.attrs['required'] = 'required'
                 field.widget.attrs['required'] = 'required'
             if 'placeholder' not in field.widget.attrs:
             if 'placeholder' not in field.widget.attrs:
                 field.widget.attrs['placeholder'] = field.label
                 field.widget.attrs['placeholder'] = field.label