Просмотр исходного кода

Merge branch 'develop' into develop-2.7

Jeremy Stretch 6 лет назад
Родитель
Сommit
778e5bed3c

+ 24 - 0
CHANGELOG.md

@@ -6,6 +6,30 @@ v2.7.0 (FUTURE)
 
 ---
 
+v2.6.5 (2019-09-25)
+
+## Enhancements
+
+* [#3297](https://github.com/netbox-community/netbox/issues/3297) -  Include reserved units when calculating rack utilization
+* [#3347](https://github.com/netbox-community/netbox/issues/3347) -  Extend upgrade script to automatically remove stale content types
+* [#3352](https://github.com/netbox-community/netbox/issues/3352) -  Enable filtering changelog API by `changed_object_id`
+* [#3515](https://github.com/netbox-community/netbox/issues/3515) -  Enable export templates for inventory items
+* [#3524](https://github.com/netbox-community/netbox/issues/3524) -  Enable bulk editing of power outlet/power port associations
+* [#3529](https://github.com/netbox-community/netbox/issues/3529) -  Enable filtering circuits list by region
+
+## Bug Fixes
+
+* [#3435](https://github.com/netbox-community/netbox/issues/3435) -  Change IP/prefix CSV export to reference VRF name instead of RD
+* [#3464](https://github.com/netbox-community/netbox/issues/3464) -  Fix foreground text color on color picker fields
+* [#3519](https://github.com/netbox-community/netbox/issues/3519) -  Prevent cables from being terminated to virtual/wireless interfaces via API
+* [#3521](https://github.com/netbox-community/netbox/issues/3521) -  Fix error in `parseURL` related to variables in API URL
+* [#3531](https://github.com/netbox-community/netbox/issues/3531) -  Fixed rack role foreground color
+* [#3534](https://github.com/netbox-community/netbox/issues/3534) -  Added blank option for untagged VLANs
+* [#3540](https://github.com/netbox-community/netbox/issues/3540) -  Fixed virtual machine interface edit with new inline vlan edit fields
+* [#3543](https://github.com/netbox-community/netbox/issues/3543) -  Added inline VLAN editing to virtual machine interfaces
+
+---
+
 v2.6.4 (2019-09-19)
 
 ## Enhancements

+ 22 - 0
CONTRIBUTING.md

@@ -117,3 +117,25 @@ Only comment on an issue if you are sharing a relevant idea or constructive
 feedback. **Do not** comment on an issue just to show your support (give the
 top post a :+1: instead) or ask for an ETA. These comments will be deleted to
 reduce noise in the discussion.
+
+## Maintainer Guidance
+
+* Maintainers are expected to contribute at least four hours per week to the
+  project on average. This can be employer-sponsored or individual time, with
+  the understanding that all contributions are submitted under the Apache 2.0
+  license and that your employer may not make claim to any contributions.
+  Contributions include code work, issue management, and community support. All
+  development must be in accordance with our [development guidance](https://netbox.readthedocs.io/en/stable/development/).
+
+* Maintainers are expected to attend (where feasible) our biweekly ~30-minute
+  sync to review agenda items. This meeting provides opportunity to present and
+  discuss pressing topics. Meetings are held as virtual audio/video conferences.
+
+* Official channels for communication include:
+
+    * GitHub issues/pull requests
+    * The [netbox-discuss](https://groups.google.com/forum/#!forum/netbox-discuss) mailing list
+    * The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
+
+* Maintainers with no substantial recorded activity in a 60-day period will be
+  removed from the project.

+ 1 - 0
README.md

@@ -40,6 +40,7 @@ and run `upgrade.sh`.
 * [Docker container](https://github.com/netbox-community/netbox-docker) (via [@cimnine](https://github.com/cimnine))
 * [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
 * [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
+* [Kubernetes deployment](https://github.com/CENGN/netbox-kubernetes) (via [@CENGN](https://github.com/CENGN))
 
 # Related projects
 

+ 1 - 1
docs/additional-features/graphs.md

@@ -2,7 +2,7 @@
 
 NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters:
 
-* **Type:** Site, provider, or interface. This determines in which view the graph will be displayed.
+* **Type:** Site, device, provider, or interface. This determines in which view the graph will be displayed.
 * **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name.
 * **Name:** The title to display above the graph.
 * **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`.

+ 7 - 5
mkdocs.yml

@@ -27,16 +27,18 @@ pages:
         - Secrets: 'core-functionality/secrets.md'
         - Tenancy: 'core-functionality/tenancy.md'
     - Additional Features:
-        - Tags: 'additional-features/tags.md'
-        - Custom Fields: 'additional-features/custom-fields.md'
+        - Caching: 'additional-features/caching.md'
+        - Change Logging: 'additional-features/change-logging.md'
         - Context Data: 'additional-features/context-data.md'
+        - Custom Fields: 'additional-features/custom-fields.md'
+        - Custom Scripts: 'additional-features/custom-scripts.md'
         - Export Templates: 'additional-features/export-templates.md'
         - Graphs: 'additional-features/graphs.md'
+        - Prometheus Metrics: 'additional-features/prometheus-metrics.md'
         - Reports: 'additional-features/reports.md'
+        - Tags: 'additional-features/tags.md'
+        - Topology Maps: 'additional-features/topology-maps.md'
         - Webhooks: 'additional-features/webhooks.md'
-        - Change Logging: 'additional-features/change-logging.md'
-        - Caching: 'additional-features/caching.md'
-        - Prometheus Metrics: 'additional-features/prometheus-metrics.md'
     - Administration:
         - Replicating NetBox: 'administration/replicating-netbox.md'
         - NetBox Shell: 'administration/netbox-shell.md'

+ 13 - 2
netbox/circuits/forms.py

@@ -1,7 +1,7 @@
 from django import forms
 from taggit.forms import TagField
 
-from dcim.models import Site
+from dcim.models import Region, Site
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyFilterForm
@@ -268,7 +268,9 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
 
 class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = Circuit
-    field_order = ['q', 'type', 'provider', 'status', 'site', 'tenant_group', 'tenant', 'commit_rate']
+    field_order = [
+        'q', 'type', 'provider', 'status', 'region', 'site', 'tenant_group', 'tenant', 'commit_rate',
+    ]
     q = forms.CharField(
         required=False,
         label='Search'
@@ -294,6 +296,15 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
         required=False,
         widget=StaticSelect2Multiple()
     )
+    region = forms.ModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+        )
+    )
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         to_field_name='slug',

+ 14 - 4
netbox/dcim/forms.py

@@ -2106,6 +2106,10 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         choices=add_blank_choice(POWERFEED_LEG_CHOICES),
         required=False,
     )
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPort.objects.all(),
+        required=False
+    )
     description = forms.CharField(
         max_length=100,
         required=False
@@ -2113,9 +2117,15 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
 
     class Meta:
         nullable_fields = [
-            'feed_leg', 'description',
+            'feed_leg', 'power_port', 'description',
         ]
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port queryset to PowerPorts which belong to the parent Device
+        self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent_obj)
+
 
 class PowerOutletBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(
@@ -2220,7 +2230,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
                     [(vlan.pk, vlan) for vlan in site_group_vlans]
                 ))
 
-        self.fields['untagged_vlan'].choices = vlan_choices
+        self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
         self.fields['tagged_vlans'].choices = vlan_choices
 
 
@@ -2330,7 +2340,7 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
                     [(vlan.pk, vlan) for vlan in site_group_vlans]
                 ))
 
-        self.fields['untagged_vlan'].choices = vlan_choices
+        self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
         self.fields['tagged_vlans'].choices = vlan_choices
 
 
@@ -2442,7 +2452,7 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo
                         [(vlan.pk, vlan) for vlan in site_group_vlans]
                     ))
 
-        self.fields['untagged_vlan'].choices = vlan_choices
+        self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
         self.fields['tagged_vlans'].choices = vlan_choices
 
 

+ 28 - 17
netbox/dcim/models.py

@@ -732,10 +732,21 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
 
     def get_utilization(self):
         """
-        Determine the utilization rate of the rack and return it as a percentage.
+        Determine the utilization rate of the rack and return it as a percentage. Occupied and reserved units both count
+        as utilized.
         """
-        u_available = len(self.get_available_units())
-        return int(float(self.u_height - u_available) / self.u_height * 100)
+        # Determine unoccupied units
+        available_units = self.get_available_units()
+
+        # Remove reserved units
+        for u in self.get_reserved_units():
+            if u in available_units:
+                available_units.remove(u)
+
+        occupied_unit_count = self.u_height - len(available_units)
+        percentage = int(float(occupied_unit_count) / self.u_height * 100)
+
+        return percentage
 
     def get_power_utilization(self):
         """
@@ -2785,6 +2796,20 @@ class Cable(ChangeLoggedModel):
         type_a = self.termination_a_type.model
         type_b = self.termination_b_type.model
 
+        # Validate interface types
+        if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
+            raise ValidationError({
+                'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
+                    self.termination_a.get_type_display()
+                )
+            })
+        if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
+            raise ValidationError({
+                'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
+                    self.termination_b.get_type_display()
+                )
+            })
+
         # Check that termination types are compatible
         if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
             raise ValidationError("Incompatible termination types: {} and {}".format(
@@ -2826,20 +2851,6 @@ class Cable(ChangeLoggedModel):
                 self.termination_b, self.termination_b.cable_id
             ))
 
-        # Virtual interfaces cannot be connected
-        endpoint_a, endpoint_b, _ = self.get_path_endpoints()
-        if (
-            (
-                isinstance(endpoint_a, Interface) and
-                endpoint_a.type == IFACE_TYPE_VIRTUAL
-            ) or
-            (
-                isinstance(endpoint_b, Interface) and
-                endpoint_b.type == IFACE_TYPE_VIRTUAL
-            )
-        ):
-            raise ValidationError("Cannot connect to a virtual interface")
-
         # Validate length and length_unit
         if self.length is not None and self.length_unit is None:
             raise ValidationError("Must specify a unit when setting a cable length")

+ 2 - 1
netbox/dcim/tables.py

@@ -74,7 +74,8 @@ RACKROLE_ACTIONS = """
 
 RACK_ROLE = """
 {% if record.role %}
-    <label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>
+    {% load helpers %}
+    <label class="label" style="color: {{ record.role.color|fgcolor }}; background-color: #{{ record.role.color }}">{{ value }}</label>
 {% else %}
     &mdash;
 {% endif %}

+ 16 - 7
netbox/dcim/tests/test_models.py

@@ -343,7 +343,7 @@ class CableTestCase(TestCase):
 
     def test_cable_validates_compatibale_types(self):
         """
-        The clean method should have a check to ensure only compatiable port types can be connected by a cable
+        The clean method should have a check to ensure only compatible port types can be connected by a cable
         """
         # An interface cannot be connected to a power port
         cable = Cable(termination_a=self.interface1, termination_b=self.power_port1)
@@ -360,30 +360,39 @@ class CableTestCase(TestCase):
 
     def test_cable_front_port_cannot_connect_to_corresponding_rear_port(self):
         """
-        A cable cannot connect a front port to its sorresponding rear port
+        A cable cannot connect a front port to its corresponding rear port
         """
         cable = Cable(termination_a=self.front_port, termination_b=self.rear_port)
         with self.assertRaises(ValidationError):
             cable.clean()
 
-    def test_cable_cannot_be_connected_to_an_existing_connection(self):
+    def test_cable_cannot_terminate_to_an_existing_connection(self):
         """
-        Either side of a cable cannot be terminated when that side aready has a connection
+        Either side of a cable cannot be terminated when that side already has a connection
         """
         # Try to create a cable with the same interface terminations
         cable = Cable(termination_a=self.interface2, termination_b=self.interface1)
         with self.assertRaises(ValidationError):
             cable.clean()
 
-    def test_cable_cannot_connect_to_a_virtual_inteface(self):
+    def test_cable_cannot_terminate_to_a_virtual_inteface(self):
         """
-        A cable connection cannot include a virtual interface
+        A cable cannot terminate to a virtual interface
         """
-        virtual_interface = Interface(device=self.device1, name="V1", type=0)
+        virtual_interface = Interface(device=self.device1, name="V1", type=IFACE_TYPE_VIRTUAL)
         cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
         with self.assertRaises(ValidationError):
             cable.clean()
 
+    def test_cable_cannot_terminate_to_a_wireless_inteface(self):
+        """
+        A cable cannot terminate to a wireless interface
+        """
+        wireless_interface = Interface(device=self.device1, name="W1", type=IFACE_TYPE_80211A)
+        cable = Cable(termination_a=self.interface2, termination_b=wireless_interface)
+        with self.assertRaises(ValidationError):
+            cable.clean()
+
 
 class CablePathTestCase(TestCase):
 

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

@@ -222,8 +222,8 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
     class Meta:
         model = ObjectChange
         fields = [
-            'id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object',
-            'object_data',
+            'id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
+            'changed_object', 'object_data',
         ]
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)

+ 1 - 0
netbox/extras/constants.py

@@ -107,6 +107,7 @@ EXPORTTEMPLATE_MODELS = [
     'dcim.device',
     'dcim.devicetype',
     'dcim.interface',
+    'dcim.inventoryitem',
     'dcim.manufacturer',
     'dcim.powerpanel',
     'dcim.powerport',

+ 3 - 1
netbox/extras/filters.py

@@ -212,7 +212,9 @@ class ObjectChangeFilter(django_filters.FilterSet):
 
     class Meta:
         model = ObjectChange
-        fields = ['user', 'user_name', 'request_id', 'action', 'changed_object_type', 'object_repr']
+        fields = [
+            'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'object_repr',
+        ]
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 8 - 3
netbox/extras/models.py

@@ -432,14 +432,19 @@ class ExportTemplate(models.Model):
         choices=TEMPLATE_LANGUAGE_CHOICES,
         default=TEMPLATE_LANGUAGE_JINJA2
     )
-    template_code = models.TextField()
+    template_code = models.TextField(
+        help_text='The list of objects being exported is passed as a context variable named <code>queryset</code>.'
+    )
     mime_type = models.CharField(
         max_length=50,
-        blank=True
+        blank=True,
+        verbose_name='MIME type',
+        help_text='Defaults to <code>text/plain</code>'
     )
     file_extension = models.CharField(
         max_length=15,
-        blank=True
+        blank=True,
+        help_text='Extension to append to the rendered filename'
     )
 
     class Meta:

+ 2 - 2
netbox/ipam/models.py

@@ -382,7 +382,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
     def to_csv(self):
         return (
             self.prefix,
-            self.vrf.rd if self.vrf else None,
+            self.vrf.name if self.vrf else None,
             self.tenant.name if self.tenant else None,
             self.site.name if self.site else None,
             self.vlan.group.name if self.vlan and self.vlan.group else None,
@@ -674,7 +674,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
 
         return (
             self.address,
-            self.vrf.rd if self.vrf else None,
+            self.vrf.name if self.vrf else None,
             self.tenant.name if self.tenant else None,
             self.get_status_display(),
             self.get_role_display(),

+ 0 - 110
netbox/project-static/css/base.css

@@ -133,116 +133,6 @@ input[name="pk"] {
     margin-top: 0;
 }
 
-/* Color Selections */
-.color-selection-aa1409 {
-    background-color: #aa1409;
-    color: #ffffff;
-}
-.color-selection-f44336 {
-    background-color: #f44336;
-    color: #ffffff;
-}
-.color-selection-e91e63 {
-    background-color: #e91e63;
-    color: #ffffff;
-}
-.color-selection-ffe4e1 {
-    background-color: #ffe4e1;
-    color: #000000;
-}
-.color-selection-ff66ff {
-    background-color: #ff66ff;
-    color: #ffffff;
-}
-.color-selection-9c27b0 {
-    background-color: #9c27b0;
-    color: #ffffff;
-}
-.color-selection-673ab7 {
-    background-color: #673ab7;
-    color: #ffffff;
-}
-.color-selection-3f51b5 {
-    background-color: #3f51b5;
-    color: #ffffff;
-}
-.color-selection-2196f3 {
-    background-color: #2196f3;
-    color: #ffffff;
-}
-.color-selection-03a9f4 {
-    background-color: #03a9f4;
-    color: #ffffff;
-}
-.color-selection-00bcd4 {
-    background-color: #00bcd4;
-    color: #ffffff;
-}
-.color-selection-009688 {
-    background-color: #009688;
-    color: #ffffff;
-}
-.color-selection-00ffff {
-    background-color: #00ffff;
-    color: #ffffff;
-}
-.color-selection-2f6a31 {
-    background-color: #2f6a31;
-    color: #ffffff;
-}
-.color-selection-4caf50 {
-    background-color: #4caf50;
-    color: #ffffff;
-}
-.color-selection-8bc34a {
-    background-color: #8bc34a;
-    color: #ffffff;
-}
-.color-selection-cddc39 {
-    background-color: #cddc39;
-    color: #000000;
-}
-.color-selection-ffeb3b {
-    background-color: #ffeb3b;
-    color: #000000;
-}
-.color-selection-ffc107 {
-    background-color: #ffc107;
-    color: #000000;
-}
-.color-selection-ff9800 {
-    background-color: #ff9800;
-    color: #ffffff;
-}
-.color-selection-ff5722 {
-    background-color: #ff5722;
-    color: #ffffff;
-}
-.color-selection-795548 {
-    background-color: #795548;
-    color: #ffffff;
-}
-.color-selection-c0c0c0 {
-    background-color: #c0c0c0;
-    color: #000000;
-}
-.color-selection-9e9e9e {
-    background-color: #9e9e9e;
-    color: #ffffff;
-}
-.color-selection-607d8b {
-    background-color: #607d8b;
-    color: #ffffff;
-}
-.color-selection-111111 {
-    background-color: #111111;
-    color: #ffffff;
-}
-.color-selection-ffffff {
-    background-color: #ffffff;
-    color: #000000;
-}
-
 
 /* Tables */
 th.pk, td.pk {

+ 5 - 8
netbox/project-static/js/forms.js

@@ -75,7 +75,7 @@ $(document).ready(function() {
         var rendered_url = url;
         var filter_field;
         while (match = filter_regex.exec(url)) {
-            filter_field = $('#id_' + match[1]);untagged
+            filter_field = $('#id_' + match[1]);
             var custom_attr = $('option:selected', filter_field).attr('api-value');
             if (custom_attr) {
                 rendered_url = rendered_url.replace(match[0], custom_attr);
@@ -91,11 +91,8 @@ $(document).ready(function() {
     // Assign color picker selection classes
     function colorPickerClassCopy(data, container) {
         if (data.element) {
-            // Remove any existing color-selection classes
-            $(container).attr('class', function(i, c) {
-                return c.replace(/(^|\s)color-selection-\S+/g, '');
-            });
-            $(container).addClass($(data.element).attr("class"));
+            // Swap the style
+            $(container).attr('style', $(data.element).attr("style"));
         }
         return data.text;
     }
@@ -200,7 +197,7 @@ $(document).ready(function() {
                 $(element).children('option').attr('disabled', false);
                 var results = data.results;
 
-                results = results.reduce((results,record) => {
+                results = results.reduce((results,record,idx) => {
                     record.text = record[element.getAttribute('display-field')] || record.name;
                     record.id = record[element.getAttribute('value-field')] || record.id;
                     if(element.getAttribute('disabled-indicator') && record[element.getAttribute('disabled-indicator')]) {
@@ -225,7 +222,7 @@ $(document).ready(function() {
                         results['global'].children.push(record);
                     }
                     else {
-                        results[record.id] = record
+                        results[idx] = record
                     }
 
                     return results;

+ 0 - 66
netbox/scripts/examples.py

@@ -1,66 +0,0 @@
-from django.utils.text import slugify
-
-from dcim.constants import *
-from dcim.models import Device, DeviceRole, DeviceType, Site
-from extras.scripts import *
-
-
-class NewBranchScript(Script):
-    script_name = "New Branch"
-    script_description = "Provision a new branch site"
-    script_fields = ['site_name', 'switch_count', 'switch_model']
-
-    site_name = StringVar(
-        description="Name of the new site"
-    )
-    switch_count = IntegerVar(
-        description="Number of access switches to create"
-    )
-    switch_model = ObjectVar(
-        description="Access switch model",
-        queryset=DeviceType.objects.filter(
-            manufacturer__name='Cisco',
-            model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T']
-        )
-    )
-    x = BooleanVar(
-        description="Check me out"
-    )
-
-    def run(self, data):
-
-        # Create the new site
-        site = Site(
-            name=data['site_name'],
-            slug=slugify(data['site_name']),
-            status=SITE_STATUS_PLANNED
-        )
-        site.save()
-        self.log_success("Created new site: {}".format(site))
-
-        # Create access switches
-        switch_role = DeviceRole.objects.get(name='Access Switch')
-        for i in range(1, data['switch_count'] + 1):
-            switch = Device(
-                device_type=data['switch_model'],
-                name='{}-switch{}'.format(site.slug, i),
-                site=site,
-                status=DEVICE_STATUS_PLANNED,
-                device_role=switch_role
-            )
-            switch.save()
-            self.log_success("Created new switch: {}".format(switch))
-
-        # Generate a CSV table of new devices
-        output = [
-            'name,make,model'
-        ]
-        for switch in Device.objects.filter(site=site):
-            attrs = [
-                switch.name,
-                switch.device_type.manufacturer.name,
-                switch.device_type.model
-            ]
-            output.append(','.join(attrs))
-
-        return '\n'.join(output)

+ 0 - 54
netbox/scripts/myscripts.py

@@ -1,54 +0,0 @@
-from dcim.models import Site
-from extras.scripts import Script, BooleanVar, IntegerVar, ObjectVar, StringVar
-
-
-class NoInputScript(Script):
-    description = "This script does not require any input"
-
-    def run(self, data):
-
-        self.log_debug("This a debug message.")
-        self.log_info("This an info message.")
-        self.log_success("This a success message.")
-        self.log_warning("This a warning message.")
-        self.log_failure("This a failure message.")
-
-
-class DemoScript(Script):
-    name = "Script Demo"
-    description = "A quick demonstration of the available field types"
-
-    my_string1 = StringVar(
-        description="Input a string between 3 and 10 characters",
-        min_length=3,
-        max_length=10
-    )
-    my_string2 = StringVar(
-        description="This field enforces a regex: three letters followed by three numbers",
-        regex=r'[a-z]{3}\d{3}'
-    )
-    my_number = IntegerVar(
-        description="Pick a number between 1 and 255 (inclusive)",
-        min_value=1,
-        max_value=255
-    )
-    my_boolean = BooleanVar(
-        description="Use the checkbox to toggle true/false"
-    )
-    my_object = ObjectVar(
-        description="Select a NetBox site",
-        queryset=Site.objects.all()
-    )
-
-    def run(self, data):
-
-        self.log_info("Your string was {}".format(data['my_string1']))
-        self.log_info("Your second string was {}".format(data['my_string2']))
-        self.log_info("Your number was {}".format(data['my_number']))
-        if data['my_boolean']:
-            self.log_info("You ticked the checkbox")
-        else:
-            self.log_info("You did not tick the checkbox")
-        self.log_info("You chose the sites {}".format(data['my_object']))
-
-        return "Here's some output"

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

@@ -135,6 +135,10 @@
                         <a href="{% url 'dcim:device_list' %}?rack_id={{ rack.id }}">{{ rack.devices.count }}</a>
                     </td>
                 </tr>
+                    <tr>
+                        <td>Utilization</td>
+                        <td>{% utilization_graph rack.get_utilization %}</td>
+                    </tr>
             </table>
         </div>
         <div class="panel panel-default">

+ 3 - 27
netbox/templates/virtualization/interface_edit.html

@@ -11,20 +11,11 @@
             {% render_field form.mtu %}
             {% render_field form.description %}
             {% render_field form.mode %}
+            {% render_field form.untagged_vlan %}
+            {% render_field form.tagged_vlans %}
             {% render_field form.tags %}
         </div>
     </div>
-    {% if obj.mode %}
-        <div class="panel panel-default" id="vlans_panel">
-            <div class="panel-heading"><strong>802.1Q VLANs</strong></div>
-            {% include 'dcim/inc/interface_vlans_table.html' %}
-            <div class="panel-footer text-right">
-                <a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'virtualization:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
-                    <i class="glyphicon glyphicon-plus"></i> Add VLANs
-                </a>
-            </div>
-        </div>
-    {% endif %}
 {% endblock %}
 
 {% block buttons %}
@@ -36,19 +27,4 @@
         <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
     {% endif %}
     <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
-{% endblock %}
-
-{% block javascript %}
-    <script type="text/javascript">
-        $(document).ready(function() {
-            $('#clear_untagged_vlan').click(function () {
-                $('input[name="untagged_vlan"]').prop("checked", false);
-                return false;
-            });
-            $('#clear_tagged_vlans').click(function () {
-                $('input[name="tagged_vlans"]').prop("checked", false);
-                return false;
-            });
-        });
-    </script>
-{% endblock %}
+{% endblock %}

+ 2 - 1
netbox/utilities/templates/widgets/colorselect_option.html

@@ -1 +1,2 @@
-<option value="{{ widget.value }}"{% include "django/forms/widgets/attrs.html" %} class="color-selection-{{ widget.value }}">{{ widget.label }}</option>
+{% load helpers %}
+<option value="{{ widget.value }}"{% include "django/forms/widgets/attrs.html" %} {% if widget.value %}style="color: {{ widget.value|fgcolor }}; background-color: #{{ widget.value }}"{% endif %}>{{ widget.label }}</option>

+ 162 - 2
netbox/virtualization/forms.py

@@ -2,11 +2,11 @@ from django import forms
 from django.core.exceptions import ValidationError
 from taggit.forms import TagField
 
-from dcim.constants import IFACE_TYPE_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL
+from dcim.constants import IFACE_TYPE_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL, IFACE_MODE_CHOICES
 from dcim.forms import INTERFACE_MODE_HELP_TEXT
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLANGroup, VLAN
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.models import Tenant
@@ -616,6 +616,24 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
 #
 
 class InterfaceForm(BootstrapMixin, forms.ModelForm):
+    untagged_vlan = forms.ModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/vlans/",
+            display_field='display_name',
+            full=True
+        )
+    )
+    tagged_vlans = forms.ModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/ipam/vlans/",
+            display_field='display_name',
+            full=True
+        )
+    )
     tags = TagField(
         required=False
     )
@@ -638,6 +656,39 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
             'mode': INTERFACE_MODE_HELP_TEXT,
         }
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
+        vlan_choices = []
+        global_vlans = VLAN.objects.filter(site=None, group=None)
+        vlan_choices.append(
+            ('Global', [(vlan.pk, vlan) for vlan in global_vlans])
+        )
+        for group in VLANGroup.objects.filter(site=None):
+            global_group_vlans = VLAN.objects.filter(group=group)
+            vlan_choices.append(
+                (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
+            )
+
+        site = getattr(self.instance.device, 'site', None)
+        if site is not None:
+
+            # Add non-grouped site VLANs
+            site_vlans = VLAN.objects.filter(site=site, group=None)
+            vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
+
+            # Add grouped site VLANs
+            for group in VLANGroup.objects.filter(site=site):
+                site_group_vlans = VLAN.objects.filter(group=group)
+                vlan_choices.append((
+                    '{} / {}'.format(group.site.name, group.name),
+                    [(vlan.pk, vlan) for vlan in site_group_vlans]
+                ))
+
+        self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
+        self.fields['tagged_vlans'].choices = vlan_choices
+
     def clean(self):
         super().clean()
 
@@ -681,6 +732,29 @@ class InterfaceCreateForm(ComponentForm):
         max_length=100,
         required=False
     )
+    mode = forms.ChoiceField(
+        choices=add_blank_choice(IFACE_MODE_CHOICES),
+        required=False,
+        widget=StaticSelect2(),
+    )
+    untagged_vlan = forms.ModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/vlans/",
+            display_field='display_name',
+            full=True
+        )
+    )
+    tagged_vlans = forms.ModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/ipam/vlans/",
+            display_field='display_name',
+            full=True
+        )
+    )
     tags = TagField(
         required=False
     )
@@ -693,6 +767,36 @@ class InterfaceCreateForm(ComponentForm):
 
         super().__init__(*args, **kwargs)
 
+        # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
+        vlan_choices = []
+        global_vlans = VLAN.objects.filter(site=None, group=None)
+        vlan_choices.append(
+            ('Global', [(vlan.pk, vlan) for vlan in global_vlans])
+        )
+        for group in VLANGroup.objects.filter(site=None):
+            global_group_vlans = VLAN.objects.filter(group=group)
+            vlan_choices.append(
+                (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
+            )
+
+        site = getattr(self.parent.cluster, 'site', None)
+        if site is not None:
+
+            # Add non-grouped site VLANs
+            site_vlans = VLAN.objects.filter(site=site, group=None)
+            vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
+
+            # Add grouped site VLANs
+            for group in VLANGroup.objects.filter(site=site):
+                site_group_vlans = VLAN.objects.filter(group=group)
+                vlan_choices.append((
+                    '{} / {}'.format(group.site.name, group.name),
+                    [(vlan.pk, vlan) for vlan in site_group_vlans]
+                ))
+
+        self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
+        self.fields['tagged_vlans'].choices = vlan_choices
+
 
 class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
@@ -713,12 +817,68 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
         max_length=100,
         required=False
     )
+    mode = forms.ChoiceField(
+        choices=add_blank_choice(IFACE_MODE_CHOICES),
+        required=False,
+        widget=StaticSelect2()
+    )
+    untagged_vlan = forms.ModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/vlans/",
+            display_field='display_name',
+            full=True
+        )
+    )
+    tagged_vlans = forms.ModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/ipam/vlans/",
+            display_field='display_name',
+            full=True
+        )
+    )
 
     class Meta:
         nullable_fields = [
             'mtu', 'description',
         ]
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
+        vlan_choices = []
+        global_vlans = VLAN.objects.filter(site=None, group=None)
+        vlan_choices.append(
+            ('Global', [(vlan.pk, vlan) for vlan in global_vlans])
+        )
+        for group in VLANGroup.objects.filter(site=None):
+            global_group_vlans = VLAN.objects.filter(group=group)
+            vlan_choices.append(
+                (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
+            )
+        if self.parent_obj.cluster is not None:
+            site = getattr(self.parent_obj.cluster, 'site', None)
+            if site is not None:
+
+                # Add non-grouped site VLANs
+                site_vlans = VLAN.objects.filter(site=site, group=None)
+                vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
+
+                # Add grouped site VLANs
+                for group in VLANGroup.objects.filter(site=site):
+                    site_group_vlans = VLAN.objects.filter(group=group)
+                    vlan_choices.append((
+                        '{} / {}'.format(group.site.name, group.name),
+                        [(vlan.pk, vlan) for vlan in site_group_vlans]
+                    ))
+
+        self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
+        self.fields['tagged_vlans'].choices = vlan_choices
+
 
 #
 # Bulk VirtualMachine component creation

+ 5 - 0
upgrade.sh

@@ -25,6 +25,11 @@ COMMAND="${PYTHON} netbox/manage.py migrate"
 echo "Applying database migrations ($COMMAND)..."
 eval $COMMAND
 
+# Delete any stale content types
+COMMAND="${PYTHON} netbox/manage.py remove_stale_contenttypes --no-input"
+echo "Removing stale content types ($COMMAND)..."
+eval $COMMAND
+
 # Collect static files
 COMMAND="${PYTHON} netbox/manage.py collectstatic --no-input"
 echo "Collecting static files ($COMMAND)..."