Browse Source

Merge branch 'develop-2.3' into develop

Jeremy Stretch 7 years ago
parent
commit
22f17a1424
90 changed files with 4623 additions and 1131 deletions
  1. 8 0
      docs/data-model/dcim.md
  2. 9 7
      netbox/circuits/api/serializers.py
  3. 4 5
      netbox/circuits/api/views.py
  4. 16 0
      netbox/circuits/constants.py
  5. 5 0
      netbox/circuits/filters.py
  6. 21 4
      netbox/circuits/forms.py
  7. 20 0
      netbox/circuits/migrations/0010_circuit_status.py
  8. 10 2
      netbox/circuits/models.py
  9. 6 1
      netbox/circuits/tables.py
  10. 63 8
      netbox/circuits/tests/test_api.py
  11. 102 19
      netbox/dcim/api/serializers.py
  12. 3 0
      netbox/dcim/api/urls.py
  13. 39 33
      netbox/dcim/api/views.py
  14. 3 0
      netbox/dcim/apps.py
  15. 34 15
      netbox/dcim/constants.py
  16. 79 4
      netbox/dcim/filters.py
  17. 473 50
      netbox/dcim/forms.py
  18. 32 0
      netbox/dcim/migrations/0050_interface_vlan_tagging.py
  19. 22 0
      netbox/dcim/migrations/0051_rackreservation_tenant.py
  20. 44 0
      netbox/dcim/migrations/0052_virtual_chassis.py
  21. 26 0
      netbox/dcim/migrations/0053_platform_manufacturer.py
  22. 31 0
      netbox/dcim/migrations/0054_site_status_timezone_description.py
  23. 25 0
      netbox/dcim/migrations/0055_virtualchassis_ordering.py
  24. 200 36
      netbox/dcim/models.py
  25. 23 0
      netbox/dcim/signals.py
  26. 36 22
      netbox/dcim/tables.py
  27. 911 55
      netbox/dcim/tests/test_api.py
  28. 4 4
      netbox/dcim/tests/test_forms.py
  29. 24 12
      netbox/dcim/urls.py
  30. 680 247
      netbox/dcim/views.py
  31. 6 6
      netbox/extras/api/views.py
  32. 2 2
      netbox/extras/management/commands/run_inventory.py
  33. 62 4
      netbox/extras/tests/test_api.py
  34. 57 15
      netbox/ipam/api/serializers.py
  35. 92 22
      netbox/ipam/api/views.py
  36. 16 14
      netbox/ipam/filters.py
  37. 2 1
      netbox/ipam/forms.py
  38. 348 22
      netbox/ipam/tests/test_api.py
  39. 11 1
      netbox/netbox/settings.py
  40. 1 1
      netbox/project-static/css/base.css
  41. 0 7
      netbox/project-static/font-awesome-4.7.0/HELP-US-OUT.txt
  42. 66 65
      netbox/project-static/js/forms.js
  43. 0 4
      netbox/project-static/js/jquery-3.2.1.min.js
  44. 2 0
      netbox/project-static/js/jquery-3.3.1.min.js
  45. 9 2
      netbox/secrets/api/serializers.py
  46. 10 20
      netbox/secrets/api/views.py
  47. 69 10
      netbox/secrets/tests/test_api.py
  48. 1 1
      netbox/templates/_base.html
  49. 6 0
      netbox/templates/circuits/circuit.html
  50. 1 0
      netbox/templates/circuits/circuit_edit.html
  51. 55 0
      netbox/templates/dcim/bulk_rename.html
  52. 210 186
      netbox/templates/dcim/device.html
  53. 5 0
      netbox/templates/dcim/inc/device_table.html
  54. 2 2
      netbox/templates/dcim/inc/devicebay.html
  55. 0 13
      netbox/templates/dcim/inc/devicetype_component_table.html
  56. 3 3
      netbox/templates/dcim/inc/interface.html
  57. 28 0
      netbox/templates/dcim/interface_edit.html
  58. 8 0
      netbox/templates/dcim/rack.html
  59. 28 0
      netbox/templates/dcim/site.html
  60. 2 0
      netbox/templates/dcim/site_edit.html
  61. 35 0
      netbox/templates/dcim/virtualchassis_add_member.html
  62. 103 0
      netbox/templates/dcim/virtualchassis_edit.html
  63. 14 0
      netbox/templates/dcim/virtualchassis_list.html
  64. 8 0
      netbox/templates/dcim/virtualchassis_remove_member.html
  65. 4 1
      netbox/templates/inc/nav_menu.html
  66. 4 0
      netbox/templates/tenancy/tenant.html
  67. 25 28
      netbox/templates/virtualization/virtualmachine.html
  68. 2 2
      netbox/tenancy/api/serializers.py
  69. 2 4
      netbox/tenancy/api/views.py
  70. 56 4
      netbox/tenancy/tests/test_api.py
  71. 2 1
      netbox/tenancy/views.py
  72. 45 18
      netbox/utilities/api.py
  73. 7 0
      netbox/utilities/constants.py
  74. 9 2
      netbox/utilities/forms.py
  75. 1 1
      netbox/utilities/tables.py
  76. 0 0
      netbox/utilities/templates/widgets/colorselect_option.html
  77. 1 0
      netbox/utilities/templates/widgets/select_option_with_pk.html
  78. 0 0
      netbox/utilities/templates/widgets/selectwithdisabled_option.html
  79. 11 0
      netbox/utilities/templatetags/helpers.py
  80. 71 87
      netbox/utilities/views.py
  81. 6 6
      netbox/virtualization/api/serializers.py
  82. 4 6
      netbox/virtualization/api/views.py
  83. 5 5
      netbox/virtualization/constants.py
  84. 2 2
      netbox/virtualization/filters.py
  85. 4 4
      netbox/virtualization/forms.py
  86. 8 3
      netbox/virtualization/models.py
  87. 120 13
      netbox/virtualization/tests/test_api.py
  88. 4 6
      netbox/virtualization/views.py
  89. 1 0
      old_requirements.txt
  90. 14 13
      requirements.txt

+ 8 - 0
docs/data-model/dcim.md

@@ -112,3 +112,11 @@ Console ports connect only to console server ports, and power ports connect only
 Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description.
 
 Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
+
+---
+
+# Virtual Chassis
+
+A virtual chassis represents a set of devices which share a single control plane: for example, a stack of switches which are managed as a single device. Each device in the virtual chassis is assigned a position and (optionally) a priority. Exactly one device is designated the virtual chassis master: This device will typically be assigned a name, secrets, services, and other attributes related to its management.
+
+It's important to recognize the distinction between a virtual chassis and a chassis-based device. For instance, a virtual chassis is not used to model a chassis switch with removable line cards such as the Juniper EX9208, as its line cards are _not_ physically separate devices capable of operating independently.

+ 9 - 7
netbox/circuits/api/serializers.py

@@ -2,11 +2,12 @@ from __future__ import unicode_literals
 
 from rest_framework import serializers
 
+from circuits.constants import CIRCUIT_STATUS_CHOICES
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ValidatedModelSerializer
+from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
 
 
 #
@@ -19,7 +20,7 @@ class ProviderSerializer(CustomFieldModelSerializer):
         model = Provider
         fields = [
             'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
-            'custom_fields',
+            'custom_fields', 'created', 'last_updated',
         ]
 
 
@@ -37,7 +38,7 @@ class WritableProviderSerializer(CustomFieldModelSerializer):
         model = Provider
         fields = [
             'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
-            'custom_fields',
+            'custom_fields', 'created', 'last_updated',
         ]
 
 
@@ -66,14 +67,15 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer):
 
 class CircuitSerializer(CustomFieldModelSerializer):
     provider = NestedProviderSerializer()
+    status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES)
     type = NestedCircuitTypeSerializer()
     tenant = NestedTenantSerializer()
 
     class Meta:
         model = Circuit
         fields = [
-            'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
-            'custom_fields',
+            'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
+            'comments', 'custom_fields', 'created', 'last_updated',
         ]
 
 
@@ -90,8 +92,8 @@ class WritableCircuitSerializer(CustomFieldModelSerializer):
     class Meta:
         model = Circuit
         fields = [
-            'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
-            'custom_fields',
+            'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
+            'comments', 'custom_fields', 'created', 'last_updated',
         ]
 
 

+ 4 - 5
netbox/circuits/api/views.py

@@ -3,14 +3,13 @@ from __future__ import unicode_literals
 from django.shortcuts import get_object_or_404
 from rest_framework.decorators import detail_route
 from rest_framework.response import Response
-from rest_framework.viewsets import ModelViewSet
 
 from circuits import filters
 from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
 from extras.api.serializers import RenderedGraphSerializer
 from extras.api.views import CustomFieldModelViewSet
 from extras.models import Graph, GRAPH_TYPE_PROVIDER
-from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
+from utilities.api import FieldChoicesViewSet, ModelViewSet
 from . import serializers
 
 
@@ -28,7 +27,7 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
 # Providers
 #
 
-class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class ProviderViewSet(CustomFieldModelViewSet):
     queryset = Provider.objects.all()
     serializer_class = serializers.ProviderSerializer
     write_serializer_class = serializers.WritableProviderSerializer
@@ -59,7 +58,7 @@ class CircuitTypeViewSet(ModelViewSet):
 # Circuits
 #
 
-class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class CircuitViewSet(CustomFieldModelViewSet):
     queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
     serializer_class = serializers.CircuitSerializer
     write_serializer_class = serializers.WritableCircuitSerializer
@@ -70,7 +69,7 @@ class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 # Circuit Terminations
 #
 
-class CircuitTerminationViewSet(WritableSerializerMixin, ModelViewSet):
+class CircuitTerminationViewSet(ModelViewSet):
     queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
     serializer_class = serializers.CircuitTerminationSerializer
     write_serializer_class = serializers.WritableCircuitTerminationSerializer

+ 16 - 0
netbox/circuits/constants.py

@@ -1,6 +1,22 @@
 from __future__ import unicode_literals
 
 
+# Circuit statuses
+CIRCUIT_STATUS_DEPROVISIONING = 0
+CIRCUIT_STATUS_ACTIVE = 1
+CIRCUIT_STATUS_PLANNED = 2
+CIRCUIT_STATUS_PROVISIONING = 3
+CIRCUIT_STATUS_OFFLINE = 4
+CIRCUIT_STATUS_DECOMMISSIONED = 5
+CIRCUIT_STATUS_CHOICES = [
+    [CIRCUIT_STATUS_PLANNED, 'Planned'],
+    [CIRCUIT_STATUS_PROVISIONING, 'Provisioning'],
+    [CIRCUIT_STATUS_ACTIVE, 'Active'],
+    [CIRCUIT_STATUS_OFFLINE, 'Offline'],
+    [CIRCUIT_STATUS_DEPROVISIONING, 'Deprovisioning'],
+    [CIRCUIT_STATUS_DECOMMISSIONED, 'Decommissioned'],
+]
+
 # CircuitTermination sides
 TERM_SIDE_A = 'A'
 TERM_SIDE_Z = 'Z'

+ 5 - 0
netbox/circuits/filters.py

@@ -7,6 +7,7 @@ from dcim.models import Site
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from utilities.filters import NumericInFilter
+from .constants import CIRCUIT_STATUS_CHOICES
 from .models import Provider, Circuit, CircuitTermination, CircuitType
 
 
@@ -77,6 +78,10 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         label='Circuit type (slug)',
     )
+    status = django_filters.MultipleChoiceFilter(
+        choices=CIRCUIT_STATUS_CHOICES,
+        null_value=None
+    )
     tenant_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',

+ 21 - 4
netbox/circuits/forms.py

@@ -8,9 +8,10 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField,
-    SmallTextarea, SlugField,
+    APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
+    CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
 )
+from .constants import CIRCUIT_STATUS_CHOICES
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 
 
@@ -105,7 +106,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     class Meta:
         model = Circuit
         fields = [
-            'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
+            'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
             'comments',
         ]
         help_texts = {
@@ -132,6 +133,11 @@ class CircuitCSVForm(forms.ModelForm):
             'invalid_choice': 'Invalid circuit type.'
         }
     )
+    status = CSVChoiceField(
+        choices=CIRCUIT_STATUS_CHOICES,
+        required=False,
+        help_text='Operational status'
+    )
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False,
@@ -144,13 +150,16 @@ class CircuitCSVForm(forms.ModelForm):
 
     class Meta:
         model = Circuit
-        fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
+        fields = [
+            'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
+        ]
 
 
 class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
     type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
     provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
+    status = forms.ChoiceField(choices=add_blank_choice(CIRCUIT_STATUS_CHOICES), required=False, initial='')
     tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
     description = forms.CharField(max_length=100, required=False)
@@ -160,6 +169,13 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
 
 
+def circuit_status_choices():
+    status_counts = {}
+    for status in Circuit.objects.values('status').annotate(count=Count('status')).order_by('status'):
+        status_counts[status['status']] = status['count']
+    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in CIRCUIT_STATUS_CHOICES]
+
+
 class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Circuit
     q = forms.CharField(required=False, label='Search')
@@ -171,6 +187,7 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=Provider.objects.annotate(filter_count=Count('circuits')),
         to_field_name='slug'
     )
+    status = forms.MultipleChoiceField(choices=circuit_status_choices, required=False)
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
         to_field_name='slug',

+ 20 - 0
netbox/circuits/migrations/0010_circuit_status.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.9 on 2018-02-06 18:48
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0009_unicode_literals'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuit',
+            name='status',
+            field=models.PositiveSmallIntegerField(choices=[[2, 'Planned'], [3, 'Provisioning'], [1, 'Active'], [4, 'Offline'], [0, 'Deprovisioning'], [5, 'Decommissioned']], default=1),
+        ),
+    ]

+ 10 - 2
netbox/circuits/models.py

@@ -5,11 +5,12 @@ from django.db import models
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 
+from dcim.constants import STATUS_CLASSES
 from dcim.fields import ASNField
 from extras.models import CustomFieldModel, CustomFieldValue
 from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
-from .constants import *
+from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
 
 
 @python_2_unicode_compatible
@@ -89,6 +90,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
     cid = models.CharField(max_length=50, verbose_name='Circuit ID')
     provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
     type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
+    status = models.PositiveSmallIntegerField(choices=CIRCUIT_STATUS_CHOICES, default=CIRCUIT_STATUS_ACTIVE)
     tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
     install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
     commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
@@ -96,7 +98,9 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
-    csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
+    csv_headers = [
+        'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
+    ]
 
     class Meta:
         ordering = ['provider', 'cid']
@@ -113,6 +117,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
             self.cid,
             self.provider.name,
             self.type.name,
+            self.get_status_display(),
             self.tenant.name if self.tenant else None,
             self.install_date,
             self.commit_rate,
@@ -120,6 +125,9 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
             self.comments,
         )
 
+    def get_status_class(self):
+        return STATUS_CLASSES[self.status]
+
     def _get_termination(self, side):
         for ct in self.terminations.all():
             if ct.term_side == side:

+ 6 - 1
netbox/circuits/tables.py

@@ -14,6 +14,10 @@ CIRCUITTYPE_ACTIONS = """
 {% endif %}
 """
 
+STATUS_LABEL = """
+<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
+"""
+
 
 class CircuitTerminationColumn(tables.Column):
 
@@ -76,10 +80,11 @@ class CircuitTable(BaseTable):
     pk = ToggleColumn()
     cid = tables.LinkColumn(verbose_name='ID')
     provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
+    status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
     termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side')
     termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side')
 
     class Meta(BaseTable.Meta):
         model = Circuit
-        fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')
+        fields = ('pk', 'cid', 'status', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')

+ 63 - 8
netbox/circuits/tests/test_api.py

@@ -69,7 +69,7 @@ class ProviderTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('circuits-api:provider-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Provider.objects.count(), 4)
@@ -77,6 +77,32 @@ class ProviderTest(HttpStatusMixin, APITestCase):
         self.assertEqual(provider4.name, data['name'])
         self.assertEqual(provider4.slug, data['slug'])
 
+    def test_create_provider_bulk(self):
+
+        data = [
+            {
+                'name': 'Test Provider 4',
+                'slug': 'test-provider-4',
+            },
+            {
+                'name': 'Test Provider 5',
+                'slug': 'test-provider-5',
+            },
+            {
+                'name': 'Test Provider 6',
+                'slug': 'test-provider-6',
+            },
+        ]
+
+        url = reverse('circuits-api:provider-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Provider.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_provider(self):
 
         data = {
@@ -85,7 +111,7 @@ class ProviderTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(Provider.objects.count(), 3)
@@ -136,7 +162,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('circuits-api:circuittype-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(CircuitType.objects.count(), 4)
@@ -152,7 +178,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(CircuitType.objects.count(), 3)
@@ -208,7 +234,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('circuits-api:circuit-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Circuit.objects.count(), 4)
@@ -217,6 +243,35 @@ class CircuitTest(HttpStatusMixin, APITestCase):
         self.assertEqual(circuit4.provider_id, data['provider'])
         self.assertEqual(circuit4.type_id, data['type'])
 
+    def test_create_circuit_bulk(self):
+
+        data = [
+            {
+                'cid': 'TEST0004',
+                'provider': self.provider1.pk,
+                'type': self.circuittype1.pk,
+            },
+            {
+                'cid': 'TEST0005',
+                'provider': self.provider1.pk,
+                'type': self.circuittype1.pk,
+            },
+            {
+                'cid': 'TEST0006',
+                'provider': self.provider1.pk,
+                'type': self.circuittype1.pk,
+            },
+        ]
+
+        url = reverse('circuits-api:circuit-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Circuit.objects.count(), 6)
+        self.assertEqual(response.data[0]['cid'], data[0]['cid'])
+        self.assertEqual(response.data[1]['cid'], data[1]['cid'])
+        self.assertEqual(response.data[2]['cid'], data[2]['cid'])
+
     def test_update_circuit(self):
 
         data = {
@@ -226,7 +281,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(Circuit.objects.count(), 3)
@@ -293,7 +348,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('circuits-api:circuittermination-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(CircuitTermination.objects.count(), 4)
@@ -313,7 +368,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(CircuitTermination.objects.count(), 3)

+ 102 - 19
netbox/dcim/api/serializers.py

@@ -7,19 +7,20 @@ from rest_framework.validators import UniqueTogetherValidator
 
 from circuits.models import Circuit, CircuitTermination
 from dcim.constants import (
-    CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
-    RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
+    CONNECTION_STATUS_CHOICES, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES,
+    RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
 )
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site,
+    RackReservation, RackRole, Region, Site, VirtualChassis,
 )
 from extras.api.customfields import CustomFieldModelSerializer
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLAN
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
+from users.api.serializers import NestedUserSerializer
+from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer
 from virtualization.models import Cluster
 
 
@@ -55,15 +56,18 @@ class WritableRegionSerializer(ValidatedModelSerializer):
 #
 
 class SiteSerializer(CustomFieldModelSerializer):
+    status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES)
     region = NestedRegionSerializer()
     tenant = NestedTenantSerializer()
+    time_zone = TimeZoneField(required=False)
 
     class Meta:
         model = Site
         fields = [
-            'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
-            'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
-            'count_vlans', 'count_racks', 'count_devices', 'count_circuits',
+            'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
+            'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+            'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices',
+            'count_circuits',
         ]
 
 
@@ -76,12 +80,14 @@ class NestedSiteSerializer(serializers.ModelSerializer):
 
 
 class WritableSiteSerializer(CustomFieldModelSerializer):
+    time_zone = TimeZoneField(required=False)
 
     class Meta:
         model = Site
         fields = [
-            'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
-            'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields',
+            'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
+            'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+            'custom_fields', 'created', 'last_updated',
         ]
 
 
@@ -147,7 +153,7 @@ class RackSerializer(CustomFieldModelSerializer):
         model = Rack
         fields = [
             'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
-            'u_height', 'desc_units', 'comments', 'custom_fields',
+            'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
         ]
 
 
@@ -165,7 +171,7 @@ class WritableRackSerializer(CustomFieldModelSerializer):
         model = Rack
         fields = [
             'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
-            'desc_units', 'comments', 'custom_fields',
+            'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
         ]
         # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
         # prevents facility_id from being interpreted as a required field.
@@ -215,10 +221,12 @@ class RackUnitSerializer(serializers.Serializer):
 
 class RackReservationSerializer(serializers.ModelSerializer):
     rack = NestedRackSerializer()
+    user = NestedUserSerializer()
+    tenant = NestedTenantSerializer()
 
     class Meta:
         model = RackReservation
-        fields = ['id', 'rack', 'units', 'created', 'user', 'description']
+        fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
 
 
 class WritableRackReservationSerializer(ValidatedModelSerializer):
@@ -423,11 +431,12 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer):
 # Platforms
 #
 
-class PlatformSerializer(ValidatedModelSerializer):
+class PlatformSerializer(serializers.ModelSerializer):
+    manufacturer = NestedManufacturerSerializer()
 
     class Meta:
         model = Platform
-        fields = ['id', 'name', 'slug', 'napalm_driver', 'rpc_client']
+        fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
 
 
 class NestedPlatformSerializer(serializers.ModelSerializer):
@@ -438,6 +447,13 @@ class NestedPlatformSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'slug']
 
 
+class WritablePlatformSerializer(ValidatedModelSerializer):
+
+    class Meta:
+        model = Platform
+        fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
+
+
 #
 # Devices
 #
@@ -460,6 +476,16 @@ class NestedClusterSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name']
 
 
+# Cannot import NestedVirtualChassisSerializer due to circular dependency
+class DeviceVirtualChassisSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
+    master = NestedDeviceSerializer()
+
+    class Meta:
+        model = VirtualChassis
+        fields = ['id', 'url', 'master']
+
+
 class DeviceSerializer(CustomFieldModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_role = NestedDeviceRoleSerializer()
@@ -468,19 +494,21 @@ class DeviceSerializer(CustomFieldModelSerializer):
     site = NestedSiteSerializer()
     rack = NestedRackSerializer()
     face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES)
-    status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
+    status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES)
     primary_ip = DeviceIPAddressSerializer()
     primary_ip4 = DeviceIPAddressSerializer()
     primary_ip6 = DeviceIPAddressSerializer()
     parent_device = serializers.SerializerMethodField()
     cluster = NestedClusterSerializer()
+    virtual_chassis = DeviceVirtualChassisSerializer()
 
     class Meta:
         model = Device
         fields = [
             'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
-            'cluster', 'comments', 'custom_fields',
+            'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created',
+            'last_updated',
         ]
 
     def get_parent_device(self, obj):
@@ -500,7 +528,8 @@ class WritableDeviceSerializer(CustomFieldModelSerializer):
         model = Device
         fields = [
             'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
-            'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'comments', 'custom_fields',
+            'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
+            'vc_priority', 'comments', 'custom_fields', 'created', 'last_updated',
         ]
         validators = []
 
@@ -628,6 +657,15 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer):
         ]
 
 
+# Cannot import ipam.api.NestedVLANSerializer due to circular dependency
+class InterfaceVLANSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
+
+    class Meta:
+        model = VLAN
+        fields = ['id', 'url', 'vid', 'name', 'display_name']
+
+
 class InterfaceSerializer(serializers.ModelSerializer):
     device = NestedDeviceSerializer()
     form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
@@ -635,12 +673,15 @@ class InterfaceSerializer(serializers.ModelSerializer):
     is_connected = serializers.SerializerMethodField(read_only=True)
     interface_connection = serializers.SerializerMethodField(read_only=True)
     circuit_termination = InterfaceCircuitTerminationSerializer()
+    untagged_vlan = InterfaceVLANSerializer()
+    mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
+    tagged_vlans = InterfaceVLANSerializer(many=True)
 
     class Meta:
         model = Interface
         fields = [
             'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
-            'is_connected', 'interface_connection', 'circuit_termination',
+            'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans',
         ]
 
     def get_is_connected(self, obj):
@@ -685,8 +726,23 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
         model = Interface
         fields = [
             'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
+            'mode', 'untagged_vlan', 'tagged_vlans',
         ]
 
+    def validate(self, data):
+
+        # Validate that all untagged VLANs either belong to the same site as the Interface's parent Deivce or
+        # VirtualMachine, or are global.
+        parent = self.instance.parent if self.instance else data.get('device') or data.get('virtual_machine')
+        for vlan in data.get('tagged_vlans', []):
+            if vlan.site not in [parent, None]:
+                raise serializers.ValidationError(
+                    "Tagged VLAN {} must belong to the same site as the interface's parent device/VM, or it must be "
+                    "global".format(vlan)
+                )
+
+        return super(WritableInterfaceSerializer, self).validate(data)
+
 
 #
 # Device bays
@@ -771,3 +827,30 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer):
     class Meta:
         model = InterfaceConnection
         fields = ['id', 'interface_a', 'interface_b', 'connection_status']
+
+
+#
+# Virtual chassis
+#
+
+class VirtualChassisSerializer(serializers.ModelSerializer):
+    master = NestedDeviceSerializer()
+
+    class Meta:
+        model = VirtualChassis
+        fields = ['id', 'master', 'domain']
+
+
+class NestedVirtualChassisSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
+
+    class Meta:
+        model = VirtualChassis
+        fields = ['id', 'url']
+
+
+class WritableVirtualChassisSerializer(ValidatedModelSerializer):
+
+    class Meta:
+        model = VirtualChassis
+        fields = ['id', 'master', 'domain']

+ 3 - 0
netbox/dcim/api/urls.py

@@ -60,6 +60,9 @@ router.register(r'console-connections', views.ConsoleConnectionViewSet, base_nam
 router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections')
 router.register(r'interface-connections', views.InterfaceConnectionViewSet)
 
+# Virtual chassis
+router.register(r'virtual-chassis', views.VirtualChassisViewSet)
+
 # Miscellaneous
 router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')
 

+ 39 - 33
netbox/dcim/api/views.py

@@ -3,26 +3,25 @@ from __future__ import unicode_literals
 from collections import OrderedDict
 
 from django.conf import settings
+from django.db import transaction
 from django.http import HttpResponseBadRequest, HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 from rest_framework.decorators import detail_route
 from rest_framework.mixins import ListModelMixin
 from rest_framework.response import Response
-from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
+from rest_framework.viewsets import GenericViewSet, ViewSet
 
 from dcim import filters
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site,
+    RackReservation, RackRole, Region, Site, VirtualChassis,
 )
 from extras.api.serializers import RenderedGraphSerializer
 from extras.api.views import CustomFieldModelViewSet
 from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
-from utilities.api import (
-    IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ServiceUnavailable, WritableSerializerMixin,
-)
+from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable
 from . import serializers
 from .exceptions import MissingFilterException
 
@@ -47,7 +46,7 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
 # Regions
 #
 
-class RegionViewSet(WritableSerializerMixin, ModelViewSet):
+class RegionViewSet(ModelViewSet):
     queryset = Region.objects.all()
     serializer_class = serializers.RegionSerializer
     write_serializer_class = serializers.WritableRegionSerializer
@@ -58,7 +57,7 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet):
 # Sites
 #
 
-class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class SiteViewSet(CustomFieldModelViewSet):
     queryset = Site.objects.select_related('region', 'tenant')
     serializer_class = serializers.SiteSerializer
     write_serializer_class = serializers.WritableSiteSerializer
@@ -79,7 +78,7 @@ class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 # Rack groups
 #
 
-class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
+class RackGroupViewSet(ModelViewSet):
     queryset = RackGroup.objects.select_related('site')
     serializer_class = serializers.RackGroupSerializer
     write_serializer_class = serializers.WritableRackGroupSerializer
@@ -100,7 +99,7 @@ class RackRoleViewSet(ModelViewSet):
 # Racks
 #
 
-class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class RackViewSet(CustomFieldModelViewSet):
     queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
     serializer_class = serializers.RackSerializer
     write_serializer_class = serializers.WritableRackSerializer
@@ -131,8 +130,8 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 # Rack reservations
 #
 
-class RackReservationViewSet(WritableSerializerMixin, ModelViewSet):
-    queryset = RackReservation.objects.select_related('rack')
+class RackReservationViewSet(ModelViewSet):
+    queryset = RackReservation.objects.select_related('rack', 'user', 'tenant')
     serializer_class = serializers.RackReservationSerializer
     write_serializer_class = serializers.WritableRackReservationSerializer
     filter_class = filters.RackReservationFilter
@@ -156,7 +155,7 @@ class ManufacturerViewSet(ModelViewSet):
 # Device types
 #
 
-class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class DeviceTypeViewSet(CustomFieldModelViewSet):
     queryset = DeviceType.objects.select_related('manufacturer')
     serializer_class = serializers.DeviceTypeSerializer
     write_serializer_class = serializers.WritableDeviceTypeSerializer
@@ -167,42 +166,42 @@ class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 # Device type components
 #
 
-class ConsolePortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+class ConsolePortTemplateViewSet(ModelViewSet):
     queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.ConsolePortTemplateSerializer
     write_serializer_class = serializers.WritableConsolePortTemplateSerializer
     filter_class = filters.ConsolePortTemplateFilter
 
 
-class ConsoleServerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+class ConsoleServerPortTemplateViewSet(ModelViewSet):
     queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.ConsoleServerPortTemplateSerializer
     write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer
     filter_class = filters.ConsoleServerPortTemplateFilter
 
 
-class PowerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+class PowerPortTemplateViewSet(ModelViewSet):
     queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.PowerPortTemplateSerializer
     write_serializer_class = serializers.WritablePowerPortTemplateSerializer
     filter_class = filters.PowerPortTemplateFilter
 
 
-class PowerOutletTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+class PowerOutletTemplateViewSet(ModelViewSet):
     queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.PowerOutletTemplateSerializer
     write_serializer_class = serializers.WritablePowerOutletTemplateSerializer
     filter_class = filters.PowerOutletTemplateFilter
 
 
-class InterfaceTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+class InterfaceTemplateViewSet(ModelViewSet):
     queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.InterfaceTemplateSerializer
     write_serializer_class = serializers.WritableInterfaceTemplateSerializer
     filter_class = filters.InterfaceTemplateFilter
 
 
-class DeviceBayTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+class DeviceBayTemplateViewSet(ModelViewSet):
     queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.DeviceBayTemplateSerializer
     write_serializer_class = serializers.WritableDeviceBayTemplateSerializer
@@ -226,6 +225,7 @@ class DeviceRoleViewSet(ModelViewSet):
 class PlatformViewSet(ModelViewSet):
     queryset = Platform.objects.all()
     serializer_class = serializers.PlatformSerializer
+    write_serializer_class = serializers.WritablePlatformSerializer
     filter_class = filters.PlatformFilter
 
 
@@ -233,9 +233,10 @@ class PlatformViewSet(ModelViewSet):
 # Devices
 #
 
-class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class DeviceViewSet(CustomFieldModelViewSet):
     queryset = Device.objects.select_related(
         'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
+        'virtual_chassis__master',
     ).prefetch_related(
         'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
     )
@@ -263,12 +264,7 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
             import napalm
         except ImportError:
             raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
-
-        # TODO: Remove support for NAPALM < 2.0
-        try:
-            from napalm.base.exceptions import ConnectAuthError, ModuleImportError
-        except ImportError:
-            from napalm_base.exceptions import ConnectAuthError, ModuleImportError
+        from napalm.base.exceptions import ConnectAuthError, ModuleImportError
 
         # Validate the configured driver
         try:
@@ -316,35 +312,35 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 # Device components
 #
 
-class ConsolePortViewSet(WritableSerializerMixin, ModelViewSet):
+class ConsolePortViewSet(ModelViewSet):
     queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
     serializer_class = serializers.ConsolePortSerializer
     write_serializer_class = serializers.WritableConsolePortSerializer
     filter_class = filters.ConsolePortFilter
 
 
-class ConsoleServerPortViewSet(WritableSerializerMixin, ModelViewSet):
+class ConsoleServerPortViewSet(ModelViewSet):
     queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
     serializer_class = serializers.ConsoleServerPortSerializer
     write_serializer_class = serializers.WritableConsoleServerPortSerializer
     filter_class = filters.ConsoleServerPortFilter
 
 
-class PowerPortViewSet(WritableSerializerMixin, ModelViewSet):
+class PowerPortViewSet(ModelViewSet):
     queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
     serializer_class = serializers.PowerPortSerializer
     write_serializer_class = serializers.WritablePowerPortSerializer
     filter_class = filters.PowerPortFilter
 
 
-class PowerOutletViewSet(WritableSerializerMixin, ModelViewSet):
+class PowerOutletViewSet(ModelViewSet):
     queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
     serializer_class = serializers.PowerOutletSerializer
     write_serializer_class = serializers.WritablePowerOutletSerializer
     filter_class = filters.PowerOutletFilter
 
 
-class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
+class InterfaceViewSet(ModelViewSet):
     queryset = Interface.objects.select_related('device')
     serializer_class = serializers.InterfaceSerializer
     write_serializer_class = serializers.WritableInterfaceSerializer
@@ -361,14 +357,14 @@ class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
         return Response(serializer.data)
 
 
-class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet):
+class DeviceBayViewSet(ModelViewSet):
     queryset = DeviceBay.objects.select_related('installed_device')
     serializer_class = serializers.DeviceBaySerializer
     write_serializer_class = serializers.WritableDeviceBaySerializer
     filter_class = filters.DeviceBayFilter
 
 
-class InventoryItemViewSet(WritableSerializerMixin, ModelViewSet):
+class InventoryItemViewSet(ModelViewSet):
     queryset = InventoryItem.objects.select_related('device', 'manufacturer')
     serializer_class = serializers.InventoryItemSerializer
     write_serializer_class = serializers.WritableInventoryItemSerializer
@@ -391,7 +387,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
     filter_class = filters.PowerConnectionFilter
 
 
-class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
+class InterfaceConnectionViewSet(ModelViewSet):
     queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
     serializer_class = serializers.InterfaceConnectionSerializer
     write_serializer_class = serializers.WritableInterfaceConnectionSerializer
@@ -399,6 +395,16 @@ class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
 
 
 #
+# Virtual chassis
+#
+
+class VirtualChassisViewSet(ModelViewSet):
+    queryset = VirtualChassis.objects.all()
+    serializer_class = serializers.VirtualChassisSerializer
+    write_serializer_class = serializers.WritableVirtualChassisSerializer
+
+
+#
 # Miscellaneous
 #
 

+ 3 - 0
netbox/dcim/apps.py

@@ -6,3 +6,6 @@ from django.apps import AppConfig
 class DCIMConfig(AppConfig):
     name = "dcim"
     verbose_name = "DCIM"
+
+    def ready(self):
+        import dcim.signals

+ 34 - 15
netbox/dcim/constants.py

@@ -193,24 +193,43 @@ WIRELESS_IFACE_TYPES = [
 
 NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
 
+IFACE_MODE_ACCESS = 100
+IFACE_MODE_TAGGED = 200
+IFACE_MODE_TAGGED_ALL = 300
+IFACE_MODE_CHOICES = [
+    [IFACE_MODE_ACCESS, 'Access'],
+    [IFACE_MODE_TAGGED, 'Tagged'],
+    [IFACE_MODE_TAGGED_ALL, 'Tagged All'],
+]
+
 # Device statuses
-STATUS_OFFLINE = 0
-STATUS_ACTIVE = 1
-STATUS_PLANNED = 2
-STATUS_STAGED = 3
-STATUS_FAILED = 4
-STATUS_INVENTORY = 5
-STATUS_CHOICES = [
-    [STATUS_ACTIVE, 'Active'],
-    [STATUS_OFFLINE, 'Offline'],
-    [STATUS_PLANNED, 'Planned'],
-    [STATUS_STAGED, 'Staged'],
-    [STATUS_FAILED, 'Failed'],
-    [STATUS_INVENTORY, 'Inventory'],
+DEVICE_STATUS_OFFLINE = 0
+DEVICE_STATUS_ACTIVE = 1
+DEVICE_STATUS_PLANNED = 2
+DEVICE_STATUS_STAGED = 3
+DEVICE_STATUS_FAILED = 4
+DEVICE_STATUS_INVENTORY = 5
+DEVICE_STATUS_CHOICES = [
+    [DEVICE_STATUS_ACTIVE, 'Active'],
+    [DEVICE_STATUS_OFFLINE, 'Offline'],
+    [DEVICE_STATUS_PLANNED, 'Planned'],
+    [DEVICE_STATUS_STAGED, 'Staged'],
+    [DEVICE_STATUS_FAILED, 'Failed'],
+    [DEVICE_STATUS_INVENTORY, 'Inventory'],
+]
+
+# Site statuses
+SITE_STATUS_ACTIVE = 1
+SITE_STATUS_PLANNED = 2
+SITE_STATUS_RETIRED = 4
+SITE_STATUS_CHOICES = [
+    [SITE_STATUS_ACTIVE, 'Active'],
+    [SITE_STATUS_PLANNED, 'Planned'],
+    [SITE_STATUS_RETIRED, 'Retired'],
 ]
 
-# Bootstrap CSS classes for device stasuses
-DEVICE_STATUS_CLASSES = {
+# Bootstrap CSS classes for device statuses
+STATUS_CLASSES = {
     0: 'warning',
     1: 'success',
     2: 'info',

+ 79 - 4
netbox/dcim/filters.py

@@ -11,13 +11,14 @@ from tenancy.models import Tenant
 from utilities.filters import NullableCharFieldFilter, NumericInFilter
 from virtualization.models import Cluster
 from .constants import (
-    IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, STATUS_CHOICES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES,
+    DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES,
+    WIRELESS_IFACE_TYPES,
 )
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site,
+    RackReservation, RackRole, Region, Site, VirtualChassis,
 )
 
 
@@ -57,6 +58,10 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
         method='search',
         label='Search',
     )
+    status = django_filters.MultipleChoiceFilter(
+        choices=SITE_STATUS_CHOICES,
+        null_value=None
+    )
     region_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         label='Region (ID)',
@@ -88,6 +93,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
         qs_filter = (
             Q(name__icontains=value) |
             Q(facility__icontains=value) |
+            Q(description__icontains=value) |
             Q(physical_address__icontains=value) |
             Q(shipping_address__icontains=value) |
             Q(contact_name__icontains=value) |
@@ -221,6 +227,16 @@ class RackReservationFilter(django_filters.FilterSet):
         to_field_name='slug',
         label='Group',
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant__slug',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         label='User (ID)',
@@ -347,6 +363,17 @@ class DeviceRoleFilter(django_filters.FilterSet):
 
 
 class PlatformFilter(django_filters.FilterSet):
+    manufacturer_id = django_filters.ModelMultipleChoiceFilter(
+        name='manufacturer',
+        queryset=Manufacturer.objects.all(),
+        label='Manufacturer (ID)',
+    )
+    manufacturer = django_filters.ModelMultipleChoiceFilter(
+        name='manufacturer__slug',
+        queryset=Manufacturer.objects.all(),
+        to_field_name='slug',
+        label='Manufacturer (slug)',
+    )
 
     class Meta:
         model = Platform
@@ -438,7 +465,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Device model (slug)',
     )
     status = django_filters.MultipleChoiceFilter(
-        choices=STATUS_CHOICES,
+        choices=DEVICE_STATUS_CHOICES,
         null_value=None
     )
     is_full_depth = django_filters.BooleanFilter(
@@ -465,6 +492,11 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         method='_has_primary_ip',
         label='Has a primary IP',
     )
+    virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
+        name='virtual_chassis',
+        queryset=VirtualChassis.objects.all(),
+        label='Virtual chassis (ID)',
+    )
 
     class Meta:
         model = Device
@@ -580,8 +612,9 @@ class InterfaceFilter(django_filters.FilterSet):
     def filter_device(self, queryset, name, value):
         try:
             device = Device.objects.select_related('device_type').get(**{name: value})
+            vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
             ordering = device.device_type.interface_ordering
-            return queryset.filter(device=device).order_naturally(ordering)
+            return queryset.filter(pk__in=vc_interface_ids).order_naturally(ordering)
         except Device.DoesNotExist:
             return queryset.none()
 
@@ -650,6 +683,48 @@ class InventoryItemFilter(DeviceComponentFilterSet):
         return queryset.filter(qs_filter)
 
 
+class VirtualChassisFilter(django_filters.FilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        name='master__site',
+        queryset=Site.objects.all(),
+        label='Site (ID)',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        name='master__site__slug',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label='Site name (slug)',
+    )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='master__tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='master__tenant__slug',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
+
+    class Meta:
+        model = VirtualChassis
+        fields = ['domain']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(master__name__icontains=value) |
+            Q(domain__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
+
 class ConsoleConnectionFilter(django_filters.FilterSet):
     site = django_filters.CharFilter(
         method='filter_site',

+ 473 - 50
netbox/dcim/forms.py

@@ -7,29 +7,32 @@ from django.contrib.auth.models import User
 from django.contrib.postgres.forms.array import SimpleArrayField
 from django.db.models import Count, Q
 from mptt.forms import TreeNodeChoiceField
+from timezone_field import TimeZoneFormField
 
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLAN, VLANGroup
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
-    ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
-    ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
-    SlugField, FilterTreeNodeMultipleChoiceField,
+    APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
+    BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField,
+    CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField,
+    FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK,
+    SmallTextarea, SlugField,
 )
 from virtualization.models import Cluster
 from .constants import (
-    CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES,
-    RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, STATUS_CHOICES,
-    SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
+    CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_FF_LAG,
+    IFACE_MODE_ACCESS, IFACE_MODE_CHOICES, IFACE_MODE_TAGGED_ALL, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES,
+    RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
+    SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
 )
 from .formfields import MACAddressFormField
 from .models import (
     DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
     Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
     Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
-    RackRole, Region, Site,
+    RackRole, Region, Site, VirtualChassis
 )
 
 DEVICE_BY_PK_RE = '{\d+\}'
@@ -47,6 +50,14 @@ def get_device_by_name_or_pk(name):
     return device
 
 
+class BulkRenameForm(forms.Form):
+    """
+    An extendable form to be used for renaming device components in bulk.
+    """
+    find = forms.CharField()
+    replace = forms.CharField()
+
+
 #
 # Regions
 #
@@ -96,8 +107,9 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     class Meta:
         model = Site
         fields = [
-            'name', 'slug', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'physical_address',
-            'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+            'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'description',
+            'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone',
+            'comments',
         ]
         widgets = {
             'physical_address': SmallTextarea(attrs={'rows': 3}),
@@ -113,6 +125,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
 
 class SiteCSVForm(forms.ModelForm):
+    status = CSVChoiceField(
+        choices=DEVICE_STATUS_CHOICES,
+        required=False,
+        help_text='Operational status'
+    )
     region = forms.ModelChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -144,17 +161,28 @@ class SiteCSVForm(forms.ModelForm):
 
 class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
+    status = forms.ChoiceField(choices=add_blank_choice(SITE_STATUS_CHOICES), required=False, initial='')
     region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
     tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
+    description = forms.CharField(max_length=100, required=False)
+    time_zone = TimeZoneFormField(required=False)
 
     class Meta:
-        nullable_fields = ['region', 'tenant', 'asn']
+        nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
+
+
+def site_status_choices():
+    status_counts = {}
+    for status in Site.objects.values('status').annotate(count=Count('status')).order_by('status'):
+        status_counts[status['status']] = status['count']
+    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in SITE_STATUS_CHOICES]
 
 
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Site
     q = forms.CharField(required=False, label='Search')
+    status = forms.MultipleChoiceField(choices=site_status_choices, required=False)
     region = FilterTreeNodeMultipleChoiceField(
         queryset=Region.objects.annotate(filter_count=Count('sites')),
         to_field_name='slug',
@@ -372,13 +400,13 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # Rack reservations
 #
 
-class RackReservationForm(BootstrapMixin, forms.ModelForm):
+class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
     units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10}))
     user = forms.ModelChoiceField(queryset=User.objects.order_by('username'))
 
     class Meta:
         model = RackReservation
-        fields = ['units', 'user', 'description']
+        fields = ['units', 'user', 'tenant_group', 'tenant', 'description']
 
     def __init__(self, *args, **kwargs):
 
@@ -408,11 +436,17 @@ class RackReservationFilterForm(BootstrapMixin, forms.Form):
         label='Rack group',
         null_label='-- None --'
     )
+    tenant = FilterChoiceField(
+        queryset=Tenant.objects.annotate(filter_count=Count('rackreservations')),
+        to_field_name='slug',
+        null_label='-- None --'
+    )
 
 
 class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput)
     user = forms.ModelChoiceField(queryset=User.objects.order_by('username'), required=False)
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     description = forms.CharField(max_length=100, required=False)
 
     class Meta:
@@ -661,7 +695,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
 
     class Meta:
         model = Platform
-        fields = ['name', 'slug', 'napalm_driver', 'rpc_client']
+        fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
 
 
 class PlatformCSVForm(forms.ModelForm):
@@ -672,6 +706,7 @@ class PlatformCSVForm(forms.ModelForm):
         fields = Platform.csv_headers
         help_texts = {
             'name': 'Platform name',
+            'manufacturer': 'Manufacturer name',
         }
 
 
@@ -757,32 +792,35 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             # Compile list of choices for primary IPv4 and IPv6 addresses
             for family in [4, 6]:
                 ip_choices = [(None, '---------')]
+
+                # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
+                interface_ids = self.instance.vc_interfaces.values('pk')
+
                 # Collect interface IPs
                 interface_ips = IPAddress.objects.select_related('interface').filter(
-                    family=family, interface__device=self.instance
+                    family=family, interface_id__in=interface_ids
                 )
                 if interface_ips:
-                    ip_choices.append(
-                        ('Interface IPs', [
-                            (ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
-                        ])
-                    )
+                    ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
+                    ip_choices.append(('Interface IPs', ip_list))
                 # Collect NAT IPs
                 nat_ips = IPAddress.objects.select_related('nat_inside').filter(
-                    family=family, nat_inside__interface__device=self.instance
+                    family=family, nat_inside__interface__in=interface_ids
                 )
                 if nat_ips:
-                    ip_choices.append(
-                        ('NAT IPs', [
-                            (ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
-                        ])
-                    )
+                    ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
+                    ip_choices.append(('NAT IPs', ip_list))
                 self.fields['primary_ip{}'.format(family)].choices = ip_choices
 
             # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
             # can be flipped from one face to another.
             self.fields['position'].widget.attrs['api-url'] += '&exclude={}'.format(self.instance.pk)
 
+            # Limit platform by manufacturer
+            self.fields['platform'].queryset = Platform.objects.filter(
+                Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
+            )
+
         else:
 
             # An object that doesn't exist yet can't have any IPs assigned to it
@@ -795,10 +833,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         pk = self.instance.pk if self.instance.pk else None
         try:
             if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
-                position_choices = Rack.objects.get(pk=self.data['rack'])\
+                position_choices = Rack.objects.get(pk=self.data['rack']) \
                     .get_rack_units(face=self.data.get('face'), exclude=pk)
             elif self.initial.get('rack') and str(self.initial.get('face')):
-                position_choices = Rack.objects.get(pk=self.initial['rack'])\
+                position_choices = Rack.objects.get(pk=self.initial['rack']) \
                     .get_rack_units(face=self.initial.get('face'), exclude=pk)
             else:
                 position_choices = []
@@ -858,8 +896,8 @@ class BaseDeviceCSVForm(forms.ModelForm):
         }
     )
     status = CSVChoiceField(
-        choices=STATUS_CHOICES,
-        help_text='Operational status of device'
+        choices=DEVICE_STATUS_CHOICES,
+        help_text='Operational status'
     )
 
     class Meta:
@@ -995,7 +1033,7 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
     tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
-    status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='')
+    status = forms.ChoiceField(choices=add_blank_choice(DEVICE_STATUS_CHOICES), required=False, initial='')
     serial = forms.CharField(max_length=50, required=False, label='Serial Number')
 
     class Meta:
@@ -1006,7 +1044,7 @@ def device_status_choices():
     status_counts = {}
     for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
         status_counts[status['status']] = status['count']
-    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
+    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in DEVICE_STATUS_CHOICES]
 
 
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -1333,6 +1371,10 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.
         }
 
 
+class ConsoleServerPortBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
+
+
 class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
     pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
 
@@ -1594,6 +1636,10 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
         }
 
 
+class PowerOutletBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
+
+
 class PowerOutletBulkDisconnectForm(ConfirmationForm):
     pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
 
@@ -1602,11 +1648,58 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
 # Interfaces
 #
 
-class InterfaceForm(BootstrapMixin, forms.ModelForm):
+class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        label='VLAN site',
+        widget=forms.Select(
+            attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
+        )
+    )
+    vlan_group = ChainedModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        chains=(
+            ('site', 'site'),
+        ),
+        required=False,
+        label='VLAN group',
+        widget=APISelect(
+            attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
+            api_url='/api/ipam/vlan-groups/?site_id={{site}}',
+        )
+    )
+    untagged_vlan = ChainedModelChoiceField(
+        queryset=VLAN.objects.all(),
+        chains=(
+            ('site', 'site'),
+            ('group', 'vlan_group'),
+        ),
+        required=False,
+        label='Untagged VLAN',
+        widget=APISelect(
+            api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+        )
+    )
+    tagged_vlans = ChainedModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        chains=(
+            ('site', 'site'),
+            ('group', 'vlan_group'),
+        ),
+        required=False,
+        label='Tagged VLANs',
+        widget=APISelectMultiple(
+            api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+        )
+    )
 
     class Meta:
         model = Interface
-        fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description']
+        fields = [
+            'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
+            'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
+        ]
         widgets = {
             'device': forms.HiddenInput(),
         }
@@ -1614,18 +1707,70 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
     def __init__(self, *args, **kwargs):
         super(InterfaceForm, self).__init__(*args, **kwargs)
 
-        # Limit LAG choices to interfaces belonging to this device
+        # Limit LAG choices to interfaces belonging to this device (or VC master)
         if self.is_bound:
+            device = Device.objects.get(pk=self.data['device'])
             self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
-                device_id=self.data['device'], form_factor=IFACE_FF_LAG
+                device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG
             )
         else:
+            device = self.instance.device
             self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
-                device=self.instance.device, form_factor=IFACE_FF_LAG
+                device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG
             )
 
+        # Limit the queryset for the site to only include the interface's device's site
+        if device and device.site:
+            self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
+            self.fields['site'].initial = None
+        else:
+            self.fields['site'].queryset = Site.objects.none()
+            self.fields['site'].initial = None
+
+        # Limit the initial vlan choices
+        if self.is_bound:
+            filter_dict = {
+                'group_id': self.data.get('vlan_group') or None,
+                'site_id': self.data.get('site') or None,
+            }
+        elif self.initial.get('untagged_vlan'):
+            filter_dict = {
+                'group_id': self.instance.untagged_vlan.group,
+                'site_id': self.instance.untagged_vlan.site,
+            }
+        elif self.initial.get('tagged_vlans'):
+            filter_dict = {
+                'group_id': self.instance.tagged_vlans.first().group,
+                'site_id': self.instance.tagged_vlans.first().site,
+            }
+        else:
+            filter_dict = {
+                'group_id': None,
+                'site_id': None,
+            }
+
+        self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
+        self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
+
+    def clean_tagged_vlans(self):
+        """
+        Because tagged_vlans is a many-to-many relationship, validation must be done in the form
+        """
+        if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']:
+            raise forms.ValidationError(
+                "An Access interface cannot have tagged VLANs."
+            )
+
+        if self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL and self.cleaned_data['tagged_vlans']:
+            raise forms.ValidationError(
+                "Interface mode Tagged All implies all VLANs are tagged. "
+                "Do not select any tagged VLANs."
+            )
 
-class InterfaceCreateForm(ComponentForm):
+        return self.cleaned_data['tagged_vlans']
+
+
+class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
     name_pattern = ExpandableNameField(label='Name')
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
     enabled = forms.BooleanField(required=False)
@@ -1638,6 +1783,51 @@ class InterfaceCreateForm(ComponentForm):
         help_text='This interface is used only for out-of-band management'
     )
     description = forms.CharField(max_length=100, required=False)
+    mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        label='VLAN Site',
+        widget=forms.Select(
+            attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
+        )
+    )
+    vlan_group = ChainedModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        chains=(
+            ('site', 'site'),
+        ),
+        required=False,
+        label='VLAN group',
+        widget=APISelect(
+            attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
+            api_url='/api/ipam/vlan-groups/?site_id={{site}}',
+        )
+    )
+    untagged_vlan = ChainedModelChoiceField(
+        queryset=VLAN.objects.all(),
+        chains=(
+            ('site', 'site'),
+            ('group', 'vlan_group'),
+        ),
+        required=False,
+        label='Untagged VLAN',
+        widget=APISelect(
+            api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+        )
+    )
+    tagged_vlans = ChainedModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        chains=(
+            ('site', 'site'),
+            ('group', 'vlan_group'),
+        ),
+        required=False,
+        label='Tagged VLANs',
+        widget=APISelectMultiple(
+            api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+        )
+    )
 
     def __init__(self, *args, **kwargs):
 
@@ -1647,16 +1837,49 @@ class InterfaceCreateForm(ComponentForm):
 
         super(InterfaceCreateForm, self).__init__(*args, **kwargs)
 
-        # Limit LAG choices to interfaces belonging to this device
+        # Limit LAG choices to interfaces belonging to this device (or its VC master)
         if self.parent is not None:
             self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
-                device=self.parent, form_factor=IFACE_FF_LAG
+                device__in=[self.parent, self.parent.get_vc_master()], form_factor=IFACE_FF_LAG
             )
         else:
             self.fields['lag'].queryset = Interface.objects.none()
 
+        # Limit the queryset for the site to only include the interface's device's site
+        if self.parent is not None and self.parent.site:
+            self.fields['site'].queryset = Site.objects.filter(pk=self.parent.site.id)
+            self.fields['site'].initial = None
+        else:
+            self.fields['site'].queryset = Site.objects.none()
+            self.fields['site'].initial = None
+
+        # Limit the initial vlan choices
+        if self.is_bound:
+            filter_dict = {
+                'group_id': self.data.get('vlan_group') or None,
+                'site_id': self.data.get('site') or None,
+            }
+        elif self.initial.get('untagged_vlan'):
+            filter_dict = {
+                'group_id': self.untagged_vlan.group,
+                'site_id': self.untagged_vlan.site,
+            }
+        elif self.initial.get('tagged_vlans'):
+            filter_dict = {
+                'group_id': self.tagged_vlans.first().group,
+                'site_id': self.tagged_vlans.first().site,
+            }
+        else:
+            filter_dict = {
+                'group_id': None,
+                'site_id': None,
+            }
+
+        self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
+        self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
+
 
-class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
+class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
     device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
     form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
@@ -1665,28 +1888,104 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
     mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
     mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
     description = forms.CharField(max_length=100, required=False)
+    mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        label='VLAN Site',
+        widget=forms.Select(
+            attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
+        )
+    )
+    vlan_group = ChainedModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        chains=(
+            ('site', 'site'),
+        ),
+        required=False,
+        label='VLAN group',
+        widget=APISelect(
+            attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
+            api_url='/api/ipam/vlan-groups/?site_id={{site}}',
+        )
+    )
+    untagged_vlan = ChainedModelChoiceField(
+        queryset=VLAN.objects.all(),
+        chains=(
+            ('site', 'site'),
+            ('group', 'vlan_group'),
+        ),
+        required=False,
+        label='Untagged VLAN',
+        widget=APISelect(
+            api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+        )
+    )
+    tagged_vlans = ChainedModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        chains=(
+            ('site', 'site'),
+            ('group', 'vlan_group'),
+        ),
+        required=False,
+        label='Tagged VLANs',
+        widget=APISelectMultiple(
+            api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+        )
+    )
 
     class Meta:
-        nullable_fields = ['lag', 'mtu', 'description']
+        nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans']
 
     def __init__(self, *args, **kwargs):
         super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
 
-        # Limit LAG choices to interfaces which belong to the parent device.
+        # Limit LAG choices to interfaces which belong to the parent device (or VC master)
         device = None
         if self.initial.get('device'):
             try:
                 device = Device.objects.get(pk=self.initial.get('device'))
             except Device.DoesNotExist:
                 pass
+        else:
+            try:
+                device = Device.objects.get(pk=self.data.get('device'))
+            except Device.DoesNotExist:
+                pass
         if device is not None:
             interface_ordering = device.device_type.interface_ordering
             self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
-                device=device, form_factor=IFACE_FF_LAG
+                device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG
             )
         else:
             self.fields['lag'].choices = []
 
+        # Limit the queryset for the site to only include the interface's device's site
+        if device and device.site:
+            self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
+            self.fields['site'].initial = None
+        else:
+            self.fields['site'].queryset = Site.objects.none()
+            self.fields['site'].initial = None
+
+        if self.is_bound:
+            filter_dict = {
+                'group_id': self.data.get('vlan_group') or None,
+                'site_id': self.data.get('site') or None,
+            }
+        else:
+            filter_dict = {
+                'group_id': None,
+                'site_id': None,
+            }
+
+        self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
+        self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
+
+
+class InterfaceBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
+
 
 class InterfaceBulkDisconnectForm(ConfirmationForm):
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
@@ -1857,11 +2156,6 @@ class InterfaceConnectionCSVForm(forms.ModelForm):
         return interface
 
 
-class InterfaceConnectionDeletionForm(ConfirmationForm):
-    # Used for HTTP redirect upon successful deletion
-    device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
-
-
 #
 # Device bays
 #
@@ -1900,6 +2194,10 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
         ).exclude(pk=device_bay.device.pk)
 
 
+class DeviceBayBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(queryset=DeviceBay.objects.all(), widget=forms.MultipleHiddenInput)
+
+
 #
 # Connections
 #
@@ -1972,3 +2270,128 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
         to_field_name='slug',
         null_label='-- None --'
     )
+
+
+#
+# Virtual chassis
+#
+
+class DeviceSelectionForm(forms.Form):
+    pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
+
+
+class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = VirtualChassis
+        fields = ['master', 'domain']
+        widgets = {
+            'master': SelectWithPK,
+        }
+
+
+class BaseVCMemberFormSet(forms.BaseModelFormSet):
+
+    def clean(self):
+        super(BaseVCMemberFormSet, self).clean()
+
+        # Check for duplicate VC position values
+        vc_position_list = []
+        for form in self.forms:
+            vc_position = form.cleaned_data['vc_position']
+            if vc_position in vc_position_list:
+                error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
+                form.add_error('vc_position', error_msg)
+            vc_position_list.append(vc_position)
+
+
+class DeviceVCMembershipForm(forms.ModelForm):
+
+    class Meta:
+        model = Device
+        fields = ['vc_position', 'vc_priority']
+        labels = {
+            'vc_position': 'Position',
+            'vc_priority': 'Priority',
+        }
+
+    def __init__(self, validate_vc_position=False, *args, **kwargs):
+        super(DeviceVCMembershipForm, self).__init__(*args, **kwargs)
+
+        # Require VC position (only required when the Device is a VirtualChassis member)
+        self.fields['vc_position'].required = True
+
+        # Validation of vc_position is optional. This is only required when adding a new member to an existing
+        # VirtualChassis. Otherwise, vc_position validation is handled by BaseVCMemberFormSet.
+        self.validate_vc_position = validate_vc_position
+
+    def clean_vc_position(self):
+        vc_position = self.cleaned_data['vc_position']
+
+        if self.validate_vc_position:
+            conflicting_members = Device.objects.filter(
+                virtual_chassis=self.instance.virtual_chassis,
+                vc_position=vc_position
+            )
+            if conflicting_members.exists():
+                raise forms.ValidationError(
+                    'A virtual chassis member already exists in position {}.'.format(vc_position)
+                )
+
+        return vc_position
+
+
+class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        label='Site',
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'rack'}
+        )
+    )
+    rack = ChainedModelChoiceField(
+        queryset=Rack.objects.all(),
+        chains=(
+            ('site', 'site'),
+        ),
+        label='Rack',
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{site}}',
+            attrs={'filter-for': 'device', 'nullable': 'true'}
+        )
+    )
+    device = ChainedModelChoiceField(
+        queryset=Device.objects.filter(virtual_chassis__isnull=True),
+        chains=(
+            ('site', 'site'),
+            ('rack', 'rack'),
+        ),
+        label='Device',
+        widget=APISelect(
+            api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
+            display_field='display_name',
+            disabled_indicator='virtual_chassis'
+        )
+    )
+
+    def clean_device(self):
+        device = self.cleaned_data['device']
+        if device.virtual_chassis is not None:
+            raise forms.ValidationError("Device {} is already assigned to a virtual chassis.".format(device))
+        return device
+
+
+class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
+    model = VirtualChassis
+    q = forms.CharField(required=False, label='Search')
+    site = FilterChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+    )
+    tenant = FilterChoiceField(
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        null_label='-- None --',
+    )

+ 32 - 0
netbox/dcim/migrations/0050_interface_vlan_tagging.py

@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2017-11-10 20:10
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0020_ipaddress_add_role_carp'),
+        ('dcim', '0049_rackreservation_change_user'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='mode',
+            field=models.PositiveSmallIntegerField(blank=True, choices=[[100, 'Access'], [200, 'Tagged'], [300, 'Tagged All']], null=True),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='tagged_vlans',
+            field=models.ManyToManyField(blank=True, related_name='interfaces_as_tagged', to='ipam.VLAN', verbose_name='Tagged VLANs'),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='untagged_vlan',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'),
+        ),
+    ]

+ 22 - 0
netbox/dcim/migrations/0051_rackreservation_tenant.py

@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2017-11-15 18:56
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0003_unicode_literals'),
+        ('dcim', '0050_interface_vlan_tagging'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='rackreservation',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='rackreservations', to='tenancy.Tenant'),
+        ),
+    ]

+ 44 - 0
netbox/dcim/migrations/0052_virtual_chassis.py

@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2017-11-27 17:27
+from __future__ import unicode_literals
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0051_rackreservation_tenant'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='VirtualChassis',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('domain', models.CharField(blank=True, max_length=30)),
+                ('master', models.OneToOneField(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='device',
+            name='virtual_chassis',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='dcim.VirtualChassis'),
+        ),
+        migrations.AddField(
+            model_name='device',
+            name='vc_position',
+            field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
+        ),
+        migrations.AddField(
+            model_name='device',
+            name='vc_priority',
+            field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
+        ),
+        migrations.AlterUniqueTogether(
+            name='device',
+            unique_together=set([('virtual_chassis', 'vc_position'), ('rack', 'position', 'face')]),
+        ),
+    ]

+ 26 - 0
netbox/dcim/migrations/0053_platform_manufacturer.py

@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2017-12-19 20:56
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0052_virtual_chassis'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='platform',
+            name='manufacturer',
+            field=models.ForeignKey(blank=True, help_text='Optionally limit this platform to devices of a certain manufacturer', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='platforms', to='dcim.Manufacturer'),
+        ),
+        migrations.AlterField(
+            model_name='platform',
+            name='napalm_driver',
+            field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices', max_length=50, verbose_name='NAPALM driver'),
+        ),
+    ]

+ 31 - 0
netbox/dcim/migrations/0054_site_status_timezone_description.py

@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2018-01-25 18:21
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import timezone_field.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0053_platform_manufacturer'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='site',
+            name='description',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+        migrations.AddField(
+            model_name='site',
+            name='status',
+            field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [2, 'Planned'], [4, 'Retired']], default=1),
+        ),
+        migrations.AddField(
+            model_name='site',
+            name='time_zone',
+            field=timezone_field.fields.TimeZoneField(blank=True),
+        ),
+    ]

+ 25 - 0
netbox/dcim/migrations/0055_virtualchassis_ordering.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.9 on 2018-02-21 14:41
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0054_site_status_timezone_description'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='virtualchassis',
+            options={'ordering': ['master'], 'verbose_name_plural': 'virtual chassis'},
+        ),
+        migrations.AlterField(
+            model_name='virtualchassis',
+            name='master',
+            field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'),
+        ),
+    ]

+ 200 - 36
netbox/dcim/models.py

@@ -14,6 +14,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 from mptt.models import MPTTModel, TreeForeignKey
+from timezone_field import TimeZoneField
 
 from circuits.models import Circuit
 from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment
@@ -79,10 +80,13 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     """
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
+    status = models.PositiveSmallIntegerField(choices=SITE_STATUS_CHOICES, default=SITE_STATUS_ACTIVE)
     region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL)
     tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT)
     facility = models.CharField(max_length=50, blank=True)
     asn = ASNField(blank=True, null=True, verbose_name='ASN')
+    time_zone = TimeZoneField(blank=True)
+    description = models.CharField(max_length=100, blank=True)
     physical_address = models.CharField(max_length=200, blank=True)
     shipping_address = models.CharField(max_length=200, blank=True)
     contact_name = models.CharField(max_length=50, blank=True)
@@ -95,8 +99,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     objects = SiteManager()
 
     csv_headers = [
-        'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name',
-        'contact_phone', 'contact_email', 'comments',
+        'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
+        'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
     ]
 
     class Meta:
@@ -112,10 +116,13 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
         return (
             self.name,
             self.slug,
+            self.get_status_display(),
             self.region.name if self.region else None,
             self.tenant.name if self.tenant else None,
             self.facility,
             self.asn,
+            self.time_zone,
+            self.description,
             self.physical_address,
             self.shipping_address,
             self.contact_name,
@@ -124,6 +131,9 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
             self.comments,
         )
 
+    def get_status_class(self):
+        return STATUS_CLASSES[self.status]
+
     @property
     def count_prefixes(self):
         return self.prefixes.count()
@@ -431,6 +441,7 @@ class RackReservation(models.Model):
     rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
     units = ArrayField(models.PositiveSmallIntegerField())
     created = models.DateTimeField(auto_now_add=True)
+    tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='rackreservations', on_delete=models.PROTECT)
     user = models.ForeignKey(User, on_delete=models.PROTECT)
     description = models.CharField(max_length=100)
 
@@ -785,18 +796,33 @@ class DeviceRole(models.Model):
 @python_2_unicode_compatible
 class Platform(models.Model):
     """
-    Platform refers to the software or firmware running on a Device; for example, "Cisco IOS-XR" or "Juniper Junos".
+    Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
     NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
-    specifying an remote procedure call (RPC) client.
+    specifying a NAPALM driver.
     """
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
-    napalm_driver = models.CharField(max_length=50, blank=True, verbose_name='NAPALM driver',
-                                     help_text="The name of the NAPALM driver to use when interacting with devices.")
-    rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True,
-                                  verbose_name='Legacy RPC client')
+    manufacturer = models.ForeignKey(
+        to='Manufacturer',
+        related_name='platforms',
+        blank=True,
+        null=True,
+        help_text="Optionally limit this platform to devices of a certain manufacturer"
+    )
+    napalm_driver = models.CharField(
+        max_length=50,
+        blank=True,
+        verbose_name='NAPALM driver',
+        help_text="The name of the NAPALM driver to use when interacting with devices"
+    )
+    rpc_client = models.CharField(
+        max_length=30,
+        choices=RPC_CLIENT_CHOICES,
+        blank=True,
+        verbose_name="Legacy RPC client"
+    )
 
-    csv_headers = ['name', 'slug', 'napalm_driver']
+    csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
 
     class Meta:
         ordering = ['name']
@@ -811,6 +837,7 @@ class Platform(models.Model):
         return (
             self.name,
             self.slug,
+            self.manufacturer.name if self.manufacturer else None,
             self.napalm_driver,
         )
 
@@ -851,7 +878,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         help_text='The lowest-numbered unit occupied by the device'
     )
     face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
-    status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
+    status = models.PositiveSmallIntegerField(choices=DEVICE_STATUS_CHOICES, default=DEVICE_STATUS_ACTIVE, verbose_name='Status')
     primary_ip4 = models.OneToOneField(
         'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True,
         verbose_name='Primary IPv4'
@@ -867,6 +894,23 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         blank=True,
         null=True
     )
+    virtual_chassis = models.ForeignKey(
+        to='VirtualChassis',
+        on_delete=models.SET_NULL,
+        related_name='members',
+        blank=True,
+        null=True
+    )
+    vc_position = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MaxValueValidator(255)]
+    )
+    vc_priority = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MaxValueValidator(255)]
+    )
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     images = GenericRelation(ImageAttachment)
@@ -880,7 +924,10 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
 
     class Meta:
         ordering = ['name']
-        unique_together = ['rack', 'position', 'face']
+        unique_together = [
+            ['rack', 'position', 'face'],
+            ['virtual_chassis', 'vc_position'],
+        ]
         permissions = (
             ('napalm_read', 'Read-only access to devices via NAPALM'),
             ('napalm_write', 'Read/write access to devices via NAPALM'),
@@ -949,29 +996,36 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
             except DeviceType.DoesNotExist:
                 pass
 
-        # Validate primary IPv4 address
-        if self.primary_ip4 and (
-            self.primary_ip4.interface is None or
-            self.primary_ip4.interface.device != self
-        ) and (
-            self.primary_ip4.nat_inside.interface is None or
-            self.primary_ip4.nat_inside.interface.device != self
-        ):
-            raise ValidationError({
-                'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4),
-            })
+        # Validate primary IP addresses
+        vc_interfaces = self.vc_interfaces.all()
+        if self.primary_ip4:
+            if self.primary_ip4.interface in vc_interfaces:
+                pass
+            elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
+                pass
+            else:
+                raise ValidationError({
+                    'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(
+                        self.primary_ip4),
+                })
+        if self.primary_ip6:
+            if self.primary_ip6.interface in vc_interfaces:
+                pass
+            elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
+                pass
+            else:
+                raise ValidationError({
+                    'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(
+                        self.primary_ip6),
+                })
 
-        # Validate primary IPv6 address
-        if self.primary_ip6 and (
-            self.primary_ip6.interface is None or
-            self.primary_ip6.interface.device != self
-        ) and (
-            self.primary_ip6.nat_inside.interface is None or
-            self.primary_ip6.nat_inside.interface.device != self
-        ):
-            raise ValidationError({
-                'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6),
-            })
+        # Validate manufacturer/platform
+        if self.device_type and self.platform:
+            if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
+                raise ValidationError({
+                    'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
+                                "to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
+                })
 
         # A Device can only be assigned to a Cluster in the same Site (or no Site)
         if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
@@ -979,6 +1033,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
                 'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
             })
 
+        # Validate virtual chassis assignment
+        if self.virtual_chassis and self.vc_position is None:
+            raise ValidationError({
+                'vc_position': "A device assigned to a virtual chassis must have its position defined."
+            })
+
     def save(self, *args, **kwargs):
 
         is_new = not bool(self.pk)
@@ -1038,6 +1098,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
     def display_name(self):
         if self.name:
             return self.name
+        elif self.virtual_chassis and self.virtual_chassis.master.name:
+            return "{}:{}".format(self.virtual_chassis.master, self.vc_position)
         elif hasattr(self, 'device_type'):
             return "{}".format(self.device_type)
         return ""
@@ -1062,6 +1124,23 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         else:
             return None
 
+    def get_vc_master(self):
+        """
+        If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
+        """
+        return self.virtual_chassis.master if self.virtual_chassis else None
+
+    @property
+    def vc_interfaces(self):
+        """
+        Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
+        Device belonging to the same VirtualChassis.
+        """
+        filter = Q(device=self)
+        if self.virtual_chassis and self.virtual_chassis.master == self:
+            filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
+        return Interface.objects.filter(filter)
+
     def get_children(self):
         """
         Return the set of child Devices installed in DeviceBays within this Device.
@@ -1069,7 +1148,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         return Device.objects.filter(parent_bay__device=self.pk)
 
     def get_status_class(self):
-        return DEVICE_STATUS_CLASSES[self.status]
+        return STATUS_CLASSES[self.status]
 
     def get_rpc_client(self):
         """
@@ -1104,6 +1183,9 @@ class ConsolePort(models.Model):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
     def to_csv(self):
         return (
             self.cs_port.device.identifier if self.cs_port else None,
@@ -1144,6 +1226,9 @@ class ConsoleServerPort(models.Model):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
     def clean(self):
 
         # Check that the parent device's DeviceType is a console server
@@ -1180,6 +1265,9 @@ class PowerPort(models.Model):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
     def to_csv(self):
         return (
             self.power_outlet.device.identifier if self.power_outlet else None,
@@ -1220,6 +1308,9 @@ class PowerOutlet(models.Model):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
     def clean(self):
 
         # Check that the parent device's DeviceType is a PDU
@@ -1275,6 +1366,24 @@ class Interface(models.Model):
         help_text="This interface is used only for out-of-band management"
     )
     description = models.CharField(max_length=100, blank=True)
+    mode = models.PositiveSmallIntegerField(
+        choices=IFACE_MODE_CHOICES,
+        blank=True,
+        null=True
+    )
+    untagged_vlan = models.ForeignKey(
+        to='ipam.VLAN',
+        null=True,
+        blank=True,
+        verbose_name='Untagged VLAN',
+        related_name='interfaces_as_untagged'
+    )
+    tagged_vlans = models.ManyToManyField(
+        to='ipam.VLAN',
+        blank=True,
+        verbose_name='Tagged VLANs',
+        related_name='interfaces_as_tagged'
+    )
 
     objects = InterfaceQuerySet.as_manager()
 
@@ -1285,6 +1394,9 @@ class Interface(models.Model):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return self.parent.get_absolute_url()
+
     def clean(self):
 
         # Check that the parent device's DeviceType is a network device
@@ -1314,8 +1426,8 @@ class Interface(models.Model):
                                "Disconnect the interface or choose a suitable form factor."
             })
 
-        # An interface's LAG must belong to the same device
-        if self.lag and self.lag.device != self.device:
+        # An interface's LAG must belong to the same device (or VC master)
+        if self.lag and self.lag.device not in [self.device, self.device.get_vc_master()]:
             raise ValidationError({
                 'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
                     self.lag.name, self.lag.device.name
@@ -1336,6 +1448,13 @@ class Interface(models.Model):
                 )
             })
 
+        # Validate untagged VLAN
+        if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
+            raise ValidationError({
+                'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
+                                 "device/VM, or it must be global".format(self.untagged_vlan)
+            })
+
     @property
     def parent(self):
         return self.device or self.virtual_machine
@@ -1439,6 +1558,9 @@ class DeviceBay(models.Model):
     def __str__(self):
         return '{} - {}'.format(self.device.name, self.name)
 
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
     def clean(self):
 
         # Validate that the parent Device can have DeviceBays
@@ -1488,6 +1610,9 @@ class InventoryItem(models.Model):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
     def to_csv(self):
         return (
             self.device.name or '{' + self.device.pk + '}',
@@ -1499,3 +1624,42 @@ class InventoryItem(models.Model):
             self.discovered,
             self.description,
         )
+
+
+#
+# Virtual chassis
+#
+
+@python_2_unicode_compatible
+class VirtualChassis(models.Model):
+    """
+    A collection of Devices which operate with a shared control plane (e.g. a switch stack).
+    """
+    master = models.OneToOneField(
+        to='Device',
+        on_delete=models.PROTECT,
+        related_name='vc_master_for'
+    )
+    domain = models.CharField(
+        max_length=30,
+        blank=True
+    )
+
+    class Meta:
+        ordering = ['master']
+        verbose_name_plural = 'virtual chassis'
+
+    def __str__(self):
+        return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis'
+
+    def get_absolute_url(self):
+        return self.master.get_absolute_url()
+
+    def clean(self):
+
+        # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
+        # VirtualChassis.)
+        if self.pk and self.master not in self.members.all():
+            raise ValidationError({
+                'master': "The selected master is not assigned to this virtual chassis."
+            })

+ 23 - 0
netbox/dcim/signals.py

@@ -0,0 +1,23 @@
+from __future__ import unicode_literals
+
+from django.db.models.signals import post_save, pre_delete
+from django.dispatch import receiver
+
+from .models import Device, VirtualChassis
+
+
+@receiver(post_save, sender=VirtualChassis)
+def assign_virtualchassis_master(instance, created, **kwargs):
+    """
+    When a VirtualChassis is created, automatically assign its master device to the VC.
+    """
+    if created:
+        Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=1)
+
+
+@receiver(pre_delete, sender=VirtualChassis)
+def clear_virtualchassis_members(instance, **kwargs):
+    """
+    When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members.
+    """
+    Device.objects.filter(virtual_chassis=instance.pk).update(vc_position=None, vc_priority=None)

+ 36 - 22
netbox/dcim/tables.py

@@ -9,6 +9,7 @@ from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform,
     PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
+    VirtualChassis,
 )
 
 REGION_LINK = """
@@ -113,7 +114,7 @@ DEVICE_ROLE = """
 <label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
 """
 
-DEVICE_STATUS = """
+STATUS_LABEL = """
 <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
 """
 
@@ -132,6 +133,12 @@ UTILIZATION_GRAPH = """
 {% utilization_graph value %}
 """
 
+VIRTUALCHASSIS_ACTIONS = """
+{% if perms.dcim.change_virtualchassis %}
+    <a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
 
 #
 # Regions
@@ -160,27 +167,13 @@ class RegionTable(BaseTable):
 class SiteTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn()
+    status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
     region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
 
     class Meta(BaseTable.Meta):
         model = Site
-        fields = ('pk', 'name', 'facility', 'region', 'tenant', 'asn')
-
-
-class SiteDetailTable(SiteTable):
-    rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
-    device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
-    prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
-    vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs')
-    circuit_count = tables.Column(accessor=Accessor('count_circuits'), orderable=False, verbose_name='Circuits')
-    vm_count = tables.Column(accessor=Accessor('count_vms'), orderable=False, verbose_name='VMs')
-
-    class Meta(SiteTable.Meta):
-        fields = (
-            'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
-            'vlan_count', 'circuit_count', 'vm_count',
-        )
+        fields = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')
 
 
 #
@@ -270,6 +263,7 @@ class RackImportTable(BaseTable):
 
 class RackReservationTable(BaseTable):
     pk = ToggleColumn()
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
     unit_list = tables.Column(orderable=False, verbose_name='Units')
     actions = tables.TemplateColumn(
@@ -278,7 +272,7 @@ class RackReservationTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = RackReservation
-        fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions')
+        fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions')
 
 
 #
@@ -289,13 +283,14 @@ class ManufacturerTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn(verbose_name='Name')
     devicetype_count = tables.Column(verbose_name='Device Types')
+    platform_count = tables.Column(verbose_name='Platforms')
     slug = tables.Column(verbose_name='Slug')
     actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}},
                                     verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = Manufacturer
-        fields = ('pk', 'name', 'devicetype_count', 'slug', 'actions')
+        fields = ('pk', 'name', 'devicetype_count', 'platform_count', 'slug', 'actions')
 
 
 #
@@ -437,7 +432,7 @@ class PlatformTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Platform
-        fields = ('pk', 'name', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions')
+        fields = ('pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions')
 
 
 #
@@ -447,7 +442,7 @@ class PlatformTable(BaseTable):
 class DeviceTable(BaseTable):
     pk = ToggleColumn()
     name = tables.TemplateColumn(template_code=DEVICE_LINK)
-    status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
+    status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
@@ -474,7 +469,7 @@ class DeviceDetailTable(DeviceTable):
 
 class DeviceImportTable(BaseTable):
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
-    status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
+    status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
@@ -587,3 +582,22 @@ class InventoryItemTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = InventoryItem
         fields = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
+
+
+#
+# Virtual chassis
+#
+
+class VirtualChassisTable(BaseTable):
+    pk = ToggleColumn()
+    master = tables.LinkColumn()
+    member_count = tables.Column(verbose_name='Members')
+    actions = tables.TemplateColumn(
+        template_code=VIRTUALCHASSIS_ACTIONS,
+        attrs={'td': {'class': 'text-right'}},
+        verbose_name=''
+    )
+
+    class Meta(BaseTable.Meta):
+        model = VirtualChassis
+        fields = ('pk', 'master', 'domain', 'member_count', 'actions')

File diff suppressed because it is too large
+ 911 - 55
netbox/dcim/tests/test_api.py


+ 4 - 4
netbox/dcim/tests/test_forms.py

@@ -26,7 +26,7 @@ class DeviceTestCase(TestCase):
             'face': RACK_FACE_FRONT,
             'position': 41,
             'platform': get_id(Platform, 'juniper-junos'),
-            'status': STATUS_ACTIVE,
+            'status': DEVICE_STATUS_ACTIVE,
         })
         self.assertTrue(test.is_valid(), test.fields['position'].choices)
         self.assertTrue(test.save())
@@ -43,7 +43,7 @@ class DeviceTestCase(TestCase):
             'face': RACK_FACE_FRONT,
             'position': 1,
             'platform': get_id(Platform, 'juniper-junos'),
-            'status': STATUS_ACTIVE,
+            'status': DEVICE_STATUS_ACTIVE,
         })
         self.assertFalse(test.is_valid())
 
@@ -59,7 +59,7 @@ class DeviceTestCase(TestCase):
             'face': None,
             'position': None,
             'platform': None,
-            'status': STATUS_ACTIVE,
+            'status': DEVICE_STATUS_ACTIVE,
         })
         self.assertTrue(test.is_valid())
         self.assertTrue(test.save())
@@ -76,7 +76,7 @@ class DeviceTestCase(TestCase):
             'face': RACK_FACE_REAR,
             'position': None,
             'platform': None,
-            'status': STATUS_ACTIVE,
+            'status': DEVICE_STATUS_ACTIVE,
         })
         self.assertTrue(test.is_valid())
         self.assertTrue(test.save())

+ 24 - 12
netbox/dcim/urls.py

@@ -140,8 +140,8 @@ urlpatterns = [
     url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
     url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
     url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
-    url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
-    url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
+    url(r'^console-ports/(?P<pk>\d+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'),
+    url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.ConsolePortDisconnectView.as_view(), name='consoleport_disconnect'),
     url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
     url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
 
@@ -150,17 +150,18 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
     url(r'^devices/(?P<pk>\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
     url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
-    url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
-    url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
+    url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.ConsoleServerPortConnectView.as_view(), name='consoleserverport_connect'),
+    url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.ConsoleServerPortDisconnectView.as_view(), name='consoleserverport_disconnect'),
     url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
     url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
+    url(r'^console-server-ports/rename/$', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
 
     # Power ports
     url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
     url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
     url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
-    url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
-    url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
+    url(r'^power-ports/(?P<pk>\d+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'),
+    url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.PowerPortDisconnectView.as_view(), name='powerport_disconnect'),
     url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
     url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
 
@@ -169,10 +170,11 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
-    url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
-    url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
+    url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'),
+    url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.PowerOutletDisconnectView.as_view(), name='poweroutlet_disconnect'),
     url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
     url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
+    url(r'^power-outlets/rename/$', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
 
     # Interfaces
     url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
@@ -180,10 +182,11 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
     url(r'^devices/(?P<pk>\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
     url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
-    url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
-    url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
+    url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'),
+    url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
     url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
     url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
+    url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
 
     # Device bays
     url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
@@ -191,8 +194,9 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
     url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
     url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
-    url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
-    url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
+    url(r'^device-bays/(?P<pk>\d+)/populate/$', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
+    url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
+    url(r'^device-bays/rename/$', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
 
     # Inventory items
     url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
@@ -211,4 +215,12 @@ urlpatterns = [
     url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
     url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
 
+    # Virtual chassis
+    url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
+    url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
+    url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
+    url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
+    url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
+    url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
+
 ]

File diff suppressed because it is too large
+ 680 - 247
netbox/dcim/views.py


+ 6 - 6
netbox/extras/api/views.py

@@ -6,12 +6,12 @@ from django.shortcuts import get_object_or_404
 from rest_framework.decorators import detail_route
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.response import Response
-from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet
+from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
 
 from extras import filters
 from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
 from extras.reports import get_report, get_reports
-from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, WritableSerializerMixin
+from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
 from . import serializers
 
 
@@ -64,7 +64,7 @@ class CustomFieldModelViewSet(ModelViewSet):
 # Graphs
 #
 
-class GraphViewSet(WritableSerializerMixin, ModelViewSet):
+class GraphViewSet(ModelViewSet):
     queryset = Graph.objects.all()
     serializer_class = serializers.GraphSerializer
     write_serializer_class = serializers.WritableGraphSerializer
@@ -75,7 +75,7 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet):
 # Export templates
 #
 
-class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+class ExportTemplateViewSet(ModelViewSet):
     queryset = ExportTemplate.objects.all()
     serializer_class = serializers.ExportTemplateSerializer
     filter_class = filters.ExportTemplateFilter
@@ -85,7 +85,7 @@ class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
 # Topology maps
 #
 
-class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
+class TopologyMapViewSet(ModelViewSet):
     queryset = TopologyMap.objects.select_related('site')
     serializer_class = serializers.TopologyMapSerializer
     write_serializer_class = serializers.WritableTopologyMapSerializer
@@ -115,7 +115,7 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
 # Image attachments
 #
 
-class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
+class ImageAttachmentViewSet(ModelViewSet):
     queryset = ImageAttachment.objects.all()
     serializer_class = serializers.ImageAttachmentSerializer
     write_serializer_class = serializers.WritableImageAttachmentSerializer

+ 2 - 2
netbox/extras/management/commands/run_inventory.py

@@ -8,7 +8,7 @@ from django.db import transaction
 from ncclient.transport.errors import AuthenticationError
 from paramiko import AuthenticationException
 
-from dcim.models import Device, InventoryItem, Site, STATUS_ACTIVE
+from dcim.models import DEVICE_STATUS_ACTIVE, Device, InventoryItem, Site
 
 
 class Command(BaseCommand):
@@ -41,7 +41,7 @@ class Command(BaseCommand):
             self.password = getpass("Password: ")
 
         # Attempt to inventory only active devices
-        device_list = Device.objects.filter(status=STATUS_ACTIVE)
+        device_list = Device.objects.filter(status=DEVICE_STATUS_ACTIVE)
 
         # --site: Include only devices belonging to specified site(s)
         if options['site']:

+ 62 - 4
netbox/extras/tests/test_api.py

@@ -54,7 +54,7 @@ class GraphTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('extras-api:graph-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Graph.objects.count(), 4)
@@ -63,6 +63,35 @@ class GraphTest(HttpStatusMixin, APITestCase):
         self.assertEqual(graph4.name, data['name'])
         self.assertEqual(graph4.source, data['source'])
 
+    def test_create_graph_bulk(self):
+
+        data = [
+            {
+                'type': GRAPH_TYPE_SITE,
+                'name': 'Test Graph 4',
+                'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
+            },
+            {
+                'type': GRAPH_TYPE_SITE,
+                'name': 'Test Graph 5',
+                'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
+            },
+            {
+                'type': GRAPH_TYPE_SITE,
+                'name': 'Test Graph 6',
+                'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
+            },
+        ]
+
+        url = reverse('extras-api:graph-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Graph.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_graph(self):
 
         data = {
@@ -72,7 +101,7 @@ class GraphTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(Graph.objects.count(), 3)
@@ -135,7 +164,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('extras-api:exporttemplate-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(ExportTemplate.objects.count(), 4)
@@ -144,6 +173,35 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
         self.assertEqual(exporttemplate4.name, data['name'])
         self.assertEqual(exporttemplate4.template_code, data['template_code'])
 
+    def test_create_exporttemplate_bulk(self):
+
+        data = [
+            {
+                'content_type': self.content_type.pk,
+                'name': 'Test Export Template 4',
+                'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+            },
+            {
+                'content_type': self.content_type.pk,
+                'name': 'Test Export Template 5',
+                'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+            },
+            {
+                'content_type': self.content_type.pk,
+                'name': 'Test Export Template 6',
+                'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+            },
+        ]
+
+        url = reverse('extras-api:exporttemplate-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(ExportTemplate.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_exporttemplate(self):
 
         data = {
@@ -153,7 +211,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(ExportTemplate.objects.count(), 3)

+ 57 - 15
netbox/ipam/api/serializers.py

@@ -3,9 +3,11 @@ from __future__ import unicode_literals
 from collections import OrderedDict
 
 from rest_framework import serializers
+from rest_framework.reverse import reverse
 from rest_framework.validators import UniqueTogetherValidator
 
 from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
+from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
 from ipam.constants import (
     IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES,
@@ -25,7 +27,10 @@ class VRFSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = VRF
-        fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields']
+        fields = [
+            'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created',
+            'last_updated',
+        ]
 
 
 class NestedVRFSerializer(serializers.ModelSerializer):
@@ -40,7 +45,9 @@ class WritableVRFSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = VRF
-        fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
+        fields = [
+            'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields', 'created', 'last_updated',
+        ]
 
 
 #
@@ -90,7 +97,9 @@ class AggregateSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Aggregate
-        fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
+        fields = [
+            'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated',
+        ]
 
 
 class NestedAggregateSerializer(serializers.ModelSerializer):
@@ -105,7 +114,7 @@ class WritableAggregateSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Aggregate
-        fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
+        fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated']
 
 
 #
@@ -165,7 +174,7 @@ class VLANSerializer(CustomFieldModelSerializer):
         model = VLAN
         fields = [
             'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
-            'custom_fields',
+            'custom_fields', 'created', 'last_updated',
         ]
 
 
@@ -181,7 +190,10 @@ class WritableVLANSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = VLAN
-        fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields']
+        fields = [
+            'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields', 'created',
+            'last_updated',
+        ]
         validators = []
 
     def validate(self, data):
@@ -215,7 +227,7 @@ class PrefixSerializer(CustomFieldModelSerializer):
         model = Prefix
         fields = [
             'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
-            'custom_fields',
+            'custom_fields', 'created', 'last_updated',
         ]
 
 
@@ -233,23 +245,47 @@ class WritablePrefixSerializer(CustomFieldModelSerializer):
         model = Prefix
         fields = [
             'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
-            'custom_fields',
+            'custom_fields', 'created', 'last_updated',
         ]
 
 
+class AvailablePrefixSerializer(serializers.Serializer):
+
+    def to_representation(self, instance):
+        if self.context.get('vrf'):
+            vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
+        else:
+            vrf = None
+        return OrderedDict([
+            ('family', instance.version),
+            ('prefix', str(instance)),
+            ('vrf', vrf),
+        ])
+
+
 #
 # IP addresses
 #
 
-class IPAddressInterfaceSerializer(InterfaceSerializer):
+class IPAddressInterfaceSerializer(serializers.ModelSerializer):
+    url = serializers.SerializerMethodField()  # We're imitating a HyperlinkedIdentityField here
+    device = NestedDeviceSerializer()
     virtual_machine = NestedVirtualMachineSerializer()
 
     class Meta(InterfaceSerializer.Meta):
+        model = Interface
         fields = [
-            'id', 'device', 'virtual_machine', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address',
-            'mgmt_only', 'description', 'is_connected', 'interface_connection', 'circuit_termination',
+            'id', 'url', 'device', 'virtual_machine', 'name',
         ]
 
+    def get_url(self, obj):
+        """
+        Return a link to the Interface via either the DCIM API if the parent is a Device, or via the virtualization API
+        if the parent is a VirtualMachine.
+        """
+        url_name = 'dcim-api:interface-detail' if obj.device else 'virtualization-api:interface-detail'
+        return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request'])
+
 
 class IPAddressSerializer(CustomFieldModelSerializer):
     vrf = NestedVRFSerializer()
@@ -262,7 +298,7 @@ class IPAddressSerializer(CustomFieldModelSerializer):
         model = IPAddress
         fields = [
             'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
-            'nat_outside', 'custom_fields',
+            'nat_outside', 'custom_fields', 'created', 'last_updated',
         ]
 
 
@@ -284,7 +320,7 @@ class WritableIPAddressSerializer(CustomFieldModelSerializer):
         model = IPAddress
         fields = [
             'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
-            'custom_fields',
+            'custom_fields', 'created', 'last_updated',
         ]
 
 
@@ -314,7 +350,10 @@ class ServiceSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Service
-        fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
+        fields = [
+            'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
+            'last_updated',
+        ]
 
 
 # TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
@@ -322,4 +361,7 @@ class WritableServiceSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Service
-        fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
+        fields = [
+            'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
+            'last_updated',
+        ]

+ 92 - 22
netbox/ipam/api/views.py

@@ -6,12 +6,11 @@ from rest_framework import status
 from rest_framework.decorators import detail_route
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.response import Response
-from rest_framework.viewsets import ModelViewSet
 
 from extras.api.views import CustomFieldModelViewSet
 from ipam import filters
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
-from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
+from utilities.api import FieldChoicesViewSet, ModelViewSet
 from . import serializers
 
 
@@ -33,7 +32,7 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
 # VRFs
 #
 
-class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class VRFViewSet(CustomFieldModelViewSet):
     queryset = VRF.objects.select_related('tenant')
     serializer_class = serializers.VRFSerializer
     write_serializer_class = serializers.WritableVRFSerializer
@@ -54,7 +53,7 @@ class RIRViewSet(ModelViewSet):
 # Aggregates
 #
 
-class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class AggregateViewSet(CustomFieldModelViewSet):
     queryset = Aggregate.objects.select_related('rir')
     serializer_class = serializers.AggregateSerializer
     write_serializer_class = serializers.WritableAggregateSerializer
@@ -75,12 +74,72 @@ class RoleViewSet(ModelViewSet):
 # Prefixes
 #
 
-class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class PrefixViewSet(CustomFieldModelViewSet):
     queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     serializer_class = serializers.PrefixSerializer
     write_serializer_class = serializers.WritablePrefixSerializer
     filter_class = filters.PrefixFilter
 
+    @detail_route(url_path='available-prefixes', methods=['get', 'post'])
+    def available_prefixes(self, request, pk=None):
+        """
+        A convenience method for returning available child prefixes within a parent.
+        """
+        prefix = get_object_or_404(Prefix, pk=pk)
+        available_prefixes = prefix.get_available_prefixes()
+
+        if request.method == 'POST':
+
+            # Permissions check
+            if not request.user.has_perm('ipam.add_prefix'):
+                raise PermissionDenied()
+
+            # Normalize to a list of objects
+            requested_prefixes = request.data if isinstance(request.data, list) else [request.data]
+
+            # Allocate prefixes to the requested objects based on availability within the parent
+            for requested_prefix in requested_prefixes:
+
+                # Find the first available prefix equal to or larger than the requested size
+                for available_prefix in available_prefixes.iter_cidrs():
+                    if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
+                        allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
+                        requested_prefix['prefix'] = allocated_prefix
+                        requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
+                        break
+                else:
+                    return Response(
+                        {
+                            "detail": "Insufficient space is available to accommodate the requested prefix size(s)"
+                        },
+                        status=status.HTTP_400_BAD_REQUEST
+                    )
+
+                # Remove the allocated prefix from the list of available prefixes
+                available_prefixes.remove(allocated_prefix)
+
+            # Initialize the serializer with a list or a single object depending on what was requested
+            if isinstance(request.data, list):
+                serializer = serializers.WritablePrefixSerializer(data=requested_prefixes, many=True)
+            else:
+                serializer = serializers.WritablePrefixSerializer(data=requested_prefixes[0])
+
+            # Create the new Prefix(es)
+            if serializer.is_valid():
+                serializer.save()
+                return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+        else:
+
+            serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
+                'request': request,
+                'vrf': prefix.vrf,
+            })
+
+            return Response(serializer.data)
+
     @detail_route(url_path='available-ips', methods=['get', 'post'])
     def available_ips(self, request, pk=None):
         """
@@ -97,28 +156,39 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
             if not request.user.has_perm('ipam.add_ipaddress'):
                 raise PermissionDenied()
 
-            # Find the first available IP address in the prefix
-            try:
-                ipaddress = list(prefix.get_available_ips())[0]
-            except IndexError:
+            # Normalize to a list of objects
+            requested_ips = request.data if isinstance(request.data, list) else [request.data]
+
+            # Determine if the requested number of IPs is available
+            available_ips = list(prefix.get_available_ips())
+            if len(available_ips) < len(requested_ips):
                 return Response(
                     {
-                        "detail": "There are no available IPs within this prefix ({})".format(prefix)
+                        "detail": "An insufficient number of IP addresses are available within the prefix {} ({} "
+                                  "requested, {} available)".format(prefix, len(requested_ips), len(available_ips))
                     },
                     status=status.HTTP_400_BAD_REQUEST
                 )
 
-            # Create the new IP address
-            data = request.data.copy()
-            data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen)
-            data['vrf'] = prefix.vrf.pk if prefix.vrf else None
-            serializer = serializers.WritableIPAddressSerializer(data=data)
+            # Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix
+            for requested_ip in requested_ips:
+                requested_ip['address'] = available_ips.pop(0)
+                requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None
+
+            # Initialize the serializer with a list or a single object depending on what was requested
+            if isinstance(request.data, list):
+                serializer = serializers.WritableIPAddressSerializer(data=requested_ips, many=True)
+            else:
+                serializer = serializers.WritableIPAddressSerializer(data=requested_ips[0])
+
+            # Create the new IP address(es)
             if serializer.is_valid():
                 serializer.save()
                 return Response(serializer.data, status=status.HTTP_201_CREATED)
+
             return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
-        # Determine the maximum amount of IPs to return
+        # Determine the maximum number of IPs to return
         else:
             try:
                 limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT))
@@ -146,11 +216,11 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 # IP addresses
 #
 
-class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class IPAddressViewSet(CustomFieldModelViewSet):
     queryset = IPAddress.objects.select_related(
-        'vrf__tenant', 'tenant', 'nat_inside'
+        'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine'
     ).prefetch_related(
-        'interface__device', 'interface__virtual_machine'
+        'nat_outside'
     )
     serializer_class = serializers.IPAddressSerializer
     write_serializer_class = serializers.WritableIPAddressSerializer
@@ -161,7 +231,7 @@ class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 # VLAN groups
 #
 
-class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
+class VLANGroupViewSet(ModelViewSet):
     queryset = VLANGroup.objects.select_related('site')
     serializer_class = serializers.VLANGroupSerializer
     write_serializer_class = serializers.WritableVLANGroupSerializer
@@ -172,7 +242,7 @@ class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
 # VLANs
 #
 
-class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class VLANViewSet(CustomFieldModelViewSet):
     queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
     serializer_class = serializers.VLANSerializer
     write_serializer_class = serializers.WritableVLANSerializer
@@ -183,7 +253,7 @@ class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 # Services
 #
 
-class ServiceViewSet(WritableSerializerMixin, ModelViewSet):
+class ServiceViewSet(ModelViewSet):
     queryset = Service.objects.select_related('device')
     serializer_class = serializers.ServiceSerializer
     write_serializer_class = serializers.WritableServiceSerializer

+ 16 - 14
netbox/ipam/filters.py

@@ -99,11 +99,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         method='search',
         label='Search',
     )
-    # TODO: Deprecate in v2.3.0
-    parent = django_filters.CharFilter(
-        method='search_within_include',
-        label='Parent prefix (deprecated)',
-    )
     within = django_filters.CharFilter(
         method='search_within',
         label='Within prefix',
@@ -262,16 +257,15 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         label='Tenant (slug)',
     )
-    device_id = django_filters.ModelMultipleChoiceFilter(
-        name='interface__device',
-        queryset=Device.objects.all(),
-        label='Device (ID)',
+    device = django_filters.CharFilter(
+        method='filter_device',
+        name='name',
+        label='Device',
     )
-    device = django_filters.ModelMultipleChoiceFilter(
-        name='interface__device__name',
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        label='Device (name)',
+    device_id = django_filters.NumberFilter(
+        method='filter_device',
+        name='pk',
+        label='Device (ID)',
     )
     virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
         name='interface__virtual_machine',
@@ -324,6 +318,14 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
             return queryset
         return queryset.filter(address__net_mask_length=value)
 
+    def filter_device(self, queryset, name, value):
+        try:
+            device = Device.objects.select_related('device_type').get(**{name: value})
+            vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
+            return queryset.filter(interface_id__in=vc_interface_ids)
+        except Device.DoesNotExist:
+            return queryset.none()
+
 
 class VLANGroupFilter(django_filters.FilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(

+ 2 - 1
netbox/ipam/forms.py

@@ -931,8 +931,9 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
 
         # Limit IP address choices to those assigned to interfaces of the parent device/VM
         if self.instance.device:
+            vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')]
             self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
-                interface__device=self.instance.device
+                interface_id__in=vc_interface_ids
             )
         elif self.instance.virtual_machine:
             self.fields['ipaddresses'].queryset = IPAddress.objects.filter(

+ 348 - 22
netbox/ipam/tests/test_api.py

@@ -47,7 +47,7 @@ class VRFTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:vrf-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(VRF.objects.count(), 4)
@@ -55,6 +55,32 @@ class VRFTest(HttpStatusMixin, APITestCase):
         self.assertEqual(vrf4.name, data['name'])
         self.assertEqual(vrf4.rd, data['rd'])
 
+    def test_create_vrf_bulk(self):
+
+        data = [
+            {
+                'name': 'Test VRF 4',
+                'rd': '65000:4',
+            },
+            {
+                'name': 'Test VRF 5',
+                'rd': '65000:5',
+            },
+            {
+                'name': 'Test VRF 6',
+                'rd': '65000:6',
+            },
+        ]
+
+        url = reverse('ipam-api:vrf-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(VRF.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_vrf(self):
 
         data = {
@@ -63,7 +89,7 @@ class VRFTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(VRF.objects.count(), 3)
@@ -114,7 +140,7 @@ class RIRTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:rir-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(RIR.objects.count(), 4)
@@ -122,6 +148,32 @@ class RIRTest(HttpStatusMixin, APITestCase):
         self.assertEqual(rir4.name, data['name'])
         self.assertEqual(rir4.slug, data['slug'])
 
+    def test_create_rir_bulk(self):
+
+        data = [
+            {
+                'name': 'Test RIR 4',
+                'slug': 'test-rir-4',
+            },
+            {
+                'name': 'Test RIR 5',
+                'slug': 'test-rir-5',
+            },
+            {
+                'name': 'Test RIR 6',
+                'slug': 'test-rir-6',
+            },
+        ]
+
+        url = reverse('ipam-api:rir-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(RIR.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_rir(self):
 
         data = {
@@ -130,7 +182,7 @@ class RIRTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(RIR.objects.count(), 3)
@@ -183,7 +235,7 @@ class AggregateTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:aggregate-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Aggregate.objects.count(), 4)
@@ -191,6 +243,32 @@ class AggregateTest(HttpStatusMixin, APITestCase):
         self.assertEqual(str(aggregate4.prefix), data['prefix'])
         self.assertEqual(aggregate4.rir_id, data['rir'])
 
+    def test_create_aggregate_bulk(self):
+
+        data = [
+            {
+                'prefix': '100.0.0.0/8',
+                'rir': self.rir1.pk,
+            },
+            {
+                'prefix': '101.0.0.0/8',
+                'rir': self.rir1.pk,
+            },
+            {
+                'prefix': '102.0.0.0/8',
+                'rir': self.rir1.pk,
+            },
+        ]
+
+        url = reverse('ipam-api:aggregate-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Aggregate.objects.count(), 6)
+        self.assertEqual(response.data[0]['prefix'], data[0]['prefix'])
+        self.assertEqual(response.data[1]['prefix'], data[1]['prefix'])
+        self.assertEqual(response.data[2]['prefix'], data[2]['prefix'])
+
     def test_update_aggregate(self):
 
         data = {
@@ -199,7 +277,7 @@ class AggregateTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(Aggregate.objects.count(), 3)
@@ -250,7 +328,7 @@ class RoleTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:role-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Role.objects.count(), 4)
@@ -258,6 +336,32 @@ class RoleTest(HttpStatusMixin, APITestCase):
         self.assertEqual(role4.name, data['name'])
         self.assertEqual(role4.slug, data['slug'])
 
+    def test_create_role_bulk(self):
+
+        data = [
+            {
+                'name': 'Test Role 4',
+                'slug': 'test-role-4',
+            },
+            {
+                'name': 'Test Role 5',
+                'slug': 'test-role-5',
+            },
+            {
+                'name': 'Test Role 6',
+                'slug': 'test-role-6',
+            },
+        ]
+
+        url = reverse('ipam-api:role-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Role.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_role(self):
 
         data = {
@@ -266,7 +370,7 @@ class RoleTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(Role.objects.count(), 3)
@@ -324,7 +428,7 @@ class PrefixTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:prefix-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Prefix.objects.count(), 4)
@@ -335,6 +439,29 @@ class PrefixTest(HttpStatusMixin, APITestCase):
         self.assertEqual(prefix4.vlan_id, data['vlan'])
         self.assertEqual(prefix4.role_id, data['role'])
 
+    def test_create_prefix_bulk(self):
+
+        data = [
+            {
+                'prefix': '10.0.1.0/24',
+            },
+            {
+                'prefix': '10.0.2.0/24',
+            },
+            {
+                'prefix': '10.0.3.0/24',
+            },
+        ]
+
+        url = reverse('ipam-api:prefix-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Prefix.objects.count(), 6)
+        self.assertEqual(response.data[0]['prefix'], data[0]['prefix'])
+        self.assertEqual(response.data[1]['prefix'], data[1]['prefix'])
+        self.assertEqual(response.data[2]['prefix'], data[2]['prefix'])
+
     def test_update_prefix(self):
 
         data = {
@@ -346,7 +473,7 @@ class PrefixTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(Prefix.objects.count(), 3)
@@ -365,7 +492,73 @@ class PrefixTest(HttpStatusMixin, APITestCase):
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertEqual(Prefix.objects.count(), 2)
 
-    def test_available_ips(self):
+    def test_list_available_prefixes(self):
+
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
+        Prefix.objects.create(prefix=IPNetwork('192.0.2.64/26'))
+        Prefix.objects.create(prefix=IPNetwork('192.0.2.192/27'))
+        url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
+
+        # Retrieve all available IPs
+        response = self.client.get(url, **self.header)
+        available_prefixes = ['192.0.2.0/26', '192.0.2.128/26', '192.0.2.224/27']
+        for i, p in enumerate(response.data):
+            self.assertEqual(p['prefix'], available_prefixes[i])
+
+    def test_create_single_available_prefix(self):
+
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True)
+        url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
+
+        # Create four available prefixes with individual requests
+        prefixes_to_be_created = [
+            '192.0.2.0/30',
+            '192.0.2.4/30',
+            '192.0.2.8/30',
+            '192.0.2.12/30',
+        ]
+        for i in range(4):
+            data = {
+                'prefix_length': 30,
+                'description': 'Test Prefix {}'.format(i + 1)
+            }
+            response = self.client.post(url, data, format='json', **self.header)
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            self.assertEqual(response.data['prefix'], prefixes_to_be_created[i])
+            self.assertEqual(response.data['description'], data['description'])
+
+        # Try to create one more prefix
+        response = self.client.post(url, {'prefix_length': 30}, **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('detail', response.data)
+
+    def test_create_multiple_available_prefixes(self):
+
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True)
+        url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
+
+        # Try to create five /30s (only four are available)
+        data = [
+            {'prefix_length': 30, 'description': 'Test Prefix 1'},
+            {'prefix_length': 30, 'description': 'Test Prefix 2'},
+            {'prefix_length': 30, 'description': 'Test Prefix 3'},
+            {'prefix_length': 30, 'description': 'Test Prefix 4'},
+            {'prefix_length': 30, 'description': 'Test Prefix 5'},
+        ]
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('detail', response.data)
+
+        # Verify that no prefixes were created (the entire /28 is still available)
+        response = self.client.get(url, **self.header)
+        self.assertEqual(response.data[0]['prefix'], '192.0.2.0/28')
+
+        # Create four /30s in a single request
+        response = self.client.post(url, data[:4], format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(len(response.data), 4)
+
+    def test_list_available_ips(self):
 
         prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True)
         url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
@@ -380,12 +573,17 @@ class PrefixTest(HttpStatusMixin, APITestCase):
         response = self.client.get(url, **self.header)
         self.assertEqual(len(response.data), 6)  # 8 - 2 because prefix.is_pool = False
 
-        # Create all six available IPs
-        for i in range(6):
+    def test_create_single_available_ip(self):
+
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), is_pool=True)
+        url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
+
+        # Create all four available IPs with individual requests
+        for i in range(1, 5):
             data = {
                 'description': 'Test IP {}'.format(i)
             }
-            response = self.client.post(url, data, **self.header)
+            response = self.client.post(url, data, format='json', **self.header)
             self.assertHttpStatus(response, status.HTTP_201_CREATED)
             self.assertEqual(response.data['description'], data['description'])
 
@@ -394,6 +592,27 @@ class PrefixTest(HttpStatusMixin, APITestCase):
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertIn('detail', response.data)
 
+    def test_create_multiple_available_ips(self):
+
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True)
+        url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
+
+        # Try to create nine IPs (only eight are available)
+        data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 10)]  # 9 IPs
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('detail', response.data)
+
+        # Verify that no IPs were created (eight are still available)
+        response = self.client.get(url, **self.header)
+        self.assertEqual(len(response.data), 8)
+
+        # Create all eight available IPs in a single request
+        data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 9)]  # 8 IPs
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(len(response.data), 8)
+
 
 class IPAddressTest(HttpStatusMixin, APITestCase):
 
@@ -430,7 +649,7 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:ipaddress-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(IPAddress.objects.count(), 4)
@@ -438,6 +657,29 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
         self.assertEqual(str(ipaddress4.address), data['address'])
         self.assertEqual(ipaddress4.vrf_id, data['vrf'])
 
+    def test_create_ipaddress_bulk(self):
+
+        data = [
+            {
+                'address': '192.168.0.4/24',
+            },
+            {
+                'address': '192.168.0.5/24',
+            },
+            {
+                'address': '192.168.0.6/24',
+            },
+        ]
+
+        url = reverse('ipam-api:ipaddress-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(IPAddress.objects.count(), 6)
+        self.assertEqual(response.data[0]['address'], data[0]['address'])
+        self.assertEqual(response.data[1]['address'], data[1]['address'])
+        self.assertEqual(response.data[2]['address'], data[2]['address'])
+
     def test_update_ipaddress(self):
 
         data = {
@@ -446,7 +688,7 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(IPAddress.objects.count(), 3)
@@ -497,7 +739,7 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:vlangroup-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(VLANGroup.objects.count(), 4)
@@ -505,6 +747,32 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
         self.assertEqual(vlangroup4.name, data['name'])
         self.assertEqual(vlangroup4.slug, data['slug'])
 
+    def test_create_vlangroup_bulk(self):
+
+        data = [
+            {
+                'name': 'Test VLAN Group 4',
+                'slug': 'test-vlan-group-4',
+            },
+            {
+                'name': 'Test VLAN Group 5',
+                'slug': 'test-vlan-group-5',
+            },
+            {
+                'name': 'Test VLAN Group 6',
+                'slug': 'test-vlan-group-6',
+            },
+        ]
+
+        url = reverse('ipam-api:vlangroup-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(VLANGroup.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_vlangroup(self):
 
         data = {
@@ -513,7 +781,7 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(VLANGroup.objects.count(), 3)
@@ -564,7 +832,7 @@ class VLANTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:vlan-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(VLAN.objects.count(), 4)
@@ -572,6 +840,32 @@ class VLANTest(HttpStatusMixin, APITestCase):
         self.assertEqual(vlan4.vid, data['vid'])
         self.assertEqual(vlan4.name, data['name'])
 
+    def test_create_vlan_bulk(self):
+
+        data = [
+            {
+                'vid': 4,
+                'name': 'Test VLAN 4',
+            },
+            {
+                'vid': 5,
+                'name': 'Test VLAN 5',
+            },
+            {
+                'vid': 6,
+                'name': 'Test VLAN 6',
+            },
+        ]
+
+        url = reverse('ipam-api:vlan-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(VLAN.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_vlan(self):
 
         data = {
@@ -580,7 +874,7 @@ class VLANTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(VLAN.objects.count(), 3)
@@ -649,7 +943,7 @@ class ServiceTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:service-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Service.objects.count(), 4)
@@ -659,6 +953,38 @@ class ServiceTest(HttpStatusMixin, APITestCase):
         self.assertEqual(service4.protocol, data['protocol'])
         self.assertEqual(service4.port, data['port'])
 
+    def test_create_service_bulk(self):
+
+        data = [
+            {
+                'device': self.device1.pk,
+                'name': 'Test Service 4',
+                'protocol': IP_PROTOCOL_TCP,
+                'port': 4,
+            },
+            {
+                'device': self.device1.pk,
+                'name': 'Test Service 5',
+                'protocol': IP_PROTOCOL_TCP,
+                'port': 5,
+            },
+            {
+                'device': self.device1.pk,
+                'name': 'Test Service 6',
+                'protocol': IP_PROTOCOL_TCP,
+                'port': 6,
+            },
+        ]
+
+        url = reverse('ipam-api:service-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Service.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_service(self):
 
         data = {
@@ -669,7 +995,7 @@ class ServiceTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(Service.objects.count(), 3)

+ 11 - 1
netbox/netbox/settings.py

@@ -1,6 +1,8 @@
 import logging
 import os
 import socket
+import sys
+import warnings
 
 from django.contrib.messages import constants as messages
 from django.core.exceptions import ImproperlyConfigured
@@ -12,8 +14,15 @@ except ImportError:
         "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
     )
 
+# Raise a deprecation warning for Python 2.x
+if sys.version_info[0] < 3:
+    warnings.warn(
+        "Support for Python 2 will be removed in NetBox v2.5. Please consider migration to Python 3 at your earliest "
+        "opportunity. Guidance is available in the documentation at http://netbox.readthedocs.io/.",
+        DeprecationWarning
+    )
 
-VERSION = '2.2.11-dev'
+VERSION = '2.3.0-dev'
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
@@ -125,6 +134,7 @@ INSTALLED_APPS = (
     'mptt',
     'rest_framework',
     'rest_framework_swagger',
+    'timezone_field',
     'circuits',
     'dcim',
     'ipam',

+ 1 - 1
netbox/project-static/css/base.css

@@ -121,7 +121,7 @@ input[name="pk"] {
 }
 
 /* Tables */
-.table > tbody > tr > th.pk, .table > tbody > tr > td.pk {
+th.pk, td.pk {
     padding-bottom: 6px;
     padding-top: 10px;
     width: 30px;

+ 0 - 7
netbox/project-static/font-awesome-4.7.0/HELP-US-OUT.txt

@@ -1,7 +0,0 @@
-I hope you love Font Awesome. If you've found it useful, please do me a favor and check out my latest project,
-Fort Awesome (https://fortawesome.com). It makes it easy to put the perfect icons on your website. Choose from our awesome,
-comprehensive icon sets or copy and paste your own.
-
-Please. Check it out.
-
--Dave Gandy

+ 66 - 65
netbox/project-static/js/forms.js

@@ -1,14 +1,24 @@
 $(document).ready(function() {
 
-    // "Toggle all" checkbox (table header)
-    $('#toggle_all').click(function() {
-        $('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
+    // "Toggle" checkbox for object lists (PK column)
+    $('input:checkbox.toggle').click(function() {
+        $(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
+
+        // Show the "select all" box if present
         if ($(this).is(':checked')) {
             $('#select_all_box').removeClass('hidden');
         } else {
             $('#select_all').prop('checked', false);
         }
     });
+
+    // Uncheck the "toggle" and "select all" checkboxes if an item is unchecked
+    $('input:checkbox[name=pk]').click(function (event) {
+        if (!$(this).attr('checked')) {
+            $('input:checkbox.toggle, #select_all').prop('checked', false);
+        }
+    });
+
     // Enable hidden buttons when "select all" is checked
     $('#select_all').click(function() {
         if ($(this).is(':checked')) {
@@ -17,21 +27,6 @@ $(document).ready(function() {
             $('#select_all_box').find('button').prop('disabled', 'disabled');
         }
     });
-    // Uncheck the "toggle all" checkbox if an item is unchecked
-    $('input:checkbox[name=pk]').click(function (event) {
-        if (!$(this).attr('checked')) {
-            $('#select_all, #toggle_all').prop('checked', false);
-        }
-    });
-
-    // Simple "Toggle all" button (panel)
-    $('button.toggle').click(function() {
-        var selected = $(this).attr('selected');
-        $(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected);
-        $(this).attr('selected', !selected);
-        $(this).children('span').toggleClass('glyphicon-unchecked glyphicon-check');
-        return false;
-    });
 
     // Slugify
     function slugify(s, num_chars) {
@@ -71,59 +66,65 @@ $(document).ready(function() {
     $('select[filter-for]').change(function() {
 
         // Resolve child field by ID specified in parent
-        var child_name = $(this).attr('filter-for');
-        var child_field = $('#id_' + child_name);
-        var child_selected = child_field.val();
-
-        // Wipe out any existing options within the child field and create a default option
-        child_field.empty();
-        if (!child_field.attr('multiple')) {
-            child_field.append($("<option></option>").attr("value", "").text("---------"));
-        }
+        var child_names = $(this).attr('filter-for');
+        var parent = this;
 
-        if ($(this).val() || $(this).attr('nullable') == 'true') {
-            var api_url = child_field.attr('api-url') + '&limit=1000';
-            var disabled_indicator = child_field.attr('disabled-indicator');
-            var initial_value = child_field.attr('initial');
-            var display_field = child_field.attr('display-field') || 'name';
-
-            // Determine the filter fields needed to make an API call
-            var filter_regex = /\{\{([a-z_]+)\}\}/g;
-            var match;
-            var rendered_url = api_url;
-            while (match = filter_regex.exec(api_url)) {
-                var filter_field = $('#id_' + match[1]);
-                if (filter_field.val()) {
-                    rendered_url = rendered_url.replace(match[0], filter_field.val());
-                } else if (filter_field.attr('nullable') == 'true') {
-                    rendered_url = rendered_url.replace(match[0], '0');
-                }
+        // allow more than one child
+        $.each(child_names.split(" "), function(_, child_name){
+
+            var child_field = $('#id_' + child_name);
+            var child_selected = child_field.val();
+
+            // Wipe out any existing options within the child field and create a default option
+            child_field.empty();
+            if (!child_field.attr('multiple')) {
+                child_field.append($("<option></option>").attr("value", "").text("---------"));
             }
 
-            // If all URL variables have been replaced, make the API call
-            if (rendered_url.search('{{') < 0) {
-                console.log(child_name + ": Fetching " + rendered_url);
-                $.ajax({
-                    url: rendered_url,
-                    dataType: 'json',
-                    success: function(response, status) {
-                        $.each(response.results, function(index, choice) {
-                            var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
-                            if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
-                                option.attr("disabled", "disabled");
-                            } else if (choice.id == child_selected) {
-                                option.attr("selected", "selected");
-                            }
-                            child_field.append(option);
-                        });
+            if ($(parent).val() || $(parent).attr('nullable') == 'true') {
+                var api_url = child_field.attr('api-url') + '&limit=1000';
+                var disabled_indicator = child_field.attr('disabled-indicator');
+                var initial_value = child_field.attr('initial');
+                var display_field = child_field.attr('display-field') || 'name';
+
+                // Determine the filter fields needed to make an API call
+                var filter_regex = /\{\{([a-z_]+)\}\}/g;
+                var match;
+                var rendered_url = api_url;
+                while (match = filter_regex.exec(api_url)) {
+                    var filter_field = $('#id_' + match[1]);
+                    if (filter_field.val()) {
+                        rendered_url = rendered_url.replace(match[0], filter_field.val());
+                    } else if (filter_field.attr('nullable') == 'true') {
+                        rendered_url = rendered_url.replace(match[0], '0');
                     }
-                });
-            }
+                }
 
-        }
+                // If all URL variables have been replaced, make the API call
+                if (rendered_url.search('{{') < 0) {
+                    console.log(child_name + ": Fetching " + rendered_url);
+                    $.ajax({
+                        url: rendered_url,
+                        dataType: 'json',
+                        success: function(response, status) {
+                            $.each(response.results, function(index, choice) {
+                                var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
+                                if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
+                                    option.attr("disabled", "disabled");
+                                } else if (choice.id == child_selected) {
+                                    option.attr("selected", "selected");
+                                }
+                                child_field.append(option);
+                            });
+                        }
+                    });
+                }
+
+            }
 
-        // Trigger change event in case the child field is the parent of another field
-        child_field.change();
+            // Trigger change event in case the child field is the parent of another field
+            child_field.change();
+        });
 
     });
 });

File diff suppressed because it is too large
+ 0 - 4
netbox/project-static/js/jquery-3.2.1.min.js


File diff suppressed because it is too large
+ 2 - 0
netbox/project-static/js/jquery-3.3.1.min.js


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

@@ -45,13 +45,20 @@ class WritableSecretSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Secret
-        fields = ['id', 'device', 'role', 'name', 'plaintext']
+        fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
         validators = []
 
     def validate(self, data):
 
+        # Encrypt plaintext data using the master key provided from the view context
+        if data.get('plaintext'):
+            s = Secret(plaintext=data['plaintext'])
+            s.encrypt(self.context['master_key'])
+            data['ciphertext'] = s.ciphertext
+            data['hash'] = s.hash
+
         # Validate uniqueness of name if one has been provided.
-        if data.get('name', None):
+        if data.get('name'):
             validator = UniqueTogetherValidator(queryset=Secret.objects.all(), fields=('device', 'role', 'name'))
             validator.set_context(self)
             validator(data)

+ 10 - 20
netbox/secrets/api/views.py

@@ -7,12 +7,12 @@ from django.http import HttpResponseBadRequest
 from rest_framework.exceptions import ValidationError
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
-from rest_framework.viewsets import ModelViewSet, ViewSet
+from rest_framework.viewsets import ViewSet
 
 from secrets import filters
 from secrets.exceptions import InvalidKey
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
-from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
+from utilities.api import FieldChoicesViewSet, ModelViewSet
 from . import serializers
 
 ERR_USERKEY_MISSING = "No UserKey found for the current user."
@@ -44,7 +44,7 @@ class SecretRoleViewSet(ModelViewSet):
 # Secrets
 #
 
-class SecretViewSet(WritableSerializerMixin, ModelViewSet):
+class SecretViewSet(ModelViewSet):
     queryset = Secret.objects.select_related(
         'device__primary_ip4', 'device__primary_ip6', 'role',
     ).prefetch_related(
@@ -56,17 +56,13 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet):
 
     master_key = None
 
-    def _get_encrypted_fields(self, serializer):
-        """
-        Since we can't call encrypt() on the serializer like we can on the Secret model, we need to calculate the
-        ciphertext and hash values by encrypting a dummy copy. These can be passed to the serializer's save() method.
-        """
-        s = Secret(plaintext=serializer.validated_data['plaintext'])
-        s.encrypt(self.master_key)
-        return ({
-            'ciphertext': s.ciphertext,
-            'hash': s.hash,
-        })
+    def get_serializer_context(self):
+
+        # Make the master key available to the serializer for encrypting plaintext values
+        context = super(SecretViewSet, self).get_serializer_context()
+        context['master_key'] = self.master_key
+
+        return context
 
     def initial(self, request, *args, **kwargs):
 
@@ -128,12 +124,6 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet):
         serializer = self.get_serializer(queryset, many=True)
         return Response(serializer.data)
 
-    def perform_create(self, serializer):
-        serializer.save(**self._get_encrypted_fields(serializer))
-
-    def perform_update(self, serializer):
-        serializer.save(**self._get_encrypted_fields(serializer))
-
 
 class GetSessionKeyViewSet(ViewSet):
     """

+ 69 - 10
netbox/secrets/tests/test_api.py

@@ -81,12 +81,12 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
     def test_create_secretrole(self):
 
         data = {
-            'name': 'Test SecretRole 4',
-            'slug': 'test-secretrole-4',
+            'name': 'Test Secret Role 4',
+            'slug': 'test-secret-role-4',
         }
 
         url = reverse('secrets-api:secretrole-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(SecretRole.objects.count(), 4)
@@ -94,6 +94,32 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
         self.assertEqual(secretrole4.name, data['name'])
         self.assertEqual(secretrole4.slug, data['slug'])
 
+    def test_create_secretrole_bulk(self):
+
+        data = [
+            {
+                'name': 'Test Secret Role 4',
+                'slug': 'test-secret-role-4',
+            },
+            {
+                'name': 'Test Secret Role 5',
+                'slug': 'test-secret-role-5',
+            },
+            {
+                'name': 'Test Secret Role 6',
+                'slug': 'test-secret-role-6',
+            },
+        ]
+
+        url = reverse('secrets-api:secretrole-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(SecretRole.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_secretrole(self):
 
         data = {
@@ -102,7 +128,7 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(SecretRole.objects.count(), 3)
@@ -138,9 +164,9 @@ class SecretTest(HttpStatusMixin, APITestCase):
         }
 
         self.plaintext = {
-            'secret1': 'Secret#1Plaintext',
-            'secret2': 'Secret#2Plaintext',
-            'secret3': 'Secret#3Plaintext',
+            'secret1': 'Secret #1 Plaintext',
+            'secret2': 'Secret #2 Plaintext',
+            'secret3': 'Secret #3 Plaintext',
         }
 
         site = Site.objects.create(name='Test Site 1', slug='test-site-1')
@@ -187,11 +213,12 @@ class SecretTest(HttpStatusMixin, APITestCase):
         data = {
             'device': self.device.pk,
             'role': self.secretrole1.pk,
-            'plaintext': 'Secret#4Plaintext',
+            'name': 'Test Secret 4',
+            'plaintext': 'Secret #4 Plaintext',
         }
 
         url = reverse('secrets-api:secret-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(response.data['plaintext'], data['plaintext'])
@@ -201,6 +228,38 @@ class SecretTest(HttpStatusMixin, APITestCase):
         self.assertEqual(secret4.role_id, data['role'])
         self.assertEqual(secret4.plaintext, data['plaintext'])
 
+    def test_create_secret_bulk(self):
+
+        data = [
+            {
+                'device': self.device.pk,
+                'role': self.secretrole1.pk,
+                'name': 'Test Secret 4',
+                'plaintext': 'Secret #4 Plaintext',
+            },
+            {
+                'device': self.device.pk,
+                'role': self.secretrole1.pk,
+                'name': 'Test Secret 5',
+                'plaintext': 'Secret #5 Plaintext',
+            },
+            {
+                'device': self.device.pk,
+                'role': self.secretrole1.pk,
+                'name': 'Test Secret 6',
+                'plaintext': 'Secret #6 Plaintext',
+            },
+        ]
+
+        url = reverse('secrets-api:secret-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Secret.objects.count(), 6)
+        self.assertEqual(response.data[0]['plaintext'], data[0]['plaintext'])
+        self.assertEqual(response.data[1]['plaintext'], data[1]['plaintext'])
+        self.assertEqual(response.data[2]['plaintext'], data[2]['plaintext'])
+
     def test_update_secret(self):
 
         data = {
@@ -210,7 +269,7 @@ class SecretTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data['plaintext'], data['plaintext'])

+ 1 - 1
netbox/templates/_base.html

@@ -62,7 +62,7 @@
             </div>
         </div>
     </footer>
-<script src="{% static 'js/jquery-3.2.1.min.js' %}"></script>
+<script src="{% static 'js/jquery-3.3.1.min.js' %}"></script>
 <script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
 <script src="{% static 'bootstrap-3.3.7-dist/js/bootstrap.min.js' %}"></script>
 <script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>

+ 6 - 0
netbox/templates/circuits/circuit.html

@@ -47,6 +47,12 @@
             </div>
             <table class="table table-hover panel-body attr-table">
                 <tr>
+                    <td>Status</td>
+                    <td>
+                        <span class="label label-{{ circuit.get_status_class }}">{{ circuit.get_status_display }}</span>
+                    </td>
+                </tr>
+                <tr>
                     <td>Provider</td>
                     <td>
                         <a href="{% url 'circuits:provider' slug=circuit.provider.slug %}">{{ circuit.provider }}</a>

+ 1 - 0
netbox/templates/circuits/circuit_edit.html

@@ -8,6 +8,7 @@
             {% render_field form.provider %}
             {% render_field form.cid %}
             {% render_field form.type %}
+            {% render_field form.status %}
             {% render_field form.install_date %}
             <div class="form-group">
                 <label class="col-md-3 control-label" for="id_commit_rate">{{ form.commit_rate.label }}</label>

+ 55 - 0
netbox/templates/dcim/bulk_rename.html

@@ -0,0 +1,55 @@
+{% extends '_base.html' %}
+{% load helpers %}
+{% load form_helpers %}
+
+{% block content %}
+    <h1>{% block title %}Renaming {{ selected_objects|length }} {{ obj_type_plural|bettertitle }}{% endblock %}</h1>
+    <div class="row">
+        <div class="col-md-7">
+            <table class="table">
+                <thead>
+                    <tr>
+                        <th>Current Name</th>
+                        <th>New Name</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for obj in selected_objects %}
+                        <tr{% if obj.new_name and obj.name != obj.new_name %} class="success"{% endif %}>
+                            <td>{{ obj.name }}</td>
+                            <td>{{ obj.new_name }}</td>
+                        </tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+        <div class="col-md-5">
+            <form action="" method="post" class="form form-horizontal">
+                {% csrf_token %}
+                {% if form.non_field_errors %}
+                    <div class="panel panel-danger">
+                        <div class="panel-heading"><strong>Errors</strong></div>
+                        <div class="panel-body">
+                            {{ form.non_field_errors }}
+                        </div>
+                    </div>
+                {% endif %}
+                <div class="panel panel-default">
+                    <div class="panel-heading"><strong>Rename</strong></div>
+                    <div class="panel-body">
+                        {% render_form form %}
+                    </div>
+                </div>
+                <div class="form-group text-right">
+                    <div class="col-md-12">
+                        <button type="submit" name="_preview" class="btn btn-primary">Preview</button>
+                        {% if '_preview' in request.POST and not form.errors %}
+                            <button type="submit" name="_apply" class="btn btn-primary">Apply</button>
+                        {% endif %}
+                        <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+                    </div>
+                </div>
+            </form>
+        </div>
+    </div>
+{% endblock %}

+ 210 - 186
netbox/templates/dcim/device.html

@@ -98,6 +98,46 @@
                 </tr>
             </table>
         </div>
+        {% if vc_members %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Virtual Chassis</strong>
+                </div>
+                <table class="table table-hover panel-body attr-table">
+                    <tr>
+                        <th>Device</th>
+                        <th>Position</th>
+                        <th>Master</th>
+                        <th>Priority</th>
+                    </tr>
+                    {% for vc_member in vc_members %}
+                        <tr{% if vc_member == device %} class="info"{% endif %}>
+                            <td>
+                                <a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
+                            </td>
+                            <td><span class="badge badge-default">{{ vc_member.vc_position }}</span></td>
+                            <td>{% if device.virtual_chassis.master == vc_member %}<i class="fa fa-check"></i>{% endif %}</td>
+                            <td>{{ vc_member.vc_priority|default:"" }}</td>
+                        </tr>
+                    {% endfor %}
+                </table>
+                <div class="panel-footer text-right">
+                    {% if perms.dcim.change_virtualchassis %}
+                        <a href="{% url 'dcim:virtualchassis_add_member' pk=device.virtual_chassis.pk %}?site={{ device.site.pk }}&rack={{ device.rack.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Member
+                        </a>
+                        <a href="{% url 'dcim:virtualchassis_edit' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Virtual Chassis
+                        </a>
+                    {% endif %}
+                    {% if perms.dcim.delete_virtualchassis %}
+                        <a href="{% url 'dcim:virtualchassis_delete' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Virtual Chassis
+                        </a>
+                    {% endif %}
+                </div>
+            </div>
+        {% endif %}
         <div class="panel panel-default">
             <div class="panel-heading">
                 <strong>Management</strong>
@@ -339,45 +379,48 @@
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Device Bays</strong>
-                    <div class="pull-right">
-                        {% if perms.dcim.change_devicebay and device_bays|length > 1 %}
-                            <button class="btn btn-default btn-xs toggle">
-                                <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
-                            </button>
-                        {% endif %}
-                        {% if perms.dcim.add_devicebay and device_bays|length > 10 %}
-                            <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
-                            </a>
-                        {% endif %}
-                    </div>
                 </div>
-                <table class="table table-hover panel-body component-list">
-                    {% for devicebay in device_bays %}
-                        {% include 'dcim/inc/devicebay.html' %}
-                    {% empty %}
+                <table class="table table-hover table-headings panel-body component-list">
+                    <thead>
                         <tr>
-                            <td colspan="4">No device bays defined</td>
+                            {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
+                                <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
+                            {% endif %}
+                            <th>Name</th>
+                            <th colspan="2">Installed Device</th>
+                            <th></th>
                         </tr>
-                    {% endfor %}
+                    </thead>
+                    <tbody>
+                        {% for devicebay in device_bays %}
+                            {% include 'dcim/inc/devicebay.html' %}
+                        {% empty %}
+                            <tr>
+                                <td colspan="5" class="text-center text-muted">&mdash; No device bays defined &mdash;</td>
+                            </tr>
+                        {% endfor %}
+                    </tbody>
                 </table>
-                {% if perms.dcim.add_devicebay or perms.dcim.delete_devicebay %}
-                    <div class="panel-footer">
-                        {% if device_bays and perms.dcim.delete_devicebay %}
-                            <button type="submit" class="btn btn-danger btn-xs">
-                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
-                            </button>
-                        {% endif %}
-                        {% if perms.dcim.add_devicebay %}
-                            <div class="pull-right">
-                                <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
-                                </a>
-                            </div>
-                            <div class="clearfix"></div>
-                        {% endif %}
-                     </div>
-                {% endif %}
+                <div class="panel-footer">
+                    {% if device_bays and perms.dcim.change_devicebay %}
+                        <button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
+                        </button>
+                    {% endif %}
+                    {% if device_bays and perms.dcim.delete_devicebay %}
+                        <button type="submit" class="btn btn-danger btn-xs">
+                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
+                        </button>
+                    {% endif %}
+                    {% if perms.dcim.add_devicebay %}
+                        <div class="pull-right">
+                            <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
+                            </a>
+                        </div>
+                        <div class="clearfix"></div>
+                    {% endif %}
+                 </div>
             </div>
             {% if perms.dcim.delete_devicebay %}
                 </form>
@@ -396,66 +439,61 @@
                         <button class="btn btn-default btn-xs toggle-ips" selected="selected">
                             <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
                         </button>
-                        {% if perms.dcim.change_interface and interfaces|length > 1 %}
-                            <button class="btn btn-default btn-xs toggle">
-                                <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
-                            </button>
-                        {% endif %}
-                        {% if perms.dcim.add_interface and interfaces|length > 10 %}
-                            <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
-                            </a>
-                        {% endif %}
                     </div>
                 </div>
-                <table id="interfaces_table" class="table table-hover panel-body component-list">
-                    <tr class="table-headings">
-                        {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
-                            <th></th>
-                        {% endif %}
-                        <th>Name</th>
-                        <th>LAG</th>
-                        <th>Description</th>
-                        <th>MTU</th>
-                        <th>MAC Address</th>
-                        <th colspan="2">Connection</th>
-                        <th></th>
-                    </tr>
-                    {% for iface in interfaces %}
-                        {% include 'dcim/inc/interface.html' %}
-                    {% empty %}
+                <table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
+                    <thead>
                         <tr>
-                            <td colspan="8">No interfaces defined</td>
+                            {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
+                                <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
+                            {% endif %}
+                            <th>Name</th>
+                            <th>LAG</th>
+                            <th>Description</th>
+                            <th>MTU</th>
+                            <th>MAC Address</th>
+                            <th colspan="2">Connection</th>
+                            <th></th>
                         </tr>
-                    {% endfor %}
+                    </thead>
+                    <tbody>
+                        {% for iface in interfaces %}
+                            {% include 'dcim/inc/interface.html' %}
+                        {% empty %}
+                            <tr>
+                                <td colspan="9" class="text-center text-muted">&mdash; No interfaces defined &mdash;</td>
+                            </tr>
+                        {% endfor %}
+                    </tbody>
                 </table>
-                {% if perms.dcim.add_interface or perms.dcim.delete_interface %}
-                    <div class="panel-footer">
-                        {% if interfaces and perms.dcim.change_interface %}
-                            <button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
-                                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
-                            </button>
-                        {% endif %}
-                        {% if interfaces and perms.dcim.delete_interfaceconnection %}
-                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
-                                <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
-                            </button>
-                        {% endif %}
-                        {% if interfaces and perms.dcim.delete_interface %}
-                            <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
-                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
-                            </button>
-                        {% endif %}
-                        {% if perms.dcim.add_interface %}
-                            <div class="pull-right">
-                                <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
-                                </a>
-                            </div>
-                            <div class="clearfix"></div>
-                        {% endif %}
-                     </div>
-                {% endif %}
+                <div class="panel-footer">
+                    {% if interfaces and perms.dcim.change_interface %}
+                        <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
+                        </button>
+                        <button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
+                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
+                        </button>
+                    {% endif %}
+                    {% if interfaces and perms.dcim.delete_interfaceconnection %}
+                        <button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
+                            <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
+                        </button>
+                    {% endif %}
+                    {% if interfaces and perms.dcim.delete_interface %}
+                        <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
+                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
+                        </button>
+                    {% endif %}
+                    {% if perms.dcim.add_interface %}
+                        <div class="pull-right">
+                            <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
+                            </a>
+                        </div>
+                        <div class="clearfix"></div>
+                    {% endif %}
+                 </div>
             </div>
             {% if perms.dcim.delete_interface %}
                 </form>
@@ -469,58 +507,51 @@
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Console Server Ports</strong>
-                    <div class="pull-right">
-                        {% if perms.dcim.change_consoleserverport and cs_ports|length > 1 %}
-                            <button class="btn btn-default btn-xs toggle">
-                                <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
-                            </button>
-                        {% endif %}
-                        {% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
-                            <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
-                            </a>
-                        {% endif %}
-                    </div>
                 </div>
-                <table class="table table-hover panel-body component-list">
-                    <tr class="table-headings">
-                        {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
-                            <th></th>
-                        {% endif %}
-                        <th>Name</th>
-                        <th colspan="2">Connection</th>
-                        <th></th>
-                    </tr>
-                    {% for csp in cs_ports %}
-                        {% include 'dcim/inc/consoleserverport.html' %}
-                    {% empty %}
+                <table class="table table-hover table-headings panel-body component-list">
+                    <thead>
                         <tr>
-                            <td colspan="4">No console server ports defined</td>
+                            {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
+                                <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
+                            {% endif %}
+                            <th>Name</th>
+                            <th colspan="2">Connection</th>
+                            <th></th>
                         </tr>
-                    {% endfor %}
+                    </thead>
+                    <tbody>
+                        {% for csp in cs_ports %}
+                            {% include 'dcim/inc/consoleserverport.html' %}
+                        {% empty %}
+                            <tr>
+                                <td colspan="5" class="text-center text-muted">&mdash; No console server ports defined &mdash;</td>
+                            </tr>
+                        {% endfor %}
+                    </tbody>
                 </table>
-                {% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %}
-                    <div class="panel-footer">
-                        {% if cs_ports and perms.dcim.change_consoleport %}
-                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
-                                <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
-                            </button>
-                        {% endif %}
-                        {% if cs_ports and perms.dcim.delete_consoleserverport %}
-                            <button type="submit" class="btn btn-danger btn-xs">
-                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
-                            </button>
-                        {% endif %}
-                        {% if perms.dcim.add_consoleserverport %}
-                            <div class="pull-right">
-                                <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
-                                </a>
-                            </div>
-                            <div class="clearfix"></div>
-                        {% endif %}
-                    </div>
-                {% endif %}
+                <div class="panel-footer">
+                    {% if cs_ports and perms.dcim.change_consoleport %}
+                        <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
+                        </button>
+                        <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
+                            <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
+                        </button>
+                    {% endif %}
+                    {% if cs_ports and perms.dcim.delete_consoleserverport %}
+                        <button type="submit" class="btn btn-danger btn-xs">
+                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
+                        </button>
+                    {% endif %}
+                    {% if perms.dcim.add_consoleserverport %}
+                        <div class="pull-right">
+                            <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
+                            </a>
+                        </div>
+                        <div class="clearfix"></div>
+                    {% endif %}
+                </div>
             </div>
             {% if perms.dcim.delete_consoleserverport %}
                 </form>
@@ -534,58 +565,51 @@
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Power Outlets</strong>
-                    <div class="pull-right">
-                        {% if perms.dcim.change_poweroutlet and power_outlets|length > 1 %}
-                            <button class="btn btn-default btn-xs toggle">
-                                <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
-                            </button>
-                        {% endif %}
-                        {% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
+                </div>
+                <table class="table table-hover table-headings panel-body component-list">
+                    <thead>
+                        <tr>
+                            {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
+                                <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
+                            {% endif %}
+                            <th>Name</th>
+                            <th colspan="2">Connection</th>
+                            <th></th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        {% for po in power_outlets %}
+                            {% include 'dcim/inc/poweroutlet.html' %}
+                        {% empty %}
+                            <tr>
+                                <td colspan="5" class="text-center text-muted">&mdash; No power outlets defined &mdash;</td>
+                            </tr>
+                        {% endfor %}
+                    </tbody>
+                </table>
+                <div class="panel-footer">
+                    {% if power_outlets and perms.dcim.change_powerport %}
+                        <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
+                        </button>
+                        <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
+                            <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
+                        </button>
+                    {% endif %}
+                    {% if power_outlets and perms.dcim.delete_poweroutlet %}
+                        <button type="submit" class="btn btn-danger btn-xs">
+                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
+                        </button>
+                    {% endif %}
+                    {% if perms.dcim.add_poweroutlet %}
+                        <div class="pull-right">
                             <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
                             </a>
-                        {% endif %}
-                    </div>
+                        </div>
+                        <div class="clearfix"></div>
+                    {% endif %}
                 </div>
-                <table class="table table-hover panel-body component-list">
-                    <tr class="table-headings">
-                        {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
-                            <th></th>
-                        {% endif %}
-                        <th>Name</th>
-                        <th colspan="2">Connection</th>
-                        <th></th>
-                    </tr>
-                    {% for po in power_outlets %}
-                        {% include 'dcim/inc/poweroutlet.html' %}
-                    {% empty %}
-                        <tr>
-                            <td colspan="4">No power outlets defined</td>
-                        </tr> text-nowrap
-                    {% endfor %}
-                </table>
-                {% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %}
-                    <div class="panel-footer">
-                        {% if power_outlets and perms.dcim.change_powerport %}
-                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
-                                <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
-                            </button>
-                        {% endif %}
-                        {% if power_outlets and perms.dcim.delete_poweroutlet %}
-                            <button type="submit" class="btn btn-danger btn-xs">
-                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
-                            </button>
-                        {% endif %}
-                        {% if perms.dcim.add_poweroutlet %}
-                            <div class="pull-right">
-                                <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
-                                </a>
-                            </div>
-                            <div class="clearfix"></div>
-                        {% endif %}
-                    </div>
-                {% endif %}
             </div>
             {% if perms.dcim.delete_poweroutlet %}
                 </form>

+ 5 - 0
netbox/templates/dcim/inc/device_table.html

@@ -16,4 +16,9 @@
             </ul>
         </div>
     {% endif %}
+    {% if perms.dcim.add_virtualchassis %}
+        <button type="submit" name="_edit" formaction="{% url 'dcim:virtualchassis_add' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-primary btn-sm">
+            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create Virtual Chassis
+        </button>
+    {% endif %}
 {% endblock %}

+ 2 - 2
netbox/templates/dcim/inc/devicebay.html

@@ -5,7 +5,7 @@
         </td>
     {% endif %}
     <td>
-        <i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay }}
+        <i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
     </td>
     {% if devicebay.installed_device %}
         <td>
@@ -19,7 +19,7 @@
             <span class="text-muted">Vacant</span>
         </td>
     {% endif %}
-    <td colspan="2" class="text-right">
+    <td class="text-right">
         {% if perms.dcim.change_devicebay %}
             {% if devicebay.installed_device %}
                 <a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">

+ 0 - 13
netbox/templates/dcim/inc/devicetype_component_table.html

@@ -4,19 +4,6 @@
         <div class="panel panel-default">
             <div class="panel-heading">
                 <strong>{{ title }}</strong>
-                <div class="pull-right">
-                    {% if table.rows|length > 1 %}
-                        <button class="btn btn-default btn-xs toggle">
-                            <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
-                        </button>
-                    {% endif %}
-                    {% if table.rows|length > 10 %}
-                        <a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs">
-                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                            Add {{ title }}
-                        </a>
-                    {% endif %}
-                </div>
             </div>
             {% include 'responsive_table.html' %}
             <div class="panel-footer">

+ 3 - 3
netbox/templates/dcim/inc/interface.html

@@ -98,18 +98,18 @@
                             <i class="fa fa-plug" aria-hidden="true"></i>
                         </a>
                     {% endif %}
-                    <a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Disconnect">
+                    <a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Disconnect">
                         <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                     </a>
                 {% elif iface.circuit_termination and perms.circuits.change_circuittermination %}
                     <button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
                         <i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
                     </button>
-                    <a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
+                    <a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}&return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Edit circuit termination">
                         <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                     </a>
                 {% else %}
-                    <a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
+                    <a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
                         <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                     </a>
                 {% endif %}

+ 28 - 0
netbox/templates/dcim/interface_edit.html

@@ -0,0 +1,28 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Interface</strong></div>
+        <div class="panel-body">
+            {% render_field form.name %}
+            {% render_field form.form_factor %}
+            {% render_field form.enabled %}
+            {% render_field form.lag %}
+            {% render_field form.mac_address %}
+            {% render_field form.mtu %}
+            {% render_field form.mgmt_only %}
+            {% render_field form.description %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>802.1Q Encapsulation</strong></div>
+        <div class="panel-body">
+            {% render_field form.mode %}
+            {% render_field form.site %}
+            {% render_field form.vlan_group %}
+            {% render_field form.untagged_vlan %}
+            {% render_field form.tagged_vlans %}
+        </div>
+    </div>
+{% endblock %}

+ 8 - 0
netbox/templates/dcim/rack.html

@@ -225,6 +225,7 @@
                 <table class="table table-hover panel-body">
                     <tr>
                         <th>Units</th>
+                        <th>Tenant</th>
                         <th>Description</th>
                         <th></th>
                     </tr>
@@ -232,6 +233,13 @@
                         <tr>
                             <td>{{ resv.unit_list }}</td>
                             <td>
+                                {% if resv.tenant %}
+                                    <a href="{{ resv.tenant.get_absolute_url }}">{{ resv.tenant }}</a>
+                                {% else %}
+                                    <span class="text-muted">None</span>
+                                {% endif %}
+                            </td>
+                            <td>
                                 {{ resv.description }}<br />
                                 <small>{{ resv.user }} &middot; {{ resv.created }}</small>
                             </td>

+ 28 - 0
netbox/templates/dcim/site.html

@@ -1,5 +1,6 @@
 {% extends '_base.html' %}
 {% load static from staticfiles %}
+{% load tz %}
 {% load helpers %}
 
 {% block content %}
@@ -58,6 +59,12 @@
             </div>
             <table class="table table-hover panel-body attr-table">
                 <tr>
+                    <td>Status</td>
+                    <td>
+                        <span class="label label-{{ site.get_status_class }}">{{ site.get_status_display }}</span>
+                    </td>
+                </tr>
+                <tr>
                     <td>Region</td>
                     <td>
                         {% if site.region %}
@@ -105,6 +112,27 @@
                         {% endif %}
                     </td>
                 </tr>
+                <tr>
+                    <td>Time Zone</td>
+                    <td>
+                        {% if site.time_zone %}
+                            {{ site.time_zone }} (UTC {{ site.time_zone|tzoffset }})<br />
+                            <small class="text-muted">Site time: {% timezone site.time_zone %}{% now "SHORT_DATETIME_FORMAT" %}{% endtimezone %}</small>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Description</td>
+                    <td>
+                        {% if site.description %}
+                            {{ site.description }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
             </table>
         </div>
         <div class="panel panel-default">

+ 2 - 0
netbox/templates/dcim/site_edit.html

@@ -7,9 +7,11 @@
         <div class="panel-body">
             {% render_field form.name %}
             {% render_field form.slug %}
+            {% render_field form.status %}
             {% render_field form.region %}
             {% render_field form.facility %}
             {% render_field form.asn %}
+            {% render_field form.time_zone %}
         </div>
     </div>
     <div class="panel panel-default">

+ 35 - 0
netbox/templates/dcim/virtualchassis_add_member.html

@@ -0,0 +1,35 @@
+{% extends '_base.html' %}
+{% load form_helpers %}
+
+{% block content %}
+    <form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
+        {% csrf_token %}
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3">
+                <h3>{% block title %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblock %}</h3>
+                {% if membership_form.non_field_errors %}
+                    <div class="panel panel-danger">
+                        <div class="panel-heading"><strong>Errors</strong></div>
+                        <div class="panel-body">
+                            {{ membership_form.non_field_errors }}
+                        </div>
+                    </div>
+                {% endif %}
+                <div class="panel panel-default">
+                    <div class="panel-heading"><strong>Add New Member</strong></div>
+                    <div class="table panel-body">
+                        {% render_form member_select_form %}
+                        {% render_form membership_form %}
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3 text-right">
+                <button type="submit" name="_save" class="btn btn-primary">Save</button>
+                <button type="submit" name="_addanother" class="btn btn-primary">Add Another</button>
+                <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+            </div>
+        </div>
+    </form>
+{% endblock %}

+ 103 - 0
netbox/templates/dcim/virtualchassis_edit.html

@@ -0,0 +1,103 @@
+{% extends '_base.html' %}
+{% load form_helpers %}
+
+{% block content %}
+    <form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
+        {% csrf_token %}
+        {{ pk_form.pk }}
+        {{ formset.management_form }}
+        <div class="row">
+            <div class="col-md-8 col-md-offset-2">
+                <h3>{% block title %}{% if vc_form.instance %}Editing {{ vc_form.instance }}{% else %}New Virtual Chassis{% endif %}{% endblock %}</h3>
+                {% if vc_form.non_field_errors %}
+                    <div class="panel panel-danger">
+                        <div class="panel-heading"><strong>Errors</strong></div>
+                        <div class="panel-body">
+                            {{ vc_form.non_field_errors }}
+                        </div>
+                    </div>
+                {% endif %}
+                <div class="panel panel-default">
+                    <div class="panel-heading"><strong>Virtual Chassis</strong></div>
+                    <div class="table panel-body">
+                        {% render_form vc_form %}
+                    </div>
+                </div>
+                <div class="panel panel-default">
+                    <div class="panel-heading"><strong>Members</strong></div>
+                    <table class="table panel-body">
+                        <thead>
+                            <tr>
+                                <th>Device</th>
+                                <th>ID</th>
+                                <th>Rack/Unit</th>
+                                <th>Serial</th>
+                                <th>Position</th>
+                                <th>Priority</th>
+                                <th></th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            {% for form in formset %}
+                                {% for field in form.hidden_fields %}
+                                    {{ field }}
+                                {% endfor %}
+                                {% with device=form.instance virtual_chassis=vc_form.instance %}
+                                    <tr>
+                                        <td>
+                                            <a href="{{ device.get_absolute_url }}">{{ device }}</a>
+                                        </td>
+                                        <td>{{ device.pk }}</td>
+                                        <td>
+                                            {% if device.rack %}
+                                                {{ device.rack }} / {{ device.position }}
+                                            {% else %}
+                                                <span class="text-muted">N/A</span>
+                                            {% endif %}
+                                        </td>
+                                        <td>
+                                            {% if device.serial %}
+                                                {{ device.serial }}
+                                            {% else %}
+                                                <span class="text-muted">N/A</span>
+                                            {% endif %}
+                                        </td>
+                                        <td>
+                                            {{ form.vc_position }}
+                                            {% if form.vc_position.errors %}
+                                                <br /><small class="text-danger">{{ form.vc_position.errors.0 }}</small>
+                                            {% endif %}
+                                        </td>
+                                        <td>
+                                            {{ form.vc_priority }}
+                                            {% if form.vc_priority.errors %}
+                                                <br /><small class="text-danger">{{ form.vc_priority.errors.0 }}</small>
+                                            {% endif %}
+                                        </td>
+                                        <td>
+                                            {% if virtual_chassis.pk %}
+                                                <a href="{% url 'dcim:virtualchassis_remove_member' pk=device.pk %}?return_url={% url 'dcim:virtualchassis_edit' pk=virtual_chassis.pk %}" class="btn btn-danger btn-xs{% if virtual_chassis.master == device %} disabled{% endif %}">
+                                                    <span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+                                                </a>
+                                            {% endif %}
+                                        </td>
+                                    </tr>
+                                {% endwith %}
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
+            </div>
+        </div>
+        <div class="row">
+            <div class="col-md-8 col-md-offset-2 text-right">
+                {% if vc_form.instance.pk %}
+                    <button type="submit" name="_update" class="btn btn-primary">Update</button>
+                {% else %}
+                    <button type="submit" name="_create" class="btn btn-primary">Create</button>
+                {% endif %}
+                <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+            </div>
+        </div>
+    </form>
+{% endblock %}

+ 14 - 0
netbox/templates/dcim/virtualchassis_list.html

@@ -0,0 +1,14 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block content %}
+<h1>{% block title %}Virtual Chassis{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' %}
+    </div>
+    <div class="col-md-3">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

+ 8 - 0
netbox/templates/dcim/virtualchassis_remove_member.html

@@ -0,0 +1,8 @@
+{% extends 'utilities/confirmation_form.html' %}
+{% load form_helpers %}
+
+{% block title %}Remove Virtual Chassis Member?{% endblock %}
+
+{% block message %}
+    <p>Are you sure you want to remove <strong>{{ device }}</strong> from virtual chassis {{ device.virtual_chassis }}?</p>
+{% endblock %}

+ 4 - 1
netbox/templates/inc/nav_menu.html

@@ -104,7 +104,7 @@
                         </li>
                     </ul>
                 </li>
-                <li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/,-connections/,/dcim/inventory-items/' %} active{% endif %}">
+                <li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/virtual-chassis,/dcim/manufacturers/,/dcim/platforms/,-connections/,/dcim/inventory-items/' %} active{% endif %}">
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Devices</li>
@@ -135,6 +135,9 @@
                             {% endif %}
                             <a href="{% url 'dcim:platform_list' %}">Platforms</a>
                         </li>
+                        <li>
+                            <a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a>
+                        </li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Device Types</li>
                         <li>

+ 4 - 0
netbox/templates/tenancy/tenant.html

@@ -101,6 +101,10 @@
                     <p>Racks</p>
                 </div>
                 <div class="col-md-4 text-center">
+                    <h2><a href="{% url 'dcim:rackreservation_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.rackreservation_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.rackreservation_count }}</a></h2>
+                    <p>Rack reservations</p>
+                </div>
+                <div class="col-md-4 text-center">
                     <h2><a href="{% url 'dcim:device_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.device_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
                     <p>Devices</p>
                 </div>

+ 25 - 28
netbox/templates/virtualization/virtualmachine.html

@@ -235,42 +235,39 @@
                     <button class="btn btn-default btn-xs toggle-ips" selected="selected">
                         <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
                     </button>
-                    {% if perms.dcim.change_interface and interfaces|length > 1 %}
-                        <button class="btn btn-default btn-xs toggle">
-                            <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
-                        </button>
-                    {% endif %}
-                    {% if perms.dcim.add_interface and interfaces|length > 10 %}
-                        <a href="{% url 'virtualization:interface_add' pk=vm.pk %}" class="btn btn-primary btn-xs">
-                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
-                        </a>
-                    {% endif %}
                 </div>
             </div>
-            <table id="interfaces_table" class="table table-hover panel-body component-list">
-                <tr class="table-headings">
-                    {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
-                        <th></th>
-                    {% endif %}
-                    <th>Name</th>
-                    <th>LAG</th>
-                    <th>Description</th>
-                    <th>MTU</th>
-                    <th>MAC Address</th>
-                    <th colspan="2">Connection</th>
-                    <th></th>
-                </tr>
-                {% for iface in interfaces %}
-                    {% include 'dcim/inc/interface.html' with device=vm %}
-                {% empty %}
+            <table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
+                <thead>
                     <tr>
-                        <td colspan="6">No interfaces defined</td>
+                        {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
+                            <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
+                        {% endif %}
+                        <th>Name</th>
+                        <th>LAG</th>
+                        <th>Description</th>
+                        <th>MTU</th>
+                        <th>MAC Address</th>
+                        <th colspan="2">Connection</th>
+                        <th></th>
                     </tr>
-                {% endfor %}
+                </thead>
+                <tbody>
+                    {% for iface in interfaces %}
+                        {% include 'dcim/inc/interface.html' with device=vm %}
+                    {% empty %}
+                        <tr>
+                            <td colspan="9" class="text-center text-muted">&mdash; No interfaces defined &mdash;</td>
+                        </tr>
+                    {% endfor %}
+                </tbody>
             </table>
             {% if perms.dcim.add_interface or perms.dcim.delete_interface %}
                 <div class="panel-footer">
                     {% if interfaces and perms.dcim.change_interface %}
+                        <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ vm.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
+                        </button>
                         <button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=vm.pk %}" class="btn btn-warning btn-xs">
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                         </button>

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

@@ -35,7 +35,7 @@ class TenantSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Tenant
-        fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']
+        fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']
 
 
 class NestedTenantSerializer(serializers.ModelSerializer):
@@ -50,4 +50,4 @@ class WritableTenantSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Tenant
-        fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']
+        fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']

+ 2 - 4
netbox/tenancy/api/views.py

@@ -1,11 +1,9 @@
 from __future__ import unicode_literals
 
-from rest_framework.viewsets import ModelViewSet
-
 from extras.api.views import CustomFieldModelViewSet
 from tenancy import filters
 from tenancy.models import Tenant, TenantGroup
-from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
+from utilities.api import FieldChoicesViewSet, ModelViewSet
 from . import serializers
 
 
@@ -31,7 +29,7 @@ class TenantGroupViewSet(ModelViewSet):
 # Tenants
 #
 
-class TenantViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class TenantViewSet(CustomFieldModelViewSet):
     queryset = Tenant.objects.select_related('group')
     serializer_class = serializers.TenantSerializer
     write_serializer_class = serializers.WritableTenantSerializer

+ 56 - 4
netbox/tenancy/tests/test_api.py

@@ -44,7 +44,7 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('tenancy-api:tenantgroup-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(TenantGroup.objects.count(), 4)
@@ -52,6 +52,32 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
         self.assertEqual(tenantgroup4.name, data['name'])
         self.assertEqual(tenantgroup4.slug, data['slug'])
 
+    def test_create_tenantgroup_bulk(self):
+
+        data = [
+            {
+                'name': 'Test Tenant Group 4',
+                'slug': 'test-tenant-group-4',
+            },
+            {
+                'name': 'Test Tenant Group 5',
+                'slug': 'test-tenant-group-5',
+            },
+            {
+                'name': 'Test Tenant Group 6',
+                'slug': 'test-tenant-group-6',
+            },
+        ]
+
+        url = reverse('tenancy-api:tenantgroup-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(TenantGroup.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_tenantgroup(self):
 
         data = {
@@ -60,7 +86,7 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(TenantGroup.objects.count(), 3)
@@ -114,7 +140,7 @@ class TenantTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('tenancy-api:tenant-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Tenant.objects.count(), 4)
@@ -123,6 +149,32 @@ class TenantTest(HttpStatusMixin, APITestCase):
         self.assertEqual(tenant4.slug, data['slug'])
         self.assertEqual(tenant4.group_id, data['group'])
 
+    def test_create_tenant_bulk(self):
+
+        data = [
+            {
+                'name': 'Test Tenant 4',
+                'slug': 'test-tenant-4',
+            },
+            {
+                'name': 'Test Tenant 5',
+                'slug': 'test-tenant-5',
+            },
+            {
+                'name': 'Test Tenant 6',
+                'slug': 'test-tenant-6',
+            },
+        ]
+
+        url = reverse('tenancy-api:tenant-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Tenant.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_tenant(self):
 
         data = {
@@ -132,7 +184,7 @@ class TenantTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(Tenant.objects.count(), 3)

+ 2 - 1
netbox/tenancy/views.py

@@ -7,7 +7,7 @@ from django.urls import reverse
 from django.views.generic import View
 
 from circuits.models import Circuit
-from dcim.models import Site, Rack, Device
+from dcim.models import Site, Rack, Device, RackReservation
 from ipam.models import IPAddress, Prefix, VLAN, VRF
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -75,6 +75,7 @@ class TenantView(View):
         stats = {
             'site_count': Site.objects.filter(tenant=tenant).count(),
             'rack_count': Rack.objects.filter(tenant=tenant).count(),
+            'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(),
             'device_count': Device.objects.filter(tenant=tenant).count(),
             'vrf_count': VRF.objects.filter(tenant=tenant).count(),
             'prefix_count': Prefix.objects.filter(

+ 45 - 18
netbox/utilities/api.py

@@ -1,15 +1,17 @@
 from __future__ import unicode_literals
 
 from collections import OrderedDict
+import pytz
 
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.http import Http404
+from rest_framework import mixins
 from rest_framework.exceptions import APIException
 from rest_framework.permissions import BasePermission
 from rest_framework.response import Response
 from rest_framework.serializers import Field, ModelSerializer, ValidationError
-from rest_framework.viewsets import ViewSet
+from rest_framework.viewsets import GenericViewSet, ViewSet
 
 WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
 
@@ -96,10 +98,51 @@ class ContentTypeFieldSerializer(Field):
             raise ValidationError("Invalid content type")
 
 
+class TimeZoneField(Field):
+    """
+    Represent a pytz time zone.
+    """
+
+    def to_representation(self, obj):
+        return obj.zone if obj else None
+
+    def to_internal_value(self, data):
+        if not data:
+            return ""
+        try:
+            return pytz.timezone(str(data))
+        except pytz.exceptions.UnknownTimeZoneError:
+            raise ValidationError('Invalid time zone "{}"'.format(data))
+
+
 #
-# Views
+# Viewsets
 #
 
+class ModelViewSet(mixins.CreateModelMixin,
+                   mixins.RetrieveModelMixin,
+                   mixins.UpdateModelMixin,
+                   mixins.DestroyModelMixin,
+                   mixins.ListModelMixin,
+                   GenericViewSet):
+    """
+    Substitute DRF's built-in ModelViewSet for our own, which introduces a bit of additional functionality:
+    1. Use an alternate serializer (if provided) for write operations
+    2. Accept either a single object or a list of objects to create
+    """
+    def get_serializer_class(self):
+        # Check for a different serializer to use for write operations
+        if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
+            return self.write_serializer_class
+        return self.serializer_class
+
+    def get_serializer(self, *args, **kwargs):
+        # If a list of objects has been provided, initialize the serializer with many=True
+        if isinstance(kwargs.get('data', {}), list):
+            kwargs['many'] = True
+        return super(ModelViewSet, self).get_serializer(*args, **kwargs)
+
+
 class FieldChoicesViewSet(ViewSet):
     """
     Expose the built-in numeric values which represent static choices for a model's field.
@@ -135,25 +178,9 @@ class FieldChoicesViewSet(ViewSet):
         return Response(self._fields)
 
     def retrieve(self, request, pk):
-
         if pk not in self._fields:
             raise Http404
-
         return Response(self._fields[pk])
 
     def get_view_name(self):
         return "Field Choices"
-
-
-#
-# Mixins
-#
-
-class WritableSerializerMixin(object):
-    """
-    Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).
-    """
-    def get_serializer_class(self):
-        if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
-            return self.write_serializer_class
-        return self.serializer_class

+ 7 - 0
netbox/utilities/constants.py

@@ -0,0 +1,7 @@
+from utilities.forms import ChainedModelMultipleChoiceField
+
+
+# Fields which are used on ManyToMany relationships
+M2M_FIELD_TYPES = [
+    ChainedModelMultipleChoiceField,
+]

+ 9 - 2
netbox/utilities/forms.py

@@ -119,7 +119,7 @@ class ColorSelect(forms.Select):
     """
     Extends the built-in Select widget to colorize each <option>.
     """
-    option_template_name = 'colorselect_option.html'
+    option_template_name = 'widgets/colorselect_option.html'
 
     def __init__(self, *args, **kwargs):
         kwargs['choices'] = COLOR_CHOICES
@@ -144,7 +144,14 @@ class SelectWithDisabled(forms.Select):
     Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include
     'label' (string) and 'disabled' (boolean).
     """
-    option_template_name = 'selectwithdisabled_option.html'
+    option_template_name = 'widgets/selectwithdisabled_option.html'
+
+
+class SelectWithPK(forms.Select):
+    """
+    Include the primary key of each option in the option label (e.g. "Router7 (4721)").
+    """
+    option_template_name = 'widgets/select_option_with_pk.html'
 
 
 class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):

+ 1 - 1
netbox/utilities/tables.py

@@ -30,4 +30,4 @@ class ToggleColumn(tables.CheckBoxColumn):
 
     @property
     def header(self):
-        return mark_safe('<input type="checkbox" id="toggle_all" title="Toggle all" />')
+        return mark_safe('<input type="checkbox" class="toggle" title="Toggle all" />')

netbox/utilities/templates/colorselect_option.html → netbox/utilities/templates/widgets/colorselect_option.html


+ 1 - 0
netbox/utilities/templates/widgets/select_option_with_pk.html

@@ -0,0 +1 @@
+<option value="{{ widget.value|stringformat:'s' }}"{% include "django/forms/widgets/attrs.html" %}>{{ widget.label }}{% if widget.value %} ({{ widget.value }}){% endif %}</option>

netbox/utilities/templates/selectwithdisabled_option.html → netbox/utilities/templates/widgets/selectwithdisabled_option.html


+ 11 - 0
netbox/utilities/templatetags/helpers.py

@@ -1,5 +1,8 @@
 from __future__ import unicode_literals
 
+import datetime
+import pytz
+
 from django import template
 from django.utils.safestring import mark_safe
 from markdown import markdown
@@ -117,6 +120,14 @@ def example_choices(field, arg=3):
     return ', '.join(examples) or 'None'
 
 
+@register.filter()
+def tzoffset(value):
+    """
+    Returns the hour offset of a given time zone using the current time.
+    """
+    return datetime.datetime.now(value).strftime('%z')
+
+
 #
 # Tags
 #

+ 71 - 87
netbox/utilities/views.py

@@ -9,9 +9,9 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.db import transaction, IntegrityError
 from django.db.models import ProtectedError
-from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField
+from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
 from django.shortcuts import get_object_or_404, redirect, render
-from django.template import TemplateSyntaxError
+from django.template.exceptions import TemplateSyntaxError
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.http import is_safe_url
@@ -22,6 +22,7 @@ from django_tables2 import RequestConfig
 from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
 from utilities.utils import queryset_to_csv
 from utilities.forms import BootstrapMixin, CSVDataField
+from .constants import M2M_FIELD_TYPES
 from .error_handlers import handle_protectederror
 from .forms import ConfirmationForm
 from .paginator import EnhancedPaginator
@@ -510,31 +511,55 @@ class BulkEditView(View):
 
                 custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
                 standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk']
-
-                # Update standard fields. If a field is listed in _nullify, delete its value.
                 nullified_fields = request.POST.getlist('_nullify')
-                fields_to_update = {}
-                for field in standard_fields:
-                    if field in form.nullable_fields and field in nullified_fields:
-                        if isinstance(form.fields[field], CharField):
-                            fields_to_update[field] = ''
-                        else:
-                            fields_to_update[field] = None
-                    elif form.cleaned_data[field] not in (None, ''):
-                        fields_to_update[field] = form.cleaned_data[field]
-                updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
-
-                # Update custom fields for objects
-                if custom_fields:
-                    objs_updated = self.update_custom_fields(pk_list, form, custom_fields, nullified_fields)
-                    if objs_updated and not updated_count:
-                        updated_count = objs_updated
-
-                if updated_count:
-                    msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
-                    messages.success(self.request, msg)
-                    UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
-                return redirect(return_url)
+
+                try:
+
+                    with transaction.atomic():
+
+                        updated_count = 0
+                        for obj in self.cls.objects.filter(pk__in=pk_list):
+
+                            # Update standard fields. If a field is listed in _nullify, delete its value.
+                            for name in standard_fields:
+                                if name in form.nullable_fields and name in nullified_fields:
+                                    setattr(obj, name, '' if isinstance(form.fields[name], CharField) else None)
+                                elif form.cleaned_data[name] not in (None, ''):
+                                    setattr(obj, name, form.cleaned_data[name])
+                            obj.full_clean()
+                            obj.save()
+
+                            # Update custom fields
+                            obj_type = ContentType.objects.get_for_model(self.cls)
+                            for name in custom_fields:
+                                field = form.fields[name].model
+                                if name in form.nullable_fields and name in nullified_fields:
+                                    CustomFieldValue.objects.filter(
+                                        field=field, obj_type=obj_type, obj_id=obj.pk
+                                    ).delete()
+                                elif form.cleaned_data[name] not in [None, '']:
+                                    try:
+                                        cfv = CustomFieldValue.objects.get(
+                                            field=field, obj_type=obj_type, obj_id=obj.pk
+                                        )
+                                    except CustomFieldValue.DoesNotExist:
+                                        cfv = CustomFieldValue(
+                                            field=field, obj_type=obj_type, obj_id=obj.pk
+                                        )
+                                    cfv.value = form.cleaned_data[name]
+                                    cfv.save()
+
+                            updated_count += 1
+
+                    if updated_count:
+                        msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
+                        messages.success(self.request, msg)
+                        UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
+
+                    return redirect(return_url)
+
+                except ValidationError as e:
+                    messages.error(self.request, "{} failed validation: {}".format(obj, e))
 
         else:
             initial_data = request.POST.copy()
@@ -555,53 +580,6 @@ class BulkEditView(View):
             'return_url': return_url,
         })
 
-    def update_custom_fields(self, pk_list, form, fields, nullified_fields):
-        obj_type = ContentType.objects.get_for_model(self.cls)
-        objs_updated = False
-
-        for name in fields:
-
-            field = form.fields[name].model
-
-            # Setting the field to null
-            if name in form.nullable_fields and name in nullified_fields:
-
-                # Delete all CustomFieldValues for instances of this field belonging to the selected objects.
-                CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list).delete()
-                objs_updated = True
-
-            # Updating the value of the field
-            elif form.cleaned_data[name] not in [None, '']:
-
-                # Check for zero value (bulk editing)
-                if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
-                    serialized_value = field.serialize_value(None)
-                else:
-                    serialized_value = field.serialize_value(form.cleaned_data[name])
-
-                # Gather any pre-existing CustomFieldValues for the objects being edited.
-                existing_cfvs = CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list)
-
-                # Determine which objects have an existing CFV to update and which need a new CFV created.
-                update_list = [cfv['obj_id'] for cfv in existing_cfvs.values()]
-                create_list = list(set(pk_list) - set(update_list))
-
-                # Creating/updating CFVs
-                if serialized_value:
-                    existing_cfvs.update(serialized_value=serialized_value)
-                    CustomFieldValue.objects.bulk_create([
-                        CustomFieldValue(field=field, obj_type=obj_type, obj_id=pk, serialized_value=serialized_value)
-                        for pk in create_list
-                    ])
-
-                # Deleting CFVs
-                else:
-                    existing_cfvs.delete()
-
-                objs_updated = True
-
-        return len(pk_list) if objs_updated else 0
-
 
 class BulkDeleteView(View):
     """
@@ -763,6 +741,26 @@ class ComponentCreateView(View):
 
             if not form.errors:
                 self.model.objects.bulk_create(new_components)
+
+                # ManyToMany relations are bulk created via the through model
+                m2m_fields = [field for field in component_form.fields if type(component_form.fields[field]) in M2M_FIELD_TYPES]
+                if m2m_fields:
+                    for field in m2m_fields:
+                        field_links = []
+                        for new_component in new_components:
+                            for related_obj in component_form.cleaned_data[field]:
+                                # The through model columns are the id's of our M2M relation objects
+                                through_kwargs = {}
+                                new_component_column = new_component.__class__.__name__ + '_id'
+                                related_obj_column = related_obj.__class__.__name__ + '_id'
+                                through_kwargs.update({
+                                    new_component_column.lower(): new_component.id,
+                                    related_obj_column.lower(): related_obj.id
+                                })
+                                field_link = getattr(self.model, field).through(**through_kwargs)
+                                field_links.append(field_link)
+                        getattr(self.model, field).through.objects.bulk_create(field_links)
+
                 messages.success(request, "Added {} {} to {}.".format(
                     len(new_components), self.model._meta.verbose_name_plural, parent
                 ))
@@ -779,20 +777,6 @@ class ComponentCreateView(View):
         })
 
 
-class ComponentEditView(ObjectEditView):
-    parent_field = None
-
-    def get_return_url(self, request, obj):
-        return getattr(obj, self.parent_field).get_absolute_url()
-
-
-class ComponentDeleteView(ObjectDeleteView):
-    parent_field = None
-
-    def get_return_url(self, request, obj):
-        return getattr(obj, self.parent_field).get_absolute_url()
-
-
 class BulkComponentCreateView(View):
     """
     Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.

+ 6 - 6
netbox/virtualization/api/serializers.py

@@ -9,7 +9,7 @@ from extras.api.customfields import CustomFieldModelSerializer
 from ipam.models import IPAddress
 from tenancy.api.serializers import NestedTenantSerializer
 from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
-from virtualization.constants import STATUS_CHOICES
+from virtualization.constants import VM_STATUS_CHOICES
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
@@ -62,7 +62,7 @@ class ClusterSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Cluster
-        fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields']
+        fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
 
 
 class NestedClusterSerializer(serializers.ModelSerializer):
@@ -77,7 +77,7 @@ class WritableClusterSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Cluster
-        fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields']
+        fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
 
 
 #
@@ -94,7 +94,7 @@ class VirtualMachineIPAddressSerializer(serializers.ModelSerializer):
 
 
 class VirtualMachineSerializer(CustomFieldModelSerializer):
-    status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
+    status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES)
     cluster = NestedClusterSerializer()
     role = NestedDeviceRoleSerializer()
     tenant = NestedTenantSerializer()
@@ -107,7 +107,7 @@ class VirtualMachineSerializer(CustomFieldModelSerializer):
         model = VirtualMachine
         fields = [
             'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
-            'vcpus', 'memory', 'disk', 'comments', 'custom_fields',
+            'vcpus', 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
         ]
 
 
@@ -125,7 +125,7 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
         model = VirtualMachine
         fields = [
             'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus',
-            'memory', 'disk', 'comments', 'custom_fields',
+            'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
         ]
 
 

+ 4 - 6
netbox/virtualization/api/views.py

@@ -1,10 +1,8 @@
 from __future__ import unicode_literals
 
-from rest_framework.viewsets import ModelViewSet
-
 from dcim.models import Interface
 from extras.api.views import CustomFieldModelViewSet
-from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
+from utilities.api import FieldChoicesViewSet, ModelViewSet
 from virtualization import filters
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from . import serializers
@@ -34,7 +32,7 @@ class ClusterGroupViewSet(ModelViewSet):
     serializer_class = serializers.ClusterGroupSerializer
 
 
-class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class ClusterViewSet(CustomFieldModelViewSet):
     queryset = Cluster.objects.select_related('type', 'group')
     serializer_class = serializers.ClusterSerializer
     write_serializer_class = serializers.WritableClusterSerializer
@@ -45,14 +43,14 @@ class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 # Virtual machines
 #
 
-class VirtualMachineViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class VirtualMachineViewSet(CustomFieldModelViewSet):
     queryset = VirtualMachine.objects.all()
     serializer_class = serializers.VirtualMachineSerializer
     write_serializer_class = serializers.WritableVirtualMachineSerializer
     filter_class = filters.VirtualMachineFilter
 
 
-class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
+class InterfaceViewSet(ModelViewSet):
     queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
     serializer_class = serializers.InterfaceSerializer
     write_serializer_class = serializers.WritableInterfaceSerializer

+ 5 - 5
netbox/virtualization/constants.py

@@ -1,12 +1,12 @@
 from __future__ import unicode_literals
 
-from dcim.constants import STATUS_ACTIVE, STATUS_OFFLINE, STATUS_STAGED
+from dcim.constants import DEVICE_STATUS_ACTIVE, DEVICE_STATUS_OFFLINE, DEVICE_STATUS_STAGED
 
 # VirtualMachine statuses (replicated from Device statuses)
-STATUS_CHOICES = [
-    [STATUS_ACTIVE, 'Active'],
-    [STATUS_OFFLINE, 'Offline'],
-    [STATUS_STAGED, 'Staged'],
+VM_STATUS_CHOICES = [
+    [DEVICE_STATUS_ACTIVE, 'Active'],
+    [DEVICE_STATUS_OFFLINE, 'Offline'],
+    [DEVICE_STATUS_STAGED, 'Staged'],
 ]
 
 # Bootstrap CSS classes for VirtualMachine statuses

+ 2 - 2
netbox/virtualization/filters.py

@@ -9,7 +9,7 @@ from dcim.models import DeviceRole, Interface, Platform, Site
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from utilities.filters import NumericInFilter
-from .constants import STATUS_CHOICES
+from .constants import VM_STATUS_CHOICES
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
@@ -70,7 +70,7 @@ class VirtualMachineFilter(CustomFieldFilterSet):
         label='Search',
     )
     status = django_filters.MultipleChoiceFilter(
-        choices=STATUS_CHOICES,
+        choices=VM_STATUS_CHOICES,
         null_value=None
     )
     cluster_group_id = django_filters.ModelMultipleChoiceFilter(

+ 4 - 4
netbox/virtualization/forms.py

@@ -17,7 +17,7 @@ from utilities.forms import (
     ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
     ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea,
 )
-from .constants import STATUS_CHOICES
+from .constants import VM_STATUS_CHOICES
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 VIFACE_FF_CHOICES = (
@@ -300,7 +300,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
 class VirtualMachineCSVForm(forms.ModelForm):
     status = CSVChoiceField(
-        choices=STATUS_CHOICES,
+        choices=VM_STATUS_CHOICES,
         required=False,
         help_text='Operational status of device'
     )
@@ -347,7 +347,7 @@ class VirtualMachineCSVForm(forms.ModelForm):
 
 class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput)
-    status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='')
+    status = forms.ChoiceField(choices=add_blank_choice(VM_STATUS_CHOICES), required=False, initial='')
     cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False)
     role = forms.ModelChoiceField(queryset=DeviceRole.objects.filter(vm_role=True), required=False)
     tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
@@ -365,7 +365,7 @@ def vm_status_choices():
     status_counts = {}
     for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'):
         status_counts[status['status']] = status['count']
-    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
+    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VM_STATUS_CHOICES]
 
 
 class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):

+ 8 - 3
netbox/virtualization/models.py

@@ -10,7 +10,7 @@ from django.utils.encoding import python_2_unicode_compatible
 from dcim.models import Device
 from extras.models import CustomFieldModel, CustomFieldValue
 from utilities.models import CreatedUpdatedModel
-from .constants import STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES
+from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
 
 
 #
@@ -190,8 +190,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
         unique=True
     )
     status = models.PositiveSmallIntegerField(
-        choices=STATUS_CHOICES,
-        default=STATUS_ACTIVE,
+        choices=VM_STATUS_CHOICES,
+        default=DEVICE_STATUS_ACTIVE,
         verbose_name='Status'
     )
     role = models.ForeignKey(
@@ -282,3 +282,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
             return self.primary_ip4
         else:
             return None
+
+    def site(self):
+        # used when a child compent (eg Interface) needs to know its parent's site but
+        # the parent could be either a device or a virtual machine
+        return self.cluster.site

+ 120 - 13
netbox/virtualization/tests/test_api.py

@@ -44,7 +44,7 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('virtualization-api:clustertype-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(ClusterType.objects.count(), 4)
@@ -52,6 +52,32 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
         self.assertEqual(clustertype4.name, data['name'])
         self.assertEqual(clustertype4.slug, data['slug'])
 
+    def test_create_clustertype_bulk(self):
+
+        data = [
+            {
+                'name': 'Test Cluster Type 4',
+                'slug': 'test-cluster-type-4',
+            },
+            {
+                'name': 'Test Cluster Type 5',
+                'slug': 'test-cluster-type-5',
+            },
+            {
+                'name': 'Test Cluster Type 6',
+                'slug': 'test-cluster-type-6',
+            },
+        ]
+
+        url = reverse('virtualization-api:clustertype-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(ClusterType.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_clustertype(self):
 
         data = {
@@ -60,7 +86,7 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(ClusterType.objects.count(), 3)
@@ -111,7 +137,7 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('virtualization-api:clustergroup-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(ClusterGroup.objects.count(), 4)
@@ -119,6 +145,32 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
         self.assertEqual(clustergroup4.name, data['name'])
         self.assertEqual(clustergroup4.slug, data['slug'])
 
+    def test_create_clustergroup_bulk(self):
+
+        data = [
+            {
+                'name': 'Test Cluster Group 4',
+                'slug': 'test-cluster-group-4',
+            },
+            {
+                'name': 'Test Cluster Group 5',
+                'slug': 'test-cluster-group-5',
+            },
+            {
+                'name': 'Test Cluster Group 6',
+                'slug': 'test-cluster-group-6',
+            },
+        ]
+
+        url = reverse('virtualization-api:clustergroup-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(ClusterGroup.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_clustergroup(self):
 
         data = {
@@ -127,7 +179,7 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(ClusterGroup.objects.count(), 3)
@@ -182,7 +234,7 @@ class ClusterTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('virtualization-api:cluster-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Cluster.objects.count(), 4)
@@ -191,6 +243,35 @@ class ClusterTest(HttpStatusMixin, APITestCase):
         self.assertEqual(cluster4.type.pk, data['type'])
         self.assertEqual(cluster4.group.pk, data['group'])
 
+    def test_create_cluster_bulk(self):
+
+        data = [
+            {
+                'name': 'Test Cluster 4',
+                'type': ClusterType.objects.first().pk,
+                'group': ClusterGroup.objects.first().pk,
+            },
+            {
+                'name': 'Test Cluster 5',
+                'type': ClusterType.objects.first().pk,
+                'group': ClusterGroup.objects.first().pk,
+            },
+            {
+                'name': 'Test Cluster 6',
+                'type': ClusterType.objects.first().pk,
+                'group': ClusterGroup.objects.first().pk,
+            },
+        ]
+
+        url = reverse('virtualization-api:cluster-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Cluster.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_cluster(self):
 
         cluster_type2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
@@ -202,7 +283,7 @@ class ClusterTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(Cluster.objects.count(), 3)
@@ -230,11 +311,11 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
 
         cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
         cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
-        cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
+        self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
 
-        self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=cluster)
-        self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=cluster)
-        self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=cluster)
+        self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=self.cluster1)
+        self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=self.cluster1)
+        self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=self.cluster1)
 
     def test_get_virtualmachine(self):
 
@@ -254,11 +335,11 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
 
         data = {
             'name': 'Test Virtual Machine 4',
-            'cluster': Cluster.objects.first().pk,
+            'cluster': self.cluster1.pk,
         }
 
         url = reverse('virtualization-api:virtualmachine-list')
-        response = self.client.post(url, data, **self.header)
+        response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(VirtualMachine.objects.count(), 4)
@@ -266,6 +347,32 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
         self.assertEqual(virtualmachine4.name, data['name'])
         self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
 
+    def test_create_virtualmachine_bulk(self):
+
+        data = [
+            {
+                'name': 'Test Virtual Machine 4',
+                'cluster': self.cluster1.pk,
+            },
+            {
+                'name': 'Test Virtual Machine 5',
+                'cluster': self.cluster1.pk,
+            },
+            {
+                'name': 'Test Virtual Machine 6',
+                'cluster': self.cluster1.pk,
+            },
+        ]
+
+        url = reverse('virtualization-api:virtualmachine-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(VirtualMachine.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
     def test_update_virtualmachine(self):
 
         cluster2 = Cluster.objects.create(
@@ -279,7 +386,7 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
         }
 
         url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
-        response = self.client.put(url, data, **self.header)
+        response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(VirtualMachine.objects.count(), 3)

+ 4 - 6
netbox/virtualization/views.py

@@ -11,8 +11,8 @@ from dcim.models import Device, Interface
 from dcim.tables import DeviceTable
 from ipam.models import Service
 from utilities.views import (
-    BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView,
-    ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView,
+    BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
+    ObjectEditView, ObjectListView,
 )
 from . import filters, forms, tables
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -325,17 +325,15 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
     template_name = 'virtualization/virtualmachine_component_add.html'
 
 
-class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
+class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_interface'
     model = Interface
-    parent_field = 'virtual_machine'
     model_form = forms.InterfaceForm
 
 
-class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_interface'
     model = Interface
-    parent_field = 'virtual_machine'
 
 
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):

+ 1 - 0
old_requirements.txt

@@ -1 +1,2 @@
+psycopg2
 pycrypto

+ 14 - 13
requirements.txt

@@ -1,19 +1,20 @@
 Django>=1.11,<2.0
-django-cors-headers>=2.1
-django-debug-toolbar>=1.8
+django-cors-headers>=2.1.0
+django-debug-toolbar>=1.9.0
 django-filter>=1.1.0
-django-mptt==0.8.7
+django-mptt>=0.9.0
 django-rest-swagger>=2.1.0
-django-tables2>=1.10.0
-djangorestframework>=3.6.4
-graphviz>=0.6
-Markdown>=2.6.7
-natsort>=5.0.0
+django-tables2>=1.19.0
+django-timezone-field>=2.0
+djangorestframework>=3.7.7
+graphviz>=0.8.2
+Markdown>=2.6.11
+natsort>=5.2.0
 ncclient==0.5.3
 netaddr==0.7.18
-paramiko>=2.0.0
-Pillow>=4.0.0
-psycopg2>=2.7.3
+paramiko>=2.4.0
+Pillow>=5.0.0
+psycopg2-binary>=2.7.4
 py-gfm>=0.1.3
-pycryptodome>=3.4.7
-xmltodict>=0.10.2
+pycryptodome>=3.4.11
+xmltodict>=0.11.0