瀏覽代碼

Merge develop into develop-2.10

Jeremy Stretch 5 年之前
父節點
當前提交
08c492f1f4
共有 46 個文件被更改,包括 464 次插入117 次删除
  1. 1 1
      docs/installation/3-netbox.md
  2. 9 2
      docs/installation/upgrading.md
  3. 1 1
      docs/models/dcim/interface.md
  4. 1 0
      docs/models/ipam/ipaddress.md
  5. 3 0
      docs/plugins/development.md
  6. 64 1
      docs/release-notes/version-2.9.md
  7. 1 0
      mkdocs.yml
  8. 6 0
      netbox/dcim/choices.py
  9. 13 5
      netbox/dcim/elevations.py
  10. 17 9
      netbox/dcim/forms.py
  11. 17 0
      netbox/dcim/migrations/0115_rackreservation_order.py
  12. 5 11
      netbox/dcim/models/device_components.py
  13. 1 1
      netbox/dcim/models/devices.py
  14. 1 1
      netbox/dcim/models/racks.py
  15. 61 10
      netbox/dcim/tables.py
  16. 8 1
      netbox/dcim/views.py
  17. 1 1
      netbox/extras/api/customfields.py
  18. 1 0
      netbox/extras/api/views.py
  19. 9 1
      netbox/extras/filters.py
  20. 80 2
      netbox/extras/tests/test_filters.py
  21. 2 2
      netbox/ipam/api/views.py
  22. 2 0
      netbox/ipam/choices.py
  23. 8 1
      netbox/ipam/models.py
  24. 18 10
      netbox/ipam/tables.py
  25. 3 3
      netbox/ipam/views.py
  26. 1 2
      netbox/netbox/settings.py
  27. 4 1
      netbox/templates/500.html
  28. 16 0
      netbox/templates/dcim/device_component_edit.html
  29. 1 1
      netbox/templates/dcim/inc/consoleport.html
  30. 1 1
      netbox/templates/dcim/inc/consoleserverport.html
  31. 1 1
      netbox/templates/dcim/inc/devicebay.html
  32. 1 1
      netbox/templates/dcim/inc/poweroutlet.html
  33. 1 1
      netbox/templates/dcim/inc/powerport.html
  34. 15 0
      netbox/templates/dcim/interface_edit.html
  35. 16 12
      netbox/templates/inc/plugin_menu_items.html
  36. 1 1
      netbox/templates/ipam/inc/ipadress_edit_header.html
  37. 3 1
      netbox/templates/utilities/obj_edit.html
  38. 4 4
      netbox/templates/virtualization/inc/vminterface.html
  39. 1 1
      netbox/templates/virtualization/virtualmachine_component_add.html
  40. 20 0
      netbox/templates/virtualization/vminterface_edit.html
  41. 18 8
      netbox/users/views.py
  42. 1 1
      netbox/utilities/tables.py
  43. 4 0
      netbox/utilities/views.py
  44. 15 14
      netbox/virtualization/forms.py
  45. 3 3
      netbox/virtualization/models.py
  46. 4 1
      netbox/virtualization/tables.py

+ 1 - 1
docs/installation/3-netbox.md

@@ -25,7 +25,7 @@ Begin by installing all system packages required by NetBox and its dependencies.
 Before continuing with either platform, update pip (Python's package management tool) to its latest release:
 Before continuing with either platform, update pip (Python's package management tool) to its latest release:
 
 
 ```no-highlight
 ```no-highlight
-# pip install --upgrade pip
+# pip3 install --upgrade pip
 ```
 ```
 
 
 ## Download NetBox
 ## Download NetBox

+ 9 - 2
docs/installation/upgrading.md

@@ -4,8 +4,15 @@
 
 
 Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect.
 Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect.
 
 
-!!! note
-    Beginning with version 2.8, NetBox requires Python 3.6 or later.
+## Update Dependencies to Required Versions
+
+NetBox v2.9.0 and later requires the following:
+
+| Dependency | Minimum Version |
+|------------|-----------------|
+| Python     | 3.6             |
+| PostgreSQL | 9.6             |
+| Redis      | 4.0             |
 
 
 ## Install the Latest Code
 ## Install the Latest Code
 
 

+ 1 - 1
docs/models/dcim/interface.md

@@ -4,7 +4,7 @@ Interfaces in NetBox represent network interfaces used to exchange data with con
 
 
 Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces.
 Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces.
 
 
-Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. Like all virtual interfaces, LAG interfaces cannot be connected physically.
+Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
 
 
 IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.)
 IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.)
 
 

+ 1 - 0
docs/models/ipam/ipaddress.md

@@ -10,6 +10,7 @@ Each IP address can also be assigned an operational status and a functional role
 * Reserved
 * Reserved
 * Deprecated
 * Deprecated
 * DHCP
 * DHCP
+* SLAAC (IPv6 Stateless Address Autoconfiguration)
 
 
 Roles are used to indicate some special attribute of an IP address; for example, use as a loopback or as the the virtual IP for a VRRP group. (Note that functional roles are conceptual in nature, and thus cannot be customized by the user.) Available roles include:
 Roles are used to indicate some special attribute of an IP address; for example, use as a loopback or as the the virtual IP for a VRRP group. (Note that functional roles are conceptual in nature, and thus cannot be customized by the user.) Available roles include:
 
 

+ 3 - 0
docs/plugins/development.md

@@ -328,6 +328,9 @@ A `PluginMenuButton` has the following attributes:
 * `color` - One of the choices provided by `ButtonColorChoices` (optional)
 * `color` - One of the choices provided by `ButtonColorChoices` (optional)
 * `permissions` - A list of permissions required to display this button (optional)
 * `permissions` - A list of permissions required to display this button (optional)
 
 
+!!! note
+    Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
+
 ## Extending Core Templates
 ## Extending Core Templates
 
 
 Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:
 Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:

+ 64 - 1
docs/release-notes/version-2.9.md

@@ -1,7 +1,69 @@
 # NetBox v2.9
 # NetBox v2.9
 
 
+## v2.9.3 (2020-09-04)
+
+### Enhancements
+
+* [#4977](https://github.com/netbox-community/netbox/issues/4977) - Redirect authenticated users from login view
+* [#5048](https://github.com/netbox-community/netbox/issues/5048) - Show the device/VM name when editing a component
+* [#5072](https://github.com/netbox-community/netbox/issues/5072) - Add REST API filters for image attachments
+* [#5080](https://github.com/netbox-community/netbox/issues/5080) - Add 8P6C, 8P4C, 8P2C port types
+
+### Bug Fixes
+
+* [#5046](https://github.com/netbox-community/netbox/issues/5046) - Disabled plugin menu items are no longer clickable
+* [#5063](https://github.com/netbox-community/netbox/issues/5063) - Fix "add device" link in rack elevations for opposite side of half-depth devices
+* [#5074](https://github.com/netbox-community/netbox/issues/5074) - Fix inclusion of VC member interfaces when viewing VC master
+* [#5078](https://github.com/netbox-community/netbox/issues/5078) - Fix assignment of existing IP addresses to interfaces via web UI
+* [#5081](https://github.com/netbox-community/netbox/issues/5081) - Fix exception during webhook processing with custom select field
+* [#5085](https://github.com/netbox-community/netbox/issues/5085) - Fix ordering by assignment in IP addresses table
+* [#5087](https://github.com/netbox-community/netbox/issues/5087) - Restore label field when editing console server ports, power ports, and power outlets
+* [#5089](https://github.com/netbox-community/netbox/issues/5089) - Redirect to device view after editing component
+* [#5090](https://github.com/netbox-community/netbox/issues/5090) - Fix status display for console/power/interface connections
+* [#5091](https://github.com/netbox-community/netbox/issues/5091) - Avoid KeyError when handling invalid table preferences
+* [#5095](https://github.com/netbox-community/netbox/issues/5095) - Show assigned prefixes in VLANs list
+
+---
+
+## v2.9.2 (2020-08-27)
+
+### Enhancements
+
+* [#5055](https://github.com/netbox-community/netbox/issues/5055) - Add tags column to device/VM component list tables
+* [#5056](https://github.com/netbox-community/netbox/issues/5056) - Add interface and parent columns to IP address list
+
+### Bug Fixes
+
+* [#4988](https://github.com/netbox-community/netbox/issues/4988) - Fix ordering of rack reservations with identical creation times
+* [#5002](https://github.com/netbox-community/netbox/issues/5002) - Correct OpenAPI definition for `available-prefixes` endpoint
+* [#5035](https://github.com/netbox-community/netbox/issues/5035) - Fix exception when modifying an IP address assigned to a VM
+* [#5038](https://github.com/netbox-community/netbox/issues/5038) - Fix validation of primary IPs assigned to virtual machines
+* [#5040](https://github.com/netbox-community/netbox/issues/5040) - Limit SLAAC status to IPv6 addresses
+* [#5041](https://github.com/netbox-community/netbox/issues/5041) - Fix form tabs when assigning an IP to a VM interface
+* [#5042](https://github.com/netbox-community/netbox/issues/5042) - Fix display of SLAAC label for IP addresses status
+* [#5045](https://github.com/netbox-community/netbox/issues/5045) - Allow assignment of interfaces to non-master VC peer LAG during import
+* [#5058](https://github.com/netbox-community/netbox/issues/5058) - Correct URL for front rack elevation images when using external storage
+* [#5059](https://github.com/netbox-community/netbox/issues/5059) - Fix inclusion of checkboxes for interfaces in virtual machine view
+* [#5060](https://github.com/netbox-community/netbox/issues/5060) - Fix validation when bulk-importing child devices
+* [#5061](https://github.com/netbox-community/netbox/issues/5061) - Allow adding/removing tags when bulk editing virtual machine interfaces
+
+---
+
+## v2.9.1 (2020-08-22)
+
+### Enhancements
+
+* [#4540](https://github.com/netbox-community/netbox/issues/4540) - Add IP address status type for SLAAC
+* [#4814](https://github.com/netbox-community/netbox/issues/4814) - Allow nested LAG interfaces
+* [#4991](https://github.com/netbox-community/netbox/issues/4991) - Add Python and NetBox versions to error page
+* [#5033](https://github.com/netbox-community/netbox/issues/5033) - Support backward compatibility for `REMOTE_AUTH_BACKEND` configuration parameter
+
+---
+
 ## v2.9.0 (2020-08-21)
 ## v2.9.0 (2020-08-21)
 
 
+**Note:** Redis 4.0 or later is required for this release.
+
 ### New Features
 ### New Features
 
 
 #### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554))
 #### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554))
@@ -56,7 +118,8 @@ Two new REST API endpoints have been added to facilitate the retrieval and manip
 
 
 ### Configuration Changes
 ### Configuration Changes
 
 
-* If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
+* If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved.
+* If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
 * `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
 * `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
 
 
 ### REST API Changes
 ### REST API Changes

+ 1 - 0
mkdocs.yml

@@ -75,6 +75,7 @@ nav:
         - User Preferences: 'development/user-preferences.md'
         - User Preferences: 'development/user-preferences.md'
         - Release Checklist: 'development/release-checklist.md'
         - Release Checklist: 'development/release-checklist.md'
     - Release Notes:
     - Release Notes:
+        - Version 2.9: 'release-notes/version-2.9.md'
         - Version 2.8: 'release-notes/version-2.8.md'
         - Version 2.8: 'release-notes/version-2.8.md'
         - Version 2.7: 'release-notes/version-2.7.md'
         - Version 2.7: 'release-notes/version-2.7.md'
         - Version 2.6: 'release-notes/version-2.6.md'
         - Version 2.6: 'release-notes/version-2.6.md'

+ 6 - 0
netbox/dcim/choices.py

@@ -814,6 +814,9 @@ class InterfaceModeChoices(ChoiceSet):
 class PortTypeChoices(ChoiceSet):
 class PortTypeChoices(ChoiceSet):
 
 
     TYPE_8P8C = '8p8c'
     TYPE_8P8C = '8p8c'
+    TYPE_8P6C = '8p6c'
+    TYPE_8P4C = '8p4c'
+    TYPE_8P2C = '8p2c'
     TYPE_110_PUNCH = '110-punch'
     TYPE_110_PUNCH = '110-punch'
     TYPE_BNC = 'bnc'
     TYPE_BNC = 'bnc'
     TYPE_MRJ21 = 'mrj21'
     TYPE_MRJ21 = 'mrj21'
@@ -833,6 +836,9 @@ class PortTypeChoices(ChoiceSet):
             'Copper',
             'Copper',
             (
             (
                 (TYPE_8P8C, '8P8C'),
                 (TYPE_8P8C, '8P8C'),
+                (TYPE_8P6C, '8P6C'),
+                (TYPE_8P4C, '8P4C'),
+                (TYPE_8P2C, '8P2C'),
                 (TYPE_110_PUNCH, '110 Punch'),
                 (TYPE_110_PUNCH, '110 Punch'),
                 (TYPE_BNC, 'BNC'),
                 (TYPE_BNC, 'BNC'),
                 (TYPE_MRJ21, 'MRJ21'),
                 (TYPE_MRJ21, 'MRJ21'),

+ 13 - 5
netbox/dcim/elevations.py

@@ -94,8 +94,12 @@ class RackElevationSVG:
 
 
         # Embed front device type image if one exists
         # Embed front device type image if one exists
         if self.include_images and device.device_type.front_image:
         if self.include_images and device.device_type.front_image:
-            url = '{}{}'.format(self.base_url, device.device_type.front_image.url)
-            image = drawing.image(href=url, insert=start, size=end, class_='device-image')
+            image = drawing.image(
+                href=device.device_type.front_image.url,
+                insert=start,
+                size=end,
+                class_='device-image'
+            )
             image.fit(scale='slice')
             image.fit(scale='slice')
             link.add(image)
             link.add(image)
 
 
@@ -107,8 +111,12 @@ class RackElevationSVG:
 
 
         # Embed rear device type image if one exists
         # Embed rear device type image if one exists
         if self.include_images and device.device_type.rear_image:
         if self.include_images and device.device_type.rear_image:
-            url = device.device_type.rear_image.url
-            image = drawing.image(href=url, insert=start, size=end, class_='device-image')
+            image = drawing.image(
+                href=device.device_type.rear_image.url,
+                insert=start,
+                size=end,
+                class_='device-image'
+            )
             image.fit(scale='slice')
             image.fit(scale='slice')
             drawing.add(image)
             drawing.add(image)
 
 
@@ -141,7 +149,7 @@ class RackElevationSVG:
         unit_cursor = 0
         unit_cursor = 0
         for u in elevation:
         for u in elevation:
             o = other[unit_cursor]
             o = other[unit_cursor]
-            if not u['device'] and o['device']:
+            if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
                 u['device'] = o['device']
                 u['device'] = o['device']
                 u['height'] = 1
                 u['height'] = 1
             unit_cursor += u.get('height', 1)
             unit_cursor += u.get('height', 1)

+ 17 - 9
netbox/dcim/forms.py

@@ -1811,7 +1811,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
                     nat_inside__assigned_object_id__in=interface_ids
                     nat_inside__assigned_object_id__in=interface_ids
                 ).prefetch_related('assigned_object')
                 ).prefetch_related('assigned_object')
                 if nat_ips:
                 if nat_ips:
-                    ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in nat_ips]
+                    ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
                     ip_choices.append(('NAT IPs', ip_list))
                     ip_choices.append(('NAT IPs', ip_list))
                 self.fields['primary_ip{}'.format(family)].choices = ip_choices
                 self.fields['primary_ip{}'.format(family)].choices = ip_choices
 
 
@@ -2317,7 +2317,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
         fields = [
         fields = [
-            'device', 'name', 'type', 'description', 'tags',
+            'device', 'name', 'label', 'type', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
@@ -2390,7 +2390,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
         fields = [
         fields = [
-            'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
+            'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
@@ -2479,7 +2479,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
         fields = [
         fields = [
-            'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags',
+            'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
@@ -2686,7 +2686,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
         device_query = Q(device=device)
         device_query = Q(device=device)
         if device.virtual_chassis:
         if device.virtual_chassis:
             device_query |= Q(device__virtual_chassis=device.virtual_chassis)
             device_query |= Q(device__virtual_chassis=device.virtual_chassis)
-        self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG)
+        self.fields['lag'].queryset = Interface.objects.filter(
+            device_query,
+            type=InterfaceTypeChoices.TYPE_LAG
+        ).exclude(pk=self.instance.pk)
 
 
         # Add current site to VLANs query params
         # Add current site to VLANs query params
         self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
         self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
@@ -2876,17 +2879,22 @@ class InterfaceCSVForm(CSVModelForm):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Limit LAG choices to interfaces belonging to this device (or VC master)
+        # Limit LAG choices to interfaces belonging to this device (or virtual chassis)
         device = None
         device = None
         if self.is_bound and 'device' in self.data:
         if self.is_bound and 'device' in self.data:
             try:
             try:
                 device = self.fields['device'].to_python(self.data['device'])
                 device = self.fields['device'].to_python(self.data['device'])
             except forms.ValidationError:
             except forms.ValidationError:
                 pass
                 pass
-
-        if device:
+        if device and device.virtual_chassis:
             self.fields['lag'].queryset = Interface.objects.filter(
             self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG
+                Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis),
+                type=InterfaceTypeChoices.TYPE_LAG
+            )
+        elif device:
+            self.fields['lag'].queryset = Interface.objects.filter(
+                device=device,
+                type=InterfaceTypeChoices.TYPE_LAG
             )
             )
         else:
         else:
             self.fields['lag'].queryset = Interface.objects.none()
             self.fields['lag'].queryset = Interface.objects.none()

+ 17 - 0
netbox/dcim/migrations/0115_rackreservation_order.py

@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-08-24 16:03
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0114_update_jsonfield'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='rackreservation',
+            options={'ordering': ['created', 'pk']},
+        ),
+    ]

+ 5 - 11
netbox/dcim/models/device_components.py

@@ -702,18 +702,12 @@ class Interface(CableTermination, ComponentModel, BaseInterface):
                 })
                 })
 
 
         # A virtual interface cannot have a parent LAG
         # A virtual interface cannot have a parent LAG
-        if self.type in NONCONNECTABLE_IFACE_TYPES and self.lag is not None:
-            raise ValidationError({
-                'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_type_display())
-            })
+        if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
+            raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."})
 
 
-        # Only a LAG can have LAG members
-        if self.type != InterfaceTypeChoices.TYPE_LAG and self.member_interfaces.exists():
-            raise ValidationError({
-                'type': "Cannot change interface type; it has LAG members ({}).".format(
-                    ", ".join([iface.name for iface in self.member_interfaces.all()])
-                )
-            })
+        # A LAG interface cannot be its own parent
+        if self.pk and self.lag_id == self.pk:
+            raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
 
 
         # Validate untagged VLAN
         # Validate untagged VLAN
         if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
         if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:

+ 1 - 1
netbox/dcim/models/devices.py

@@ -633,7 +633,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
         # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
         # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
         # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
         # of the uniqueness constraint without manual intervention.
         # of the uniqueness constraint without manual intervention.
-        if self.name and self.tenant is None:
+        if self.name and hasattr(self, 'site') and self.tenant is None:
             if Device.objects.exclude(pk=self.pk).filter(
             if Device.objects.exclude(pk=self.pk).filter(
                     name=self.name,
                     name=self.name,
                     site=self.site,
                     site=self.site,

+ 1 - 1
netbox/dcim/models/racks.py

@@ -600,7 +600,7 @@ class RackReservation(ChangeLoggedModel):
     csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
     csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
 
 
     class Meta:
     class Meta:
-        ordering = ['created']
+        ordering = ['created', 'pk']
 
 
     def __str__(self):
     def __str__(self):
         return "Reservation for rack {}".format(self.rack)
         return "Reservation for rack {}".format(self.rack)

+ 61 - 10
netbox/dcim/tables.py

@@ -152,6 +152,10 @@ INTERFACE_TAGGED_VLANS = """
 {% endfor %}
 {% endfor %}
 """
 """
 
 
+CONNECTION_STATUS = """
+<span class="label label-{% if record.connection_status %}success{% else %}danger{% endif %}">{{ record.get_connection_status_display }}</span>
+"""
+
 
 
 #
 #
 # Regions
 # Regions
@@ -706,34 +710,48 @@ class DeviceComponentTable(BaseTable):
 
 
 
 
 class ConsolePortTable(DeviceComponentTable):
 class ConsolePortTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:consoleport_list'
+    )
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsolePort
         model = ConsolePort
-        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
+        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
 
 
 
 
 class ConsoleServerPortTable(DeviceComponentTable):
 class ConsoleServerPortTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:consoleserverport_list'
+    )
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsoleServerPort
         model = ConsoleServerPort
-        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
+        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
 
 
 
 
 class PowerPortTable(DeviceComponentTable):
 class PowerPortTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:powerport_list'
+    )
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = PowerPort
         model = PowerPort
-        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable')
+        fields = (
+            'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'tags',
+        )
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
 
 
 
 
 class PowerOutletTable(DeviceComponentTable):
 class PowerOutletTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:poweroutlet_list'
+    )
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = PowerOutlet
         model = PowerOutlet
-        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable')
+        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'tags')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
 
 
 
 
@@ -753,12 +771,15 @@ class BaseInterfaceTable(BaseTable):
 
 
 
 
 class InterfaceTable(DeviceComponentTable, BaseInterfaceTable):
 class InterfaceTable(DeviceComponentTable, BaseInterfaceTable):
+    tags = TagColumn(
+        url_name='dcim:interface_list'
+    )
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = Interface
         model = Interface
         fields = (
         fields = (
             'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
             'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
-            'description', 'cable', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
+            'description', 'cable', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
         )
         )
         default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
 
 
@@ -767,18 +788,26 @@ class FrontPortTable(DeviceComponentTable):
     rear_port_position = tables.Column(
     rear_port_position = tables.Column(
         verbose_name='Position'
         verbose_name='Position'
     )
     )
+    tags = TagColumn(
+        url_name='dcim:frontport_list'
+    )
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = FrontPort
         model = FrontPort
-        fields = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable')
+        fields = (
+            'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags',
+        )
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
 
 
 
 
 class RearPortTable(DeviceComponentTable):
 class RearPortTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:rearport_list'
+    )
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = RearPort
         model = RearPort
-        fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable')
+        fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
 
 
 
 
@@ -786,10 +815,13 @@ class DeviceBayTable(DeviceComponentTable):
     installed_device = tables.Column(
     installed_device = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    tags = TagColumn(
+        url_name='dcim:devicebay_list'
+    )
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = DeviceBay
         model = DeviceBay
-        fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
+        fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description', 'tags')
         default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
 
 
 
 
@@ -798,12 +830,16 @@ class InventoryItemTable(DeviceComponentTable):
         linkify=True
         linkify=True
     )
     )
     discovered = BooleanColumn()
     discovered = BooleanColumn()
+    tags = TagColumn(
+        url_name='dcim:inventoryitem_list'
+    )
+    cable = None  # Override DeviceComponentTable
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = InventoryItem
         model = InventoryItem
         fields = (
         fields = (
             'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
             'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
-            'discovered',
+            'discovered', 'tags',
         )
         )
         default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
         default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
 
 
@@ -876,15 +912,20 @@ class ConsoleConnectionTable(BaseTable):
         verbose_name='Console Server'
         verbose_name='Console Server'
     )
     )
     connected_endpoint = tables.Column(
     connected_endpoint = tables.Column(
+        linkify=True,
         verbose_name='Port'
         verbose_name='Port'
     )
     )
     device = tables.Column(
     device = tables.Column(
         linkify=True
         linkify=True
     )
     )
     name = tables.Column(
     name = tables.Column(
+        linkify=True,
         verbose_name='Console Port'
         verbose_name='Console Port'
     )
     )
-    connection_status = BooleanColumn()
+    connection_status = tables.TemplateColumn(
+        template_code=CONNECTION_STATUS,
+        verbose_name='Status'
+    )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ConsolePort
         model = ConsolePort
@@ -901,14 +942,20 @@ class PowerConnectionTable(BaseTable):
     )
     )
     outlet = tables.Column(
     outlet = tables.Column(
         accessor=Accessor('_connected_poweroutlet'),
         accessor=Accessor('_connected_poweroutlet'),
+        linkify=True,
         verbose_name='Outlet'
         verbose_name='Outlet'
     )
     )
     device = tables.Column(
     device = tables.Column(
         linkify=True
         linkify=True
     )
     )
     name = tables.Column(
     name = tables.Column(
+        linkify=True,
         verbose_name='Power Port'
         verbose_name='Power Port'
     )
     )
+    connection_status = tables.TemplateColumn(
+        template_code=CONNECTION_STATUS,
+        verbose_name='Status'
+    )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = PowerPort
         model = PowerPort
@@ -940,6 +987,10 @@ class InterfaceConnectionTable(BaseTable):
         args=[Accessor('_connected_interface__pk')],
         args=[Accessor('_connected_interface__pk')],
         verbose_name='Interface B'
         verbose_name='Interface B'
     )
     )
+    connection_status = tables.TemplateColumn(
+        template_code=CONNECTION_STATUS,
+        verbose_name='Status'
+    )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Interface
         model = Interface

+ 8 - 1
netbox/dcim/views.py

@@ -1030,7 +1030,7 @@ class DeviceView(ObjectView):
         )
         )
 
 
         # Interfaces
         # Interfaces
-        interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related(
+        interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related(
             Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
             Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
             Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
             Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
             'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
             'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
@@ -1228,6 +1228,7 @@ class ConsolePortCreateView(ComponentCreateView):
 class ConsolePortEditView(ObjectEditView):
 class ConsolePortEditView(ObjectEditView):
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
     model_form = forms.ConsolePortForm
     model_form = forms.ConsolePortForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 
 
 class ConsolePortDeleteView(ObjectDeleteView):
 class ConsolePortDeleteView(ObjectDeleteView):
@@ -1287,6 +1288,7 @@ class ConsoleServerPortCreateView(ComponentCreateView):
 class ConsoleServerPortEditView(ObjectEditView):
 class ConsoleServerPortEditView(ObjectEditView):
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
     model_form = forms.ConsoleServerPortForm
     model_form = forms.ConsoleServerPortForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 
 
 class ConsoleServerPortDeleteView(ObjectDeleteView):
 class ConsoleServerPortDeleteView(ObjectDeleteView):
@@ -1346,6 +1348,7 @@ class PowerPortCreateView(ComponentCreateView):
 class PowerPortEditView(ObjectEditView):
 class PowerPortEditView(ObjectEditView):
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
     model_form = forms.PowerPortForm
     model_form = forms.PowerPortForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 
 
 class PowerPortDeleteView(ObjectDeleteView):
 class PowerPortDeleteView(ObjectDeleteView):
@@ -1405,6 +1408,7 @@ class PowerOutletCreateView(ComponentCreateView):
 class PowerOutletEditView(ObjectEditView):
 class PowerOutletEditView(ObjectEditView):
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
     model_form = forms.PowerOutletForm
     model_form = forms.PowerOutletForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 
 
 class PowerOutletDeleteView(ObjectDeleteView):
 class PowerOutletDeleteView(ObjectDeleteView):
@@ -1556,6 +1560,7 @@ class FrontPortCreateView(ComponentCreateView):
 class FrontPortEditView(ObjectEditView):
 class FrontPortEditView(ObjectEditView):
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
     model_form = forms.FrontPortForm
     model_form = forms.FrontPortForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 
 
 class FrontPortDeleteView(ObjectDeleteView):
 class FrontPortDeleteView(ObjectDeleteView):
@@ -1615,6 +1620,7 @@ class RearPortCreateView(ComponentCreateView):
 class RearPortEditView(ObjectEditView):
 class RearPortEditView(ObjectEditView):
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
     model_form = forms.RearPortForm
     model_form = forms.RearPortForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 
 
 class RearPortDeleteView(ObjectDeleteView):
 class RearPortDeleteView(ObjectDeleteView):
@@ -1674,6 +1680,7 @@ class DeviceBayCreateView(ComponentCreateView):
 class DeviceBayEditView(ObjectEditView):
 class DeviceBayEditView(ObjectEditView):
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
     model_form = forms.DeviceBayForm
     model_form = forms.DeviceBayForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 
 
 class DeviceBayDeleteView(ObjectDeleteView):
 class DeviceBayDeleteView(ObjectDeleteView):

+ 1 - 1
netbox/extras/api/customfields.py

@@ -158,7 +158,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
         instance.custom_fields = {}
         instance.custom_fields = {}
         for field in custom_fields:
         for field in custom_fields:
             value = instance.cf.get(field.name)
             value = instance.cf.get(field.name)
-            if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None:
+            if field.type == CustomFieldTypeChoices.TYPE_SELECT and value:
                 instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
                 instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
             else:
             else:
                 instance.custom_fields[field.name] = value
                 instance.custom_fields[field.name] = value

+ 1 - 0
netbox/extras/api/views.py

@@ -129,6 +129,7 @@ class ImageAttachmentViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
     queryset = ImageAttachment.objects.all()
     queryset = ImageAttachment.objects.all()
     serializer_class = serializers.ImageAttachmentSerializer
     serializer_class = serializers.ImageAttachmentSerializer
+    filterset_class = filters.ImageAttachmentFilterSet
 
 
 
 
 #
 #

+ 9 - 1
netbox/extras/filters.py

@@ -7,7 +7,7 @@ from tenancy.models import Tenant, TenantGroup
 from utilities.filters import BaseFilterSet
 from utilities.filters import BaseFilterSet
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .choices import *
-from .models import ConfigContext, CustomField, ExportTemplate, ObjectChange, JobResult, Tag
+from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag
 
 
 
 
 __all__ = (
 __all__ = (
@@ -16,6 +16,7 @@ __all__ = (
     'CustomFieldFilter',
     'CustomFieldFilter',
     'CustomFieldFilterSet',
     'CustomFieldFilterSet',
     'ExportTemplateFilterSet',
     'ExportTemplateFilterSet',
+    'ImageAttachmentFilterSet',
     'LocalConfigContextFilterSet',
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
     'ObjectChangeFilterSet',
     'TagFilterSet',
     'TagFilterSet',
@@ -96,6 +97,13 @@ class ExportTemplateFilterSet(BaseFilterSet):
         fields = ['id', 'content_type', 'name']
         fields = ['id', 'content_type', 'name']
 
 
 
 
+class ImageAttachmentFilterSet(BaseFilterSet):
+
+    class Meta:
+        model = ImageAttachment
+        fields = ['id', 'content_type', 'object_id', 'name']
+
+
 class TagFilterSet(BaseFilterSet):
 class TagFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',

+ 80 - 2
netbox/extras/tests/test_filters.py

@@ -1,9 +1,9 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.test import TestCase
 
 
-from dcim.models import DeviceRole, Platform, Region, Site
+from dcim.models import DeviceRole, Platform, Rack, Region, Site
 from extras.filters import *
 from extras.filters import *
-from extras.models import ConfigContext, ExportTemplate, Tag
+from extras.models import ConfigContext, ExportTemplate, ImageAttachment, Tag
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
@@ -37,6 +37,84 @@ class ExportTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
+class ImageAttachmentTestCase(TestCase):
+    queryset = ImageAttachment.objects.all()
+    filterset = ImageAttachmentFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        site_ct = ContentType.objects.get(app_label='dcim', model='site')
+        rack_ct = ContentType.objects.get(app_label='dcim', model='rack')
+
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
+
+        racks = (
+            Rack(name='Rack 1', site=sites[0]),
+            Rack(name='Rack 2', site=sites[1]),
+        )
+        Rack.objects.bulk_create(racks)
+
+        image_attachments = (
+            ImageAttachment(
+                content_type=site_ct,
+                object_id=sites[0].pk,
+                name='Image Attachment 1',
+                image='http://example.com/image1.png',
+                image_height=100,
+                image_width=100
+            ),
+            ImageAttachment(
+                content_type=site_ct,
+                object_id=sites[1].pk,
+                name='Image Attachment 2',
+                image='http://example.com/image2.png',
+                image_height=100,
+                image_width=100
+            ),
+            ImageAttachment(
+                content_type=rack_ct,
+                object_id=racks[0].pk,
+                name='Image Attachment 3',
+                image='http://example.com/image3.png',
+                image_height=100,
+                image_width=100
+            ),
+            ImageAttachment(
+                content_type=rack_ct,
+                object_id=racks[1].pk,
+                name='Image Attachment 4',
+                image='http://example.com/image4.png',
+                image_height=100,
+                image_width=100
+            )
+        )
+        ImageAttachment.objects.bulk_create(image_attachments)
+
+    def test_id(self):
+        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_name(self):
+        params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_content_type(self):
+        params = {'content_type': ContentType.objects.get(app_label='dcim', model='site').pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_content_type_and_object_id(self):
+        params = {
+            'content_type': ContentType.objects.get(app_label='dcim', model='site').pk,
+            'object_id': [Site.objects.first().pk],
+        }
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+
 class ConfigContextTestCase(TestCase):
 class ConfigContextTestCase(TestCase):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
     filterset = ConfigContextFilterSet
     filterset = ConfigContextFilterSet

+ 2 - 2
netbox/ipam/api/views.py

@@ -88,7 +88,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
         return super().get_serializer_class()
         return super().get_serializer_class()
 
 
     @swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
     @swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
-    @swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
+    @swagger_auto_schema(method='post', responses={201: serializers.PrefixSerializer(many=False)})
     @action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
     @action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
     @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
     @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
     def available_prefixes(self, request, pk=None):
     def available_prefixes(self, request, pk=None):
@@ -247,7 +247,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
 
 
 class IPAddressViewSet(CustomFieldModelViewSet):
 class IPAddressViewSet(CustomFieldModelViewSet):
     queryset = IPAddress.objects.prefetch_related(
     queryset = IPAddress.objects.prefetch_related(
-        'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags',
+        'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
     )
     )
     serializer_class = serializers.IPAddressSerializer
     serializer_class = serializers.IPAddressSerializer
     filterset_class = filters.IPAddressFilterSet
     filterset_class = filters.IPAddressFilterSet

+ 2 - 0
netbox/ipam/choices.py

@@ -41,12 +41,14 @@ class IPAddressStatusChoices(ChoiceSet):
     STATUS_RESERVED = 'reserved'
     STATUS_RESERVED = 'reserved'
     STATUS_DEPRECATED = 'deprecated'
     STATUS_DEPRECATED = 'deprecated'
     STATUS_DHCP = 'dhcp'
     STATUS_DHCP = 'dhcp'
+    STATUS_SLAAC = 'slaac'
 
 
     CHOICES = (
     CHOICES = (
         (STATUS_ACTIVE, 'Active'),
         (STATUS_ACTIVE, 'Active'),
         (STATUS_RESERVED, 'Reserved'),
         (STATUS_RESERVED, 'Reserved'),
         (STATUS_DEPRECATED, 'Deprecated'),
         (STATUS_DEPRECATED, 'Deprecated'),
         (STATUS_DHCP, 'DHCP'),
         (STATUS_DHCP, 'DHCP'),
+        (STATUS_SLAAC, 'SLAAC'),
     )
     )
 
 
 
 

+ 8 - 1
netbox/ipam/models.py

@@ -669,6 +669,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
         'reserved': 'info',
         'reserved': 'info',
         'deprecated': 'danger',
         'deprecated': 'danger',
         'dhcp': 'success',
         'dhcp': 'success',
+        'slaac': 'success',
     }
     }
 
 
     ROLE_CLASS_MAP = {
     ROLE_CLASS_MAP = {
@@ -745,12 +746,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
                         'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
                         'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
                                        f"interface"
                                        f"interface"
                     })
                     })
-                elif self.interface.virtual_machine != vm:
+                elif self.assigned_object.virtual_machine != vm:
                     raise ValidationError({
                     raise ValidationError({
                         'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
                         'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
                                        f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
                                        f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
                     })
                     })
 
 
+        # Validate IP status selection
+        if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
+            raise ValidationError({
+                'status': "Only IPv6 addresses can be assigned SLAAC status"
+            })
+
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 
         # Force dns_name to lowercase
         # Force dns_name to lowercase

+ 18 - 10
netbox/ipam/tables.py

@@ -67,11 +67,7 @@ IPADDRESS_LINK = """
 """
 """
 
 
 IPADDRESS_ASSIGN_LINK = """
 IPADDRESS_ASSIGN_LINK = """
-{% if request.GET %}
-    <a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
-{% else %}
-    <a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
-{% endif %}
+<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
 """
 """
 
 
 VRF_LINK = """
 VRF_LINK = """
@@ -103,7 +99,7 @@ VLAN_LINK = """
 """
 """
 
 
 VLAN_PREFIXES = """
 VLAN_PREFIXES = """
-{% for prefix in record.prefixes.unrestricted %}
+{% for prefix in record.prefixes.all %}
     <a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
     <a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
 {% empty %}
 {% empty %}
     &mdash;
     &mdash;
@@ -387,15 +383,23 @@ class IPAddressTable(BaseTable):
     tenant = tables.TemplateColumn(
     tenant = tables.TemplateColumn(
         template_code=TENANT_LINK
         template_code=TENANT_LINK
     )
     )
-    assigned = tables.BooleanColumn(
-        accessor='assigned_object_id',
-        verbose_name='Assigned'
+    assigned_object = tables.Column(
+        linkify=True,
+        orderable=False,
+        verbose_name='Interface'
+    )
+    assigned_object_parent = tables.Column(
+        accessor='assigned_object__parent',
+        linkify=True,
+        orderable=False,
+        verbose_name='Interface Parent'
     )
     )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = IPAddress
         model = IPAddress
         fields = (
         fields = (
-            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'assigned_object_parent', 'dns_name',
+            'description',
         )
         )
         row_attrs = {
         row_attrs = {
             'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
             'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
@@ -411,6 +415,10 @@ class IPAddressDetailTable(IPAddressTable):
     tenant = tables.TemplateColumn(
     tenant = tables.TemplateColumn(
         template_code=COL_TENANT
         template_code=COL_TENANT
     )
     )
+    assigned = tables.BooleanColumn(
+        accessor='assigned_object_id',
+        verbose_name='Assigned'
+    )
     tags = TagColumn(
     tags = TagColumn(
         url_name='ipam:ipaddress_list'
         url_name='ipam:ipaddress_list'
     )
     )

+ 3 - 3
netbox/ipam/views.py

@@ -493,7 +493,7 @@ class PrefixBulkDeleteView(BulkDeleteView):
 
 
 class IPAddressListView(ObjectListView):
 class IPAddressListView(ObjectListView):
     queryset = IPAddress.objects.prefetch_related(
     queryset = IPAddress.objects.prefetch_related(
-        'vrf__tenant', 'tenant', 'nat_inside'
+        'vrf__tenant', 'tenant', 'nat_inside', 'assigned_object'
     )
     )
     filterset = filters.IPAddressFilterSet
     filterset = filters.IPAddressFilterSet
     filterset_form = forms.IPAddressFilterForm
     filterset_form = forms.IPAddressFilterForm
@@ -582,7 +582,7 @@ class IPAddressAssignView(ObjectView):
     def dispatch(self, request, *args, **kwargs):
     def dispatch(self, request, *args, **kwargs):
 
 
         # Redirect user if an interface has not been provided
         # Redirect user if an interface has not been provided
-        if 'interface' not in request.GET:
+        if 'interface' not in request.GET and 'vminterface' not in request.GET:
             return redirect('ipam:ipaddress_add')
             return redirect('ipam:ipaddress_add')
 
 
         return super().dispatch(request, *args, **kwargs)
         return super().dispatch(request, *args, **kwargs)
@@ -609,7 +609,7 @@ class IPAddressAssignView(ObjectView):
         return render(request, 'ipam/ipaddress_assign.html', {
         return render(request, 'ipam/ipaddress_assign.html', {
             'form': form,
             'form': form,
             'table': table,
             'table': table,
-            'return_url': request.GET.get('return_url', ''),
+            'return_url': request.GET.get('return_url'),
         })
         })
 
 
 
 

+ 1 - 2
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '2.9.1-dev'
+VERSION = '2.9.4-dev'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()
@@ -132,7 +132,6 @@ if RELEASE_CHECK_URL:
 if RELEASE_CHECK_TIMEOUT < 3600:
 if RELEASE_CHECK_TIMEOUT < 3600:
     raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)")
     raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)")
 
 
-
 #
 #
 # Database
 # Database
 #
 #

+ 4 - 1
netbox/templates/500.html

@@ -31,7 +31,10 @@
                             The complete exception is provided below:
                             The complete exception is provided below:
                         </p>
                         </p>
 <pre><strong>{{ exception }}</strong><br />
 <pre><strong>{{ exception }}</strong><br />
-{{ error }}</pre>
+{{ error }}
+
+Python version: {{ python_version }}
+NetBox version: {{ netbox_version }}</pre>
                         <p>
                         <p>
                             If further assistance is required, please post to the <a href="https://groups.google.com/forum/#!forum/netbox-discuss">NetBox mailing list</a>.
                             If further assistance is required, please post to the <a href="https://groups.google.com/forum/#!forum/netbox-discuss">NetBox mailing list</a>.
                         </p>
                         </p>

+ 16 - 0
netbox/templates/dcim/device_component_edit.html

@@ -0,0 +1,16 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form_fields %}
+    {% if form.instance.device %}
+        <div class="form-group">
+            <label class="col-md-3 control-label required" for="id_device">Device</label>
+            <div class="col-md-9">
+                <p class="form-control-static">
+                    <a href="{{ form.instance.device.get_absolute_url }}">{{ form.instance.device }}</a>
+                </p>
+            </div>
+        </div>
+    {% endif %}
+    {% render_form form %}
+{% endblock %}

+ 1 - 1
netbox/templates/dcim/inc/consoleport.html

@@ -66,7 +66,7 @@
             </span>
             </span>
         {% endif %}
         {% endif %}
         {% if perms.dcim.change_consoleport %}
         {% if perms.dcim.change_consoleport %}
-            <a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}" title="Edit port" class="btn btn-info btn-xs">
+            <a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
             </a>
         {% endif %}
         {% endif %}

+ 1 - 1
netbox/templates/dcim/inc/consoleserverport.html

@@ -68,7 +68,7 @@
             </span>
             </span>
         {% endif %}
         {% endif %}
         {% if perms.dcim.change_consoleserverport %}
         {% if perms.dcim.change_consoleserverport %}
-            <a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}" title="Edit port" class="btn btn-info btn-xs">
+            <a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
             </a>
         {% endif %}
         {% endif %}

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

@@ -52,7 +52,7 @@
                     <i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i>
                     <i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i>
                 </a>
                 </a>
             {% endif %}
             {% endif %}
-            <a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}" class="btn btn-info btn-xs">
+            <a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i>
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i>
             </a>
             </a>
         {% endif %}
         {% endif %}

+ 1 - 1
netbox/templates/dcim/inc/poweroutlet.html

@@ -81,7 +81,7 @@
             </a>
             </a>
         {% endif %}
         {% endif %}
         {% if perms.dcim.change_poweroutlet %}
         {% if perms.dcim.change_poweroutlet %}
-            <a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}" title="Edit outlet" class="btn btn-info btn-xs">
+            <a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}?return_url={{ device.get_absolute_url }}" title="Edit outlet" class="btn btn-info btn-xs">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
             </a>
         {% endif %}
         {% endif %}

+ 1 - 1
netbox/templates/dcim/inc/powerport.html

@@ -78,7 +78,7 @@
             </span>
             </span>
         {% endif %}
         {% endif %}
         {% if perms.dcim.change_powerport %}
         {% if perms.dcim.change_powerport %}
-            <a href="{% url 'dcim:powerport_edit' pk=pp.pk %}" title="Edit port" class="btn btn-info btn-xs">
+            <a href="{% url 'dcim:powerport_edit' pk=pp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
             </a>
         {% endif %}
         {% endif %}

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

@@ -5,6 +5,16 @@
     <div class="panel panel-default">
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Interface</strong></div>
         <div class="panel-heading"><strong>Interface</strong></div>
         <div class="panel-body">
         <div class="panel-body">
+            {% if form.instance.device %}
+                <div class="form-group">
+                    <label class="col-md-3 control-label required" for="id_device">Device</label>
+                    <div class="col-md-9">
+                        <p class="form-control-static">
+                            <a href="{{ form.instance.device.get_absolute_url }}">{{ form.instance.device }}</a>
+                        </p>
+                    </div>
+                </div>
+            {% endif %}
             {% render_field form.name %}
             {% render_field form.name %}
             {% render_field form.label %}
             {% render_field form.label %}
             {% render_field form.type %}
             {% render_field form.type %}
@@ -14,6 +24,11 @@
             {% render_field form.mtu %}
             {% render_field form.mtu %}
             {% render_field form.mgmt_only %}
             {% render_field form.mgmt_only %}
             {% render_field form.description %}
             {% render_field form.description %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>802.1Q Switching</strong></div>
+        <div class="panel-body">
             {% render_field form.mode %}
             {% render_field form.mode %}
             {% render_field form.untagged_vlan %}
             {% render_field form.untagged_vlan %}
             {% render_field form.tagged_vlans %}
             {% render_field form.tagged_vlans %}

+ 16 - 12
netbox/templates/inc/plugin_menu_items.html

@@ -5,18 +5,22 @@
         {% for section_name, menu_items in registry.plugin_menu_items.items %}
         {% for section_name, menu_items in registry.plugin_menu_items.items %}
             <li class="dropdown-header">{{ section_name }}</li>
             <li class="dropdown-header">{{ section_name }}</li>
             {% for menu_item in menu_items %}
             {% for menu_item in menu_items %}
-                <li{% if menu_item.permissions and not request.user|has_perms:menu_item.permissions %} class="disabled"{% endif %}>
-                    {% if menu_item.buttons %}
-                        <div class="buttons pull-right">
-                            {% for button in menu_item.buttons %}
-                                {% if not button.permissions or request.user|has_perms:button.permissions %}
-                                    <a href="{% url button.link %}" class="btn btn-xs btn-{{ button.color }}" title="{{ button.title }}"><i class="{{ button.icon_class }}"></i></a>
-                                {% endif %}
-                            {% endfor %}
-                        </div>
-                    {% endif %}
-                    <a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a>
-                </li>
+                {% if not menu_item.permissions or request.user|has_perms:menu_item.permissions %}
+                    <li>
+                        {% if menu_item.buttons %}
+                            <div class="buttons pull-right">
+                                {% for button in menu_item.buttons %}
+                                    {% if not button.permissions or request.user|has_perms:button.permissions %}
+                                        <a href="{% url button.link %}" class="btn btn-xs btn-{{ button.color }}" title="{{ button.title }}"><i class="{{ button.icon_class }}"></i></a>
+                                    {% endif %}
+                                {% endfor %}
+                            </div>
+                        {% endif %}
+                        <a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a>
+                    </li>
+                {% else %}
+                    <li class="disabled"><a href="#">{{ menu_item.link_text }}</a></li>
+                {% endif %}
             {% endfor %}
             {% endfor %}
             {% if not forloop.last %}
             {% if not forloop.last %}
                 <li class="divider"></li>
                 <li class="divider"></li>

+ 1 - 1
netbox/templates/ipam/inc/ipadress_edit_header.html

@@ -4,7 +4,7 @@
     <li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}>
     <li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}>
         <a href="{% url 'ipam:ipaddress_add' %}{% querystring request %}">New IP</a>
         <a href="{% url 'ipam:ipaddress_add' %}{% querystring request %}">New IP</a>
     </li>
     </li>
-    {% if 'interface' in request.GET %}
+    {% if 'interface' in request.GET or 'vminterface' in request.GET %}
         <li role="presentation"{% if active_tab == 'assign' %} class="active"{% endif %}>
         <li role="presentation"{% if active_tab == 'assign' %} class="active"{% endif %}>
             <a href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}">Assign IP</a>
             <a href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}">Assign IP</a>
         </li>
         </li>

+ 3 - 1
netbox/templates/utilities/obj_edit.html

@@ -31,7 +31,9 @@
                     <div class="panel panel-default">
                     <div class="panel panel-default">
                         <div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
                         <div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
                         <div class="panel-body">
                         <div class="panel-body">
-                            {% render_form form %}
+                            {% block form_fields %}
+                                {% render_form form %}
+                            {% endblock %}
                         </div>
                         </div>
                     </div>
                     </div>
                 {% endblock %}
                 {% endblock %}

+ 4 - 4
netbox/templates/virtualization/inc/vminterface.html

@@ -2,7 +2,7 @@
 <tr class="interface{% if not iface.enabled %} danger{% endif %}" id="interface_{{ iface.name }}">
 <tr class="interface{% if not iface.enabled %} danger{% endif %}" id="interface_{{ iface.name }}">
 
 
     {# Checkbox #}
     {# Checkbox #}
-    {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %}
+    {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
         <td class="pk">
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ iface.pk }}" />
             <input name="pk" type="checkbox" value="{{ iface.pk }}" />
         </td>
         </td>
@@ -43,12 +43,12 @@
                 <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
                 <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
             </a>
             </a>
         {% endif %}
         {% endif %}
-        {% if perms.virtualization.change_interface %}
+        {% if perms.virtualization.change_vminterface %}
             <a href="{% url 'virtualization:vminterface_edit' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
             <a href="{% url 'virtualization:vminterface_edit' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
             </a>
         {% endif %}
         {% endif %}
-        {% if perms.virtualization.delete_interface %}
+        {% if perms.virtualization.delete_vminterface %}
             <a href="{% url 'virtualization:vminterface_delete' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
             <a href="{% url 'virtualization:vminterface_delete' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
                 <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
             </a>
             </a>
@@ -60,7 +60,7 @@
     {% if ipaddresses %}
     {% if ipaddresses %}
         <tr class="ipaddresses">
         <tr class="ipaddresses">
             {# Placeholder #}
             {# Placeholder #}
-            {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %}
+            {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
                 <td></td>
                 <td></td>
             {% endif %}
             {% endif %}
 
 

+ 1 - 1
netbox/templates/virtualization/virtualmachine_component_add.html

@@ -2,7 +2,7 @@
 {% load helpers %}
 {% load helpers %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
-{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
+{% block title %}Create {{ component_type }}{% endblock %}
 
 
 {% block content %}
 {% block content %}
 <form action="" method="post" class="form form-horizontal">
 <form action="" method="post" class="form form-horizontal">

+ 20 - 0
netbox/templates/virtualization/vminterface_edit.html

@@ -5,14 +5,34 @@
     <div class="panel panel-default">
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Interface</strong></div>
         <div class="panel-heading"><strong>Interface</strong></div>
         <div class="panel-body">
         <div class="panel-body">
+            {% if form.instance.virtual_machine %}
+                <div class="form-group">
+                    <label class="col-md-3 control-label required" for="id_device">Virtual Machine</label>
+                    <div class="col-md-9">
+                        <p class="form-control-static">
+                            <a href="{{ form.instance.virtual_machine.get_absolute_url }}">{{ form.instance.virtual_machine }}</a>
+                        </p>
+                    </div>
+                </div>
+            {% endif %}
             {% render_field form.name %}
             {% render_field form.name %}
             {% render_field form.enabled %}
             {% render_field form.enabled %}
             {% render_field form.mac_address %}
             {% render_field form.mac_address %}
             {% render_field form.mtu %}
             {% render_field form.mtu %}
             {% render_field form.description %}
             {% render_field form.description %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>802.1Q Switching</strong></div>
+        <div class="panel-body">
             {% render_field form.mode %}
             {% render_field form.mode %}
             {% render_field form.untagged_vlan %}
             {% render_field form.untagged_vlan %}
             {% render_field form.tagged_vlans %}
             {% render_field form.tagged_vlans %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
             {% render_field form.tags %}
             {% render_field form.tags %}
         </div>
         </div>
     </div>
     </div>

+ 18 - 8
netbox/users/views.py

@@ -38,6 +38,10 @@ class LoginView(View):
     def get(self, request):
     def get(self, request):
         form = LoginForm(request)
         form = LoginForm(request)
 
 
+        if request.user.is_authenticated:
+            logger = logging.getLogger('netbox.auth.login')
+            return self.redirect_to_next(request, logger)
+
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'form': form,
             'form': form,
         })
         })
@@ -49,12 +53,6 @@ class LoginView(View):
         if form.is_valid():
         if form.is_valid():
             logger.debug("Login form validation was successful")
             logger.debug("Login form validation was successful")
 
 
-            # Determine where to direct user after successful login
-            redirect_to = request.POST.get('next', reverse('home'))
-            if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
-                logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
-                redirect_to = reverse('home')
-
             # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
             # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
             # last_login time upon authentication.
             # last_login time upon authentication.
             if settings.MAINTENANCE_MODE:
             if settings.MAINTENANCE_MODE:
@@ -66,8 +64,7 @@ class LoginView(View):
             logger.info(f"User {request.user} successfully authenticated")
             logger.info(f"User {request.user} successfully authenticated")
             messages.info(request, "Logged in as {}.".format(request.user))
             messages.info(request, "Logged in as {}.".format(request.user))
 
 
-            logger.debug(f"Redirecting user to {redirect_to}")
-            return HttpResponseRedirect(redirect_to)
+            return self.redirect_to_next(request, logger)
 
 
         else:
         else:
             logger.debug("Login form validation failed")
             logger.debug("Login form validation failed")
@@ -76,6 +73,19 @@ class LoginView(View):
             'form': form,
             'form': form,
         })
         })
 
 
+    def redirect_to_next(self, request, logger):
+        if request.method == "POST":
+            redirect_to = request.POST.get('next', reverse('home'))
+        else:
+            redirect_to = request.GET.get('next', reverse('home'))
+
+        if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
+            logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
+            redirect_to = reverse('home')
+
+        logger.debug(f"Redirecting user to {redirect_to}")
+        return HttpResponseRedirect(redirect_to)
+
 
 
 class LogoutView(View):
 class LogoutView(View):
     """
     """

+ 1 - 1
netbox/utilities/tables.py

@@ -44,7 +44,7 @@ class BaseTable(tables.Table):
                     self.columns.show(name)
                     self.columns.show(name)
                 else:
                 else:
                     self.columns.hide(name)
                     self.columns.hide(name)
-            self.sequence = columns
+            self.sequence = [c for c in columns if c in self.base_columns]
 
 
             # Always include PK and actions column, if defined on the table
             # Always include PK and actions column, if defined on the table
             if pk:
             if pk:

+ 4 - 0
netbox/utilities/views.py

@@ -1,8 +1,10 @@
 import logging
 import logging
+import platform
 import re
 import re
 import sys
 import sys
 from copy import deepcopy
 from copy import deepcopy
 
 
+from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.decorators import login_required
 from django.contrib.auth.decorators import login_required
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -1421,6 +1423,8 @@ def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
     type_, error, traceback = sys.exc_info()
     type_, error, traceback = sys.exc_info()
 
 
     return HttpResponseServerError(template.render({
     return HttpResponseServerError(template.render({
+        'python_version': platform.python_version(),
+        'netbox_version': settings.VERSION,
         'exception': str(type_),
         'exception': str(type_),
         'error': error,
         'error': error,
     }))
     }))

+ 15 - 14
netbox/virtualization/forms.py

@@ -1,4 +1,5 @@
 from django import forms
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 
 
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
@@ -325,28 +326,28 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             # Compile list of choices for primary IPv4 and IPv6 addresses
             # Compile list of choices for primary IPv4 and IPv6 addresses
             for family in [4, 6]:
             for family in [4, 6]:
                 ip_choices = [(None, '---------')]
                 ip_choices = [(None, '---------')]
+
+                # Gather PKs of all interfaces belonging to this VM
+                interface_ids = self.instance.interfaces.values_list('pk', flat=True)
+
                 # Collect interface IPs
                 # Collect interface IPs
-                interface_ips = IPAddress.objects.prefetch_related('interface').filter(
+                interface_ips = IPAddress.objects.filter(
                     address__family=family,
                     address__family=family,
-                    vminterface__in=self.instance.interfaces.values_list('id', flat=True)
+                    assigned_object_type=ContentType.objects.get_for_model(VMInterface),
+                    assigned_object_id__in=interface_ids
                 )
                 )
                 if interface_ips:
                 if interface_ips:
-                    ip_choices.append(
-                        ('Interface IPs', [
-                            (ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
-                        ])
-                    )
+                    ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
+                    ip_choices.append(('Interface IPs', ip_list))
                 # Collect NAT IPs
                 # Collect NAT IPs
                 nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
                 nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
                     address__family=family,
                     address__family=family,
-                    nat_inside__vminterface__in=self.instance.interfaces.values_list('id', flat=True)
+                    nat_inside__assigned_object_type=ContentType.objects.get_for_model(VMInterface),
+                    nat_inside__assigned_object_id__in=interface_ids
                 )
                 )
                 if nat_ips:
                 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, f'{ip.address} (NAT)') for ip in nat_ips]
+                    ip_choices.append(('NAT IPs', ip_list))
                 self.fields['primary_ip{}'.format(family)].choices = ip_choices
                 self.fields['primary_ip{}'.format(family)].choices = ip_choices
 
 
         else:
         else:
@@ -683,7 +684,7 @@ class VMInterfaceCSVForm(CSVModelForm):
             return self.cleaned_data['enabled']
             return self.cleaned_data['enabled']
 
 
 
 
-class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
+class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=VMInterface.objects.all(),
         queryset=VMInterface.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()

+ 3 - 3
netbox/virtualization/models.py

@@ -335,13 +335,13 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         for field in ['primary_ip4', 'primary_ip6']:
         for field in ['primary_ip4', 'primary_ip6']:
             ip = getattr(self, field)
             ip = getattr(self, field)
             if ip is not None:
             if ip is not None:
-                if ip.interface in interfaces:
+                if ip.assigned_object in interfaces:
                     pass
                     pass
-                elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in interfaces:
+                elif ip.nat_inside is not None and ip.nat_inside.assigned_object in interfaces:
                     pass
                     pass
                 else:
                 else:
                     raise ValidationError({
                     raise ValidationError({
-                        field: "The specified IP address ({}) is not assigned to this VM.".format(ip),
+                        field: f"The specified IP address ({ip}) is not assigned to this VM.",
                     })
                     })
 
 
     def to_csv(self):
     def to_csv(self):

+ 4 - 1
netbox/virtualization/tables.py

@@ -154,11 +154,14 @@ class VMInterfaceTable(BaseInterfaceTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    tags = TagColumn(
+        url_name='virtualization:vminterface_list'
+    )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VMInterface
         model = VMInterface
         fields = (
         fields = (
-            'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'ip_addresses',
+            'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'tags', 'ip_addresses',
             'untagged_vlan', 'tagged_vlans',
             'untagged_vlan', 'tagged_vlans',
         )
         )
         default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description')
         default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description')