瀏覽代碼

Merge branch 'develop' into develop-2.7

Jeremy Stretch 6 年之前
父節點
當前提交
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)
 v2.6.4 (2019-09-19)
 
 
 ## Enhancements
 ## 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
 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
 top post a :+1: instead) or ask for an ETA. These comments will be deleted to
 reduce noise in the discussion.
 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))
 * [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))
 * [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))
 * [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
 # 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:
 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.
 * **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.
 * **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`.
 * **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'
         - Secrets: 'core-functionality/secrets.md'
         - Tenancy: 'core-functionality/tenancy.md'
         - Tenancy: 'core-functionality/tenancy.md'
     - Additional Features:
     - 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'
         - 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'
         - Export Templates: 'additional-features/export-templates.md'
         - Graphs: 'additional-features/graphs.md'
         - Graphs: 'additional-features/graphs.md'
+        - Prometheus Metrics: 'additional-features/prometheus-metrics.md'
         - Reports: 'additional-features/reports.md'
         - Reports: 'additional-features/reports.md'
+        - Tags: 'additional-features/tags.md'
+        - Topology Maps: 'additional-features/topology-maps.md'
         - Webhooks: 'additional-features/webhooks.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:
     - Administration:
         - Replicating NetBox: 'administration/replicating-netbox.md'
         - Replicating NetBox: 'administration/replicating-netbox.md'
         - NetBox Shell: 'administration/netbox-shell.md'
         - NetBox Shell: 'administration/netbox-shell.md'

+ 13 - 2
netbox/circuits/forms.py

@@ -1,7 +1,7 @@
 from django import forms
 from django import forms
 from taggit.forms import TagField
 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 extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
@@ -268,7 +268,9 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
 
 
 class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
 class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = Circuit
     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(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
@@ -294,6 +296,15 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
         required=False,
         required=False,
         widget=StaticSelect2Multiple()
         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(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         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),
         choices=add_blank_choice(POWERFEED_LEG_CHOICES),
         required=False,
         required=False,
     )
     )
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPort.objects.all(),
+        required=False
+    )
     description = forms.CharField(
     description = forms.CharField(
         max_length=100,
         max_length=100,
         required=False
         required=False
@@ -2113,9 +2117,15 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
 
 
     class Meta:
     class Meta:
         nullable_fields = [
         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):
 class PowerOutletBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
@@ -2220,7 +2230,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
                     [(vlan.pk, vlan) for vlan in site_group_vlans]
                     [(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
         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]
                     [(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
         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]
                         [(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
         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):
     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):
     def get_power_utilization(self):
         """
         """
@@ -2785,6 +2796,20 @@ class Cable(ChangeLoggedModel):
         type_a = self.termination_a_type.model
         type_a = self.termination_a_type.model
         type_b = self.termination_b_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
         # Check that termination types are compatible
         if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
         if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
             raise ValidationError("Incompatible termination types: {} and {}".format(
             raise ValidationError("Incompatible termination types: {} and {}".format(
@@ -2826,20 +2851,6 @@ class Cable(ChangeLoggedModel):
                 self.termination_b, self.termination_b.cable_id
                 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
         # Validate length and length_unit
         if self.length is not None and self.length_unit is None:
         if self.length is not None and self.length_unit is None:
             raise ValidationError("Must specify a unit when setting a cable length")
             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 = """
 RACK_ROLE = """
 {% if record.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 %}
 {% else %}
     &mdash;
     &mdash;
 {% endif %}
 {% endif %}

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

@@ -343,7 +343,7 @@ class CableTestCase(TestCase):
 
 
     def test_cable_validates_compatibale_types(self):
     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
         # An interface cannot be connected to a power port
         cable = Cable(termination_a=self.interface1, termination_b=self.power_port1)
         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):
     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)
         cable = Cable(termination_a=self.front_port, termination_b=self.rear_port)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             cable.clean()
             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
         # Try to create a cable with the same interface terminations
         cable = Cable(termination_a=self.interface2, termination_b=self.interface1)
         cable = Cable(termination_a=self.interface2, termination_b=self.interface1)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             cable.clean()
             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)
         cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             cable.clean()
             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):
 class CablePathTestCase(TestCase):
 
 

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

@@ -222,8 +222,8 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
     class Meta:
     class Meta:
         model = ObjectChange
         model = ObjectChange
         fields = [
         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)
     @swagger_serializer_method(serializer_or_field=serializers.DictField)

+ 1 - 0
netbox/extras/constants.py

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

+ 3 - 1
netbox/extras/filters.py

@@ -212,7 +212,9 @@ class ObjectChangeFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = ObjectChange
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 8 - 3
netbox/extras/models.py

@@ -432,14 +432,19 @@ class ExportTemplate(models.Model):
         choices=TEMPLATE_LANGUAGE_CHOICES,
         choices=TEMPLATE_LANGUAGE_CHOICES,
         default=TEMPLATE_LANGUAGE_JINJA2
         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(
     mime_type = models.CharField(
         max_length=50,
         max_length=50,
-        blank=True
+        blank=True,
+        verbose_name='MIME type',
+        help_text='Defaults to <code>text/plain</code>'
     )
     )
     file_extension = models.CharField(
     file_extension = models.CharField(
         max_length=15,
         max_length=15,
-        blank=True
+        blank=True,
+        help_text='Extension to append to the rendered filename'
     )
     )
 
 
     class Meta:
     class Meta:

+ 2 - 2
netbox/ipam/models.py

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

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

@@ -133,116 +133,6 @@ input[name="pk"] {
     margin-top: 0;
     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 */
 /* Tables */
 th.pk, td.pk {
 th.pk, td.pk {

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

@@ -75,7 +75,7 @@ $(document).ready(function() {
         var rendered_url = url;
         var rendered_url = url;
         var filter_field;
         var filter_field;
         while (match = filter_regex.exec(url)) {
         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');
             var custom_attr = $('option:selected', filter_field).attr('api-value');
             if (custom_attr) {
             if (custom_attr) {
                 rendered_url = rendered_url.replace(match[0], custom_attr);
                 rendered_url = rendered_url.replace(match[0], custom_attr);
@@ -91,11 +91,8 @@ $(document).ready(function() {
     // Assign color picker selection classes
     // Assign color picker selection classes
     function colorPickerClassCopy(data, container) {
     function colorPickerClassCopy(data, container) {
         if (data.element) {
         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;
         return data.text;
     }
     }
@@ -200,7 +197,7 @@ $(document).ready(function() {
                 $(element).children('option').attr('disabled', false);
                 $(element).children('option').attr('disabled', false);
                 var results = data.results;
                 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.text = record[element.getAttribute('display-field')] || record.name;
                     record.id = record[element.getAttribute('value-field')] || record.id;
                     record.id = record[element.getAttribute('value-field')] || record.id;
                     if(element.getAttribute('disabled-indicator') && record[element.getAttribute('disabled-indicator')]) {
                     if(element.getAttribute('disabled-indicator') && record[element.getAttribute('disabled-indicator')]) {
@@ -225,7 +222,7 @@ $(document).ready(function() {
                         results['global'].children.push(record);
                         results['global'].children.push(record);
                     }
                     }
                     else {
                     else {
-                        results[record.id] = record
+                        results[idx] = record
                     }
                     }
 
 
                     return results;
                     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>
                         <a href="{% url 'dcim:device_list' %}?rack_id={{ rack.id }}">{{ rack.devices.count }}</a>
                     </td>
                     </td>
                 </tr>
                 </tr>
+                    <tr>
+                        <td>Utilization</td>
+                        <td>{% utilization_graph rack.get_utilization %}</td>
+                    </tr>
             </table>
             </table>
         </div>
         </div>
         <div class="panel panel-default">
         <div class="panel panel-default">

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

@@ -11,20 +11,11 @@
             {% render_field form.mtu %}
             {% render_field form.mtu %}
             {% render_field form.description %}
             {% render_field form.description %}
             {% render_field form.mode %}
             {% render_field form.mode %}
+            {% render_field form.untagged_vlan %}
+            {% render_field form.tagged_vlans %}
             {% render_field form.tags %}
             {% render_field form.tags %}
         </div>
         </div>
     </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 %}
 {% endblock %}
 
 
 {% block buttons %}
 {% block buttons %}
@@ -36,19 +27,4 @@
         <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
         <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
     {% endif %}
     {% endif %}
     <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
     <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 django.core.exceptions import ValidationError
 from taggit.forms import TagField
 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.forms import INTERFACE_MODE_HELP_TEXT
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
 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 TenancyForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -616,6 +616,24 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
 #
 #
 
 
 class InterfaceForm(BootstrapMixin, forms.ModelForm):
 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(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -638,6 +656,39 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
             'mode': INTERFACE_MODE_HELP_TEXT,
             '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):
     def clean(self):
         super().clean()
         super().clean()
 
 
@@ -681,6 +732,29 @@ class InterfaceCreateForm(ComponentForm):
         max_length=100,
         max_length=100,
         required=False
         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(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -693,6 +767,36 @@ class InterfaceCreateForm(ComponentForm):
 
 
         super().__init__(*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.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):
 class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
@@ -713,12 +817,68 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
         max_length=100,
         max_length=100,
         required=False
         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:
     class Meta:
         nullable_fields = [
         nullable_fields = [
             'mtu', 'description',
             '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
 # Bulk VirtualMachine component creation

+ 5 - 0
upgrade.sh

@@ -25,6 +25,11 @@ COMMAND="${PYTHON} netbox/manage.py migrate"
 echo "Applying database migrations ($COMMAND)..."
 echo "Applying database migrations ($COMMAND)..."
 eval $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
 # Collect static files
 COMMAND="${PYTHON} netbox/manage.py collectstatic --no-input"
 COMMAND="${PYTHON} netbox/manage.py collectstatic --no-input"
 echo "Collecting static files ($COMMAND)..."
 echo "Collecting static files ($COMMAND)..."