Jelajahi Sumber

Merge branch 'develop' into feature

jeremystretch 4 tahun lalu
induk
melakukan
5b4dacf0f5

+ 4 - 3
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -17,7 +17,7 @@ body:
         What version of NetBox are you currently running? (If you don't have access to the most
         What version of NetBox are you currently running? (If you don't have access to the most
         recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
         recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
         before opening a bug report to see if your issue has already been addressed.)
         before opening a bug report to see if your issue has already been addressed.)
-      placeholder: v2.11.3
+      placeholder: v2.11.4
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown
@@ -39,8 +39,9 @@ body:
         reproduce this bug using the current stable release of NetBox. Begin with the
         reproduce this bug using the current stable release of NetBox. Begin with the
         creation of any necessary database objects and call out every operation being
         creation of any necessary database objects and call out every operation being
         performed explicitly. If reporting a bug in the REST API, be sure to reconstruct
         performed explicitly. If reporting a bug in the REST API, be sure to reconstruct
-        the raw HTTP request(s) being made: Don't rely on a client  library such as
-        pynetbox."
+        the raw HTTP request(s) being made: Don't rely on a client library such as
+        pynetbox. Additionally, **do not rely on the demo instance** for reproducing
+        suspected bugs, as its data is prone to modification or deletion at any time.
       placeholder: |
       placeholder: |
         1. Click on "create widget"
         1. Click on "create widget"
         2. Set foo to 12 and bar to G
         2. Set foo to 12 and bar to G

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v2.11.3
+      placeholder: v2.11.4
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 2 - 0
README.md

@@ -22,6 +22,8 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
 <div align="center">
 <div align="center">
   <h4>Thank you to our sponsors!</h4>
   <h4>Thank you to our sponsors!</h4>
 
 
+  [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
+  &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
   [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/)
   [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/)
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
   [![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/)
   [![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/)

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

@@ -6,7 +6,7 @@ If a change is made to any of the objects returned by the query within that time
 
 
 ## Invalidating Cached Data
 ## Invalidating Cached Data
 
 
-Although caching is performed automatically and rarely requires administrative intervention, NetBox provides the `invalidate` management command to force invalidation of cached results. This command can reference a specific object my its type and numeric ID:
+Although caching is performed automatically and rarely requires administrative intervention, NetBox provides the `invalidate` management command to force invalidation of cached results. This command can reference a specific object by its type and numeric ID:
 
 
 ```no-highlight
 ```no-highlight
 $ python netbox/manage.py invalidate dcim.Device.34
 $ python netbox/manage.py invalidate dcim.Device.34

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

@@ -1,6 +1,6 @@
 # Power Feed
 # Power Feed
 
 
-A power feed represents the distribution of power from a power panel to a particular device, typically a power distribution unit (PDU). The power pot (inlet) on a device can be connected via a cable to a power feed. A power feed may optionally be assigned to a rack to allow more easily tracking the distribution of power among racks.
+A power feed represents the distribution of power from a power panel to a particular device, typically a power distribution unit (PDU). The power port (inlet) on a device can be connected via a cable to a power feed. A power feed may optionally be assigned to a rack to allow more easily tracking the distribution of power among racks.
 
 
 Each power feed is assigned an operational type (primary or redundant) and one of the following statuses:
 Each power feed is assigned an operational type (primary or redundant) and one of the following statuses:
 
 

+ 23 - 0
docs/release-notes/version-2.11.md

@@ -1,5 +1,28 @@
 # NetBox v2.11
 # NetBox v2.11
 
 
+## v2.11.4 (2021-05-25)
+
+### Enhancements
+
+* [#5121](https://github.com/netbox-community/netbox/issues/5121) - Add content type filters for tags
+* [#6358](https://github.com/netbox-community/netbox/issues/6358) - Add search field for VLAN groups
+* [#6393](https://github.com/netbox-community/netbox/issues/6393) - Add `description` filter for IP addresses
+* [#6400](https://github.com/netbox-community/netbox/issues/6400) - Add cyan color choice for plugin buttons
+* [#6422](https://github.com/netbox-community/netbox/issues/6422) - Enable filtering users by group under admin UI
+* [#6441](https://github.com/netbox-community/netbox/issues/6441) - Improve UI paginator to optimize page object count
+
+### Bug Fixes
+
+* [#6376](https://github.com/netbox-community/netbox/issues/6376) - Fix assignment of VLAN groups to clusters, cluster groups via REST API
+* [#6398](https://github.com/netbox-community/netbox/issues/6398) - Avoid exception when deleting device connected to self via circuit
+* [#6426](https://github.com/netbox-community/netbox/issues/6426) - Allow assigning virtual chassis member interfaces to LAG on VC master
+* [#6438](https://github.com/netbox-community/netbox/issues/6438) - Fix missing descriptions and label for device type imports and exports
+* [#6465](https://github.com/netbox-community/netbox/issues/6465) - Fix typo in installed plugins REST API endpoint
+* [#6467](https://github.com/netbox-community/netbox/issues/6467) - Fix access to metrics on custom `BASE_PATH` when login is required
+* [#6468](https://github.com/netbox-community/netbox/issues/6468) - Disable ordering VLAN groups list by scope object
+
+---
+
 ## v2.11.3 (2021-05-07)
 ## v2.11.3 (2021-05-07)
 
 
 ### Enhancements
 ### Enhancements

+ 3 - 3
netbox/circuits/models.py

@@ -20,7 +20,7 @@ __all__ = (
 )
 )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Provider(PrimaryModel):
 class Provider(PrimaryModel):
     """
     """
     Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
     Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@@ -96,7 +96,7 @@ class Provider(PrimaryModel):
 # Provider networks
 # Provider networks
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class ProviderNetwork(PrimaryModel):
 class ProviderNetwork(PrimaryModel):
     """
     """
     This represents a provider network which exists outside of NetBox, the details of which are unknown or
     This represents a provider network which exists outside of NetBox, the details of which are unknown or
@@ -189,7 +189,7 @@ class CircuitType(OrganizationalModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Circuit(PrimaryModel):
 class Circuit(PrimaryModel):
     """
     """
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple

+ 14 - 10
netbox/dcim/forms.py

@@ -1818,7 +1818,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
     class Meta:
         model = ConsolePortTemplate
         model = ConsolePortTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'label', 'type',
+            'device_type', 'name', 'label', 'type', 'description',
         ]
         ]
 
 
 
 
@@ -1827,7 +1827,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
     class Meta:
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'label', 'type',
+            'device_type', 'name', 'label', 'type', 'description',
         ]
         ]
 
 
 
 
@@ -1836,7 +1836,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
+            'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
         ]
         ]
 
 
 
 
@@ -1850,7 +1850,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
+            'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
         ]
         ]
 
 
 
 
@@ -1862,7 +1862,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'label', 'type', 'mgmt_only',
+            'device_type', 'name', 'label', 'type', 'mgmt_only', 'description',
         ]
         ]
 
 
 
 
@@ -1879,7 +1879,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'type', 'rear_port', 'rear_port_position',
+            'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description',
         ]
         ]
 
 
 
 
@@ -1891,7 +1891,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
     class Meta:
         model = RearPortTemplate
         model = RearPortTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'type', 'positions',
+            'device_type', 'name', 'type', 'positions', 'label', 'description',
         ]
         ]
 
 
 
 
@@ -1900,7 +1900,7 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
     class Meta:
         model = DeviceBayTemplate
         model = DeviceBayTemplate
         fields = [
         fields = [
-            'device_type', 'name',
+            'device_type', 'name', 'label', 'description',
         ]
         ]
 
 
 
 
@@ -3150,9 +3150,13 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
 
 
         device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device
         device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device
 
 
-        # Restrict parent/LAG interface assignment by device
+        # Restrict parent/LAG interface assignment by device/VC
         self.fields['parent'].widget.add_query_param('device_id', device.pk)
         self.fields['parent'].widget.add_query_param('device_id', device.pk)
-        self.fields['lag'].widget.add_query_param('device_id', device.pk)
+        if device.virtual_chassis and device.virtual_chassis.master:
+            # Get available LAG interfaces by VirtualChassis master
+            self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
+        else:
+            self.fields['lag'].widget.add_query_param('device_id', device.pk)
 
 
         # Limit VLAN choices by device
         # Limit VLAN choices by device
         self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
         self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)

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

@@ -30,7 +30,7 @@ __all__ = (
 # Cables
 # Cables
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Cable(PrimaryModel):
 class Cable(PrimaryModel):
     """
     """
     A physical connection between two endpoints.
     A physical connection between two endpoints.

+ 9 - 9
netbox/dcim/models/device_components.py

@@ -211,7 +211,7 @@ class PathEndpoint(models.Model):
 # Console ports
 # Console ports
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
 class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
     """
     """
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@@ -254,7 +254,7 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
 # Console server ports
 # Console server ports
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
 class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
     """
     """
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
@@ -297,7 +297,7 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
 # Power ports
 # Power ports
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class PowerPort(ComponentModel, CableTermination, PathEndpoint):
 class PowerPort(ComponentModel, CableTermination, PathEndpoint):
     """
     """
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@@ -408,7 +408,7 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
 # Power outlets
 # Power outlets
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
 class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
     """
     """
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     A physical power outlet (output) within a Device which provides power to a PowerPort.
@@ -512,7 +512,7 @@ class BaseInterface(models.Model):
         return self.ip_addresses.count()
         return self.ip_addresses.count()
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
 class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
     """
     """
     A network interface within a Device. A physical Interface can connect to exactly one other Interface.
     A network interface within a Device. A physical Interface can connect to exactly one other Interface.
@@ -683,7 +683,7 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
 # Pass-through ports
 # Pass-through ports
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class FrontPort(ComponentModel, CableTermination):
 class FrontPort(ComponentModel, CableTermination):
     """
     """
     A pass-through port on the front of a Device.
     A pass-through port on the front of a Device.
@@ -748,7 +748,7 @@ class FrontPort(ComponentModel, CableTermination):
             })
             })
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class RearPort(ComponentModel, CableTermination):
 class RearPort(ComponentModel, CableTermination):
     """
     """
     A pass-through port on the rear of a Device.
     A pass-through port on the rear of a Device.
@@ -801,7 +801,7 @@ class RearPort(ComponentModel, CableTermination):
 # Device bays
 # Device bays
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class DeviceBay(ComponentModel):
 class DeviceBay(ComponentModel):
     """
     """
     An empty space within a Device which can house a child device
     An empty space within a Device which can house a child device
@@ -860,7 +860,7 @@ class DeviceBay(ComponentModel):
 # Inventory items
 # Inventory items
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class InventoryItem(MPTTModel, ComponentModel):
 class InventoryItem(MPTTModel, ComponentModel):
     """
     """
     An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
     An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.

+ 19 - 3
netbox/dcim/models/devices.py

@@ -75,7 +75,7 @@ class Manufacturer(OrganizationalModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class DeviceType(PrimaryModel):
 class DeviceType(PrimaryModel):
     """
     """
     A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
     A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
@@ -183,6 +183,8 @@ class DeviceType(PrimaryModel):
                 {
                 {
                     'name': c.name,
                     'name': c.name,
                     'type': c.type,
                     'type': c.type,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 }
                 for c in self.consoleporttemplates.all()
                 for c in self.consoleporttemplates.all()
             ]
             ]
@@ -191,6 +193,8 @@ class DeviceType(PrimaryModel):
                 {
                 {
                     'name': c.name,
                     'name': c.name,
                     'type': c.type,
                     'type': c.type,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 }
                 for c in self.consoleserverporttemplates.all()
                 for c in self.consoleserverporttemplates.all()
             ]
             ]
@@ -201,6 +205,8 @@ class DeviceType(PrimaryModel):
                     'type': c.type,
                     'type': c.type,
                     'maximum_draw': c.maximum_draw,
                     'maximum_draw': c.maximum_draw,
                     'allocated_draw': c.allocated_draw,
                     'allocated_draw': c.allocated_draw,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 }
                 for c in self.powerporttemplates.all()
                 for c in self.powerporttemplates.all()
             ]
             ]
@@ -211,6 +217,8 @@ class DeviceType(PrimaryModel):
                     'type': c.type,
                     'type': c.type,
                     'power_port': c.power_port.name if c.power_port else None,
                     'power_port': c.power_port.name if c.power_port else None,
                     'feed_leg': c.feed_leg,
                     'feed_leg': c.feed_leg,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 }
                 for c in self.poweroutlettemplates.all()
                 for c in self.poweroutlettemplates.all()
             ]
             ]
@@ -220,6 +228,8 @@ class DeviceType(PrimaryModel):
                     'name': c.name,
                     'name': c.name,
                     'type': c.type,
                     'type': c.type,
                     'mgmt_only': c.mgmt_only,
                     'mgmt_only': c.mgmt_only,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 }
                 for c in self.interfacetemplates.all()
                 for c in self.interfacetemplates.all()
             ]
             ]
@@ -230,6 +240,8 @@ class DeviceType(PrimaryModel):
                     'type': c.type,
                     'type': c.type,
                     'rear_port': c.rear_port.name,
                     'rear_port': c.rear_port.name,
                     'rear_port_position': c.rear_port_position,
                     'rear_port_position': c.rear_port_position,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 }
                 for c in self.frontporttemplates.all()
                 for c in self.frontporttemplates.all()
             ]
             ]
@@ -239,6 +251,8 @@ class DeviceType(PrimaryModel):
                     'name': c.name,
                     'name': c.name,
                     'type': c.type,
                     'type': c.type,
                     'positions': c.positions,
                     'positions': c.positions,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 }
                 for c in self.rearporttemplates.all()
                 for c in self.rearporttemplates.all()
             ]
             ]
@@ -246,6 +260,8 @@ class DeviceType(PrimaryModel):
             data['device-bays'] = [
             data['device-bays'] = [
                 {
                 {
                     'name': c.name,
                     'name': c.name,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 }
                 for c in self.devicebaytemplates.all()
                 for c in self.devicebaytemplates.all()
             ]
             ]
@@ -448,7 +464,7 @@ class Platform(OrganizationalModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Device(PrimaryModel, ConfigContextModel):
 class Device(PrimaryModel, ConfigContextModel):
     """
     """
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
@@ -891,7 +907,7 @@ class Device(PrimaryModel, ConfigContextModel):
 # Virtual chassis
 # Virtual chassis
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class VirtualChassis(PrimaryModel):
 class VirtualChassis(PrimaryModel):
     """
     """
     A collection of Devices which operate with a shared control plane (e.g. a switch stack).
     A collection of Devices which operate with a shared control plane (e.g. a switch stack).

+ 2 - 2
netbox/dcim/models/power.py

@@ -21,7 +21,7 @@ __all__ = (
 # Power
 # Power
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class PowerPanel(PrimaryModel):
 class PowerPanel(PrimaryModel):
     """
     """
     A distribution point for electrical power; e.g. a data center RPP.
     A distribution point for electrical power; e.g. a data center RPP.
@@ -71,7 +71,7 @@ class PowerPanel(PrimaryModel):
             )
             )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
 class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
     """
     """
     An electrical circuit delivered from a PowerPanel.
     An electrical circuit delivered from a PowerPanel.

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

@@ -78,7 +78,7 @@ class RackRole(OrganizationalModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Rack(PrimaryModel):
 class Rack(PrimaryModel):
     """
     """
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@@ -463,7 +463,7 @@ class Rack(PrimaryModel):
         return int(allocated_draw_total / available_power_total * 100)
         return int(allocated_draw_total / available_power_total * 100)
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class RackReservation(PrimaryModel):
 class RackReservation(PrimaryModel):
     """
     """
     One or more reserved units within a Rack.
     One or more reserved units within a Rack.

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

@@ -130,7 +130,7 @@ class SiteGroup(NestedGroupModel):
 # Sites
 # Sites
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Site(PrimaryModel):
 class Site(PrimaryModel):
     """
     """
     A Site represents a geographic location within a network; typically a building or campus. The optional facility
     A Site represents a geographic location within a network; typically a building or campus. The optional facility

+ 3 - 2
netbox/dcim/signals.py

@@ -31,9 +31,10 @@ def rebuild_paths(obj):
 
 
     with transaction.atomic():
     with transaction.atomic():
         for cp in cable_paths:
         for cp in cable_paths:
-            invalidate_obj(cp.origin)
             cp.delete()
             cp.delete()
-            create_cablepath(cp.origin)
+            if cp.origin:
+                invalidate_obj(cp.origin)
+                create_cablepath(cp.origin)
 
 
 
 
 #
 #

+ 1 - 0
netbox/extras/constants.py

@@ -7,5 +7,6 @@ EXTRAS_FEATURES = [
     'custom_links',
     'custom_links',
     'export_templates',
     'export_templates',
     'job_results',
     'job_results',
+    'tags',
     'webhooks'
     'webhooks'
 ]
 ]

+ 33 - 1
netbox/extras/filtersets.py

@@ -6,7 +6,7 @@ from django.db.models import Q
 from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
 from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from utilities.filters import ContentTypeFilter
+from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .choices import *
 from .models import *
 from .models import *
@@ -114,6 +114,12 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    content_type = MultiValueCharFilter(
+        method='_content_type'
+    )
+    content_type_id = MultiValueNumberFilter(
+        method='_content_type_id'
+    )
 
 
     class Meta:
     class Meta:
         model = Tag
         model = Tag
@@ -127,6 +133,32 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
             Q(slug__icontains=value)
             Q(slug__icontains=value)
         )
         )
 
 
+    def _content_type(self, queryset, name, values):
+        ct_filter = Q()
+
+        # Compile list of app_label & model pairings
+        for value in values:
+            try:
+                app_label, model = value.lower().split('.')
+                ct_filter |= Q(
+                    app_label=app_label,
+                    model=model
+                )
+            except ValueError:
+                pass
+
+        # Get ContentType instances
+        content_types = ContentType.objects.filter(ct_filter)
+
+        return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct()
+
+    def _content_type_id(self, queryset, name, values):
+
+        # Get ContentType instances
+        content_types = ContentType.objects.filter(pk__in=values)
+
+        return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct()
+
 
 
 class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
 class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(

+ 12 - 2
netbox/extras/forms.py

@@ -8,12 +8,13 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
-    CommentField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
-    BOOLEAN_WITH_BLANK_CHOICES,
+    CommentField, ContentTypeMultipleChoiceField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField,
+    JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .choices import *
 from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag
 from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag
+from .utils import FeatureQuery
 
 
 
 
 #
 #
@@ -177,6 +178,15 @@ class AddRemoveTagsForm(forms.Form):
 
 
 class TagFilterForm(BootstrapMixin, forms.Form):
 class TagFilterForm(BootstrapMixin, forms.Form):
     model = Tag
     model = Tag
+    q = forms.CharField(
+        required=False,
+        label=_('Search')
+    )
+    content_type_id = ContentTypeMultipleChoiceField(
+        queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
+        required=False,
+        label=_('Tagged object type')
+    )
 
 
 
 
 class TagBulkEditForm(BootstrapMixin, BulkEditForm):
 class TagBulkEditForm(BootstrapMixin, BulkEditForm):

+ 1 - 1
netbox/extras/plugins/views.py

@@ -42,7 +42,7 @@ class InstalledPluginsAPIView(APIView):
             'author': plugin_app_config.author,
             'author': plugin_app_config.author,
             'author_email': plugin_app_config.author_email,
             'author_email': plugin_app_config.author_email,
             'description': plugin_app_config.description,
             'description': plugin_app_config.description,
-            'verison': plugin_app_config.version
+            'version': plugin_app_config.version
         }
         }
 
 
     def get(self, request, format=None):
     def get(self, request, format=None):

+ 16 - 0
netbox/extras/tests/test_filtersets.py

@@ -5,6 +5,7 @@ from django.contrib.auth.models import User
 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 circuits.models import Provider
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
 from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
 from extras.filtersets import *
 from extras.filtersets import *
@@ -537,6 +538,13 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         Tag.objects.bulk_create(tags)
         Tag.objects.bulk_create(tags)
 
 
+        # Apply some tags so we can filter by content type
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+
+        site.tags.set(tags[0])
+        provider.tags.set(tags[1])
+
     def test_name(self):
     def test_name(self):
         params = {'name': ['Tag 1', 'Tag 2']}
         params = {'name': ['Tag 1', 'Tag 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -549,6 +557,14 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'color': ['ff0000', '00ff00']}
         params = {'color': ['ff0000', '00ff00']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_content_type(self):
+        params = {'content_type': ['dcim.site', 'circuits.provider']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        site_ct = ContentType.objects.get_for_model(Site).pk
+        provider_ct = ContentType.objects.get_for_model(Provider).pk
+        params = {'content_type_id': [site_ct, provider_ct]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 
 class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
 class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
     queryset = ObjectChange.objects.all()
     queryset = ObjectChange.objects.all()

+ 2 - 3
netbox/ipam/api/serializers.py

@@ -7,7 +7,7 @@ from rest_framework.validators import UniqueTogetherValidator
 
 
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
 from ipam.choices import *
 from ipam.choices import *
-from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
+from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import OrganizationalModelSerializer
 from netbox.api.serializers import OrganizationalModelSerializer
@@ -115,8 +115,7 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
     scope_type = ContentTypeField(
     scope_type = ContentTypeField(
         queryset=ContentType.objects.filter(
         queryset=ContentType.objects.filter(
-            app_label='dcim',
-            model__in=['region', 'sitegroup', 'site', 'location', 'rack']
+            model__in=VLANGROUP_SCOPE_TYPES
         ),
         ),
         required=False
         required=False
     )
     )

+ 14 - 1
netbox/ipam/filtersets.py

@@ -468,7 +468,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
-        fields = ['id', 'dns_name']
+        fields = ['id', 'dns_name', 'description']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -536,6 +536,10 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 
 
 
 
 class VLANGroupFilterSet(OrganizationalModelFilterSet):
 class VLANGroupFilterSet(OrganizationalModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     scope_type = ContentTypeFilter()
     scope_type = ContentTypeFilter()
     region = django_filters.NumberFilter(
     region = django_filters.NumberFilter(
         method='filter_scope'
         method='filter_scope'
@@ -563,6 +567,15 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
         model = VLANGroup
         model = VLANGroup
         fields = ['id', 'name', 'slug', 'description', 'scope_id']
         fields = ['id', 'name', 'slug', 'description', 'scope_id']
 
 
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
     def filter_scope(self, queryset, name, value):
     def filter_scope(self, queryset, name, value):
         return queryset.filter(
         return queryset.filter(
             scope_type=ContentType.objects.get(model=name),
             scope_type=ContentType.objects.get(model=name),

+ 4 - 0
netbox/ipam/forms.py

@@ -1291,6 +1291,10 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
         ['region', 'sitegroup', 'site'],
         ['region', 'sitegroup', 'site'],
         ['location', 'rack']
         ['location', 'rack']
     ]
     ]
+    q = forms.CharField(
+        required=False,
+        label=_('Search')
+    )
     region = DynamicModelMultipleChoiceField(
     region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,

+ 3 - 3
netbox/ipam/models/ip.py

@@ -77,7 +77,7 @@ class RIR(OrganizationalModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Aggregate(PrimaryModel):
 class Aggregate(PrimaryModel):
     """
     """
     An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
     An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
@@ -228,7 +228,7 @@ class Role(OrganizationalModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Prefix(PrimaryModel):
 class Prefix(PrimaryModel):
     """
     """
     A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
     A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
@@ -489,7 +489,7 @@ class Prefix(PrimaryModel):
             return int(float(child_count) / prefix_size * 100)
             return int(float(child_count) / prefix_size * 100)
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class IPAddress(PrimaryModel):
 class IPAddress(PrimaryModel):
     """
     """
     An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
     An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is

+ 1 - 1
netbox/ipam/models/services.py

@@ -17,7 +17,7 @@ __all__ = (
 )
 )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Service(PrimaryModel):
 class Service(PrimaryModel):
     """
     """
     A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
     A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may

+ 1 - 1
netbox/ipam/models/vlans.py

@@ -100,7 +100,7 @@ class VLANGroup(OrganizationalModel):
         return None
         return None
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class VLAN(PrimaryModel):
 class VLAN(PrimaryModel):
     """
     """
     A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
     A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned

+ 2 - 2
netbox/ipam/models/vrfs.py

@@ -13,7 +13,7 @@ __all__ = (
 )
 )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class VRF(PrimaryModel):
 class VRF(PrimaryModel):
     """
     """
     A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
     A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@@ -88,7 +88,7 @@ class VRF(PrimaryModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class RouteTarget(PrimaryModel):
 class RouteTarget(PrimaryModel):
     """
     """
     A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364.
     A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364.

+ 2 - 1
netbox/ipam/tables.py

@@ -449,7 +449,8 @@ class VLANGroupTable(BaseTable):
     name = tables.Column(linkify=True)
     name = tables.Column(linkify=True)
     scope_type = ContentTypeColumn()
     scope_type = ContentTypeColumn()
     scope = tables.Column(
     scope = tables.Column(
-        linkify=True
+        linkify=True,
+        orderable=False
     )
     )
     vlan_count = LinkedCountColumn(
     vlan_count = LinkedCountColumn(
         viewname='ipam:vlan_list',
         viewname='ipam:vlan_list',

+ 6 - 2
netbox/ipam/tests/test_filtersets.py

@@ -577,12 +577,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
         Tenant.objects.bulk_create(tenants)
 
 
         ipaddresses = (
         ipaddresses = (
-            IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
+            IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a', description='foobar1'),
             IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
             IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
             IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
             IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
             IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
             IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
             IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
             IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
-            IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
+            IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a', description='foobar2'),
             IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
             IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
             IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vminterfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
             IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vminterfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
             IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vminterfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
             IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vminterfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
@@ -598,6 +598,10 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'dns_name': ['ipaddress-a', 'ipaddress-b']}
         params = {'dns_name': ['ipaddress-a', 'ipaddress-b']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_parent(self):
     def test_parent(self):
         params = {'parent': '10.0.0.0/24'}
         params = {'parent': '10.0.0.0/24'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)

+ 13 - 10
netbox/netbox/middleware.py

@@ -20,17 +20,20 @@ class LoginRequiredMiddleware(object):
         self.get_response = get_response
         self.get_response = get_response
 
 
     def __call__(self, request):
     def __call__(self, request):
+        # Redirect unauthenticated requests (except those exempted) to the login page if LOGIN_REQUIRED is true
         if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
         if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
-            # Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API
-            # performs its own authentication. Also metrics can be read without login.
-            api_path = reverse('api-root')
-            if not request.path_info.startswith((api_path, '/metrics')) and request.path_info != settings.LOGIN_URL:
-                return HttpResponseRedirect(
-                    '{}?next={}'.format(
-                        settings.LOGIN_URL,
-                        parse.quote(request.get_full_path_info())
-                    )
-                )
+            # Determine exempt paths
+            exempt_paths = [
+                reverse('api-root')
+            ]
+            if settings.METRICS_ENABLED:
+                exempt_paths.append(reverse('prometheus-django-metrics'))
+
+            # Redirect unauthenticated requests
+            if not request.path_info.startswith(tuple(exempt_paths)) and request.path_info != settings.LOGIN_URL:
+                login_url = f'{settings.LOGIN_URL}?next={parse.quote(request.get_full_path_info())}'
+                return HttpResponseRedirect(login_url)
+
         return self.get_response(request)
         return self.get_response(request)
 
 
 
 

+ 1 - 1
netbox/tenancy/models.py

@@ -57,7 +57,7 @@ class TenantGroup(NestedGroupModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Tenant(PrimaryModel):
 class Tenant(PrimaryModel):
     """
     """
     A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
     A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal

+ 1 - 0
netbox/users/admin.py

@@ -89,6 +89,7 @@ class UserAdmin(UserAdmin_):
         ('Important dates', {'fields': ('last_login', 'date_joined')}),
         ('Important dates', {'fields': ('last_login', 'date_joined')}),
     )
     )
     filter_horizontal = ('groups',)
     filter_horizontal = ('groups',)
+    list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name')
 
 
     def get_inlines(self, request, obj):
     def get_inlines(self, request, obj):
         if obj is not None:
         if obj is not None:

+ 5 - 3
netbox/utilities/choices.py

@@ -130,22 +130,24 @@ class ColorChoices(ChoiceSet):
 
 
 class ButtonColorChoices(ChoiceSet):
 class ButtonColorChoices(ChoiceSet):
     """
     """
-    Map standard button color choices to Bootstrap color classes
+    Map standard button color choices to Bootstrap 3 button classes
     """
     """
     DEFAULT = 'outline-dark'
     DEFAULT = 'outline-dark'
     BLUE = 'primary'
     BLUE = 'primary'
-    GREY = 'secondary'
+    CYAN = 'info'
     GREEN = 'success'
     GREEN = 'success'
     RED = 'danger'
     RED = 'danger'
     YELLOW = 'warning'
     YELLOW = 'warning'
+    GREY = 'secondary'
     BLACK = 'dark'
     BLACK = 'dark'
 
 
     CHOICES = (
     CHOICES = (
         (DEFAULT, 'Default'),
         (DEFAULT, 'Default'),
         (BLUE, 'Blue'),
         (BLUE, 'Blue'),
-        (GREY, 'Grey'),
+        (CYAN, 'Cyan'),
         (GREEN, 'Green'),
         (GREEN, 'Green'),
         (RED, 'Red'),
         (RED, 'Red'),
         (YELLOW, 'Yellow'),
         (YELLOW, 'Yellow'),
+        (GREY, 'Grey'),
         (BLACK, 'Black')
         (BLACK, 'Black')
     )
     )

+ 10 - 2
netbox/utilities/paginator.py

@@ -4,7 +4,9 @@ from django.core.paginator import Paginator, Page
 
 
 class EnhancedPaginator(Paginator):
 class EnhancedPaginator(Paginator):
 
 
-    def __init__(self, object_list, per_page, **kwargs):
+    def __init__(self, object_list, per_page, orphans=None, **kwargs):
+
+        # Determine the page size
         try:
         try:
             per_page = int(per_page)
             per_page = int(per_page)
             if per_page < 1:
             if per_page < 1:
@@ -12,7 +14,13 @@ class EnhancedPaginator(Paginator):
         except ValueError:
         except ValueError:
             per_page = settings.PAGINATE_COUNT
             per_page = settings.PAGINATE_COUNT
 
 
-        super().__init__(object_list, per_page, **kwargs)
+        # Set orphans count based on page size
+        if orphans is None and per_page <= 50:
+            orphans = 5
+        elif orphans is None:
+            orphans = 10
+
+        super().__init__(object_list, per_page, orphans=orphans, **kwargs)
 
 
     def _get_page(self, *args, **kwargs):
     def _get_page(self, *args, **kwargs):
         return EnhancedPage(*args, **kwargs)
         return EnhancedPage(*args, **kwargs)

+ 0 - 14
netbox/utilities/tables.py

@@ -5,7 +5,6 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist
 from django.core.exceptions import FieldDoesNotExist
 from django.db.models.fields.related import RelatedField
 from django.db.models.fields.related import RelatedField
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.html import strip_tags
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
 from django_tables2.data import TableQuerysetData
 from django_tables2.data import TableQuerysetData
@@ -15,19 +14,6 @@ from extras.models import CustomField
 from .paginator import EnhancedPaginator, get_paginate_count
 from .paginator import EnhancedPaginator, get_paginate_count
 
 
 
 
-def stripped_value(self, **kwargs):
-    """
-    Replaces TemplateColumn's value() method to both strip HTML tags and remove any leading/trailing whitespace.
-    """
-    html = super(tables.TemplateColumn, self).value(**kwargs)
-    return strip_tags(html).strip() if isinstance(html, str) else html
-
-
-# TODO: We're monkey-patching TemplateColumn here to strip leading/trailing whitespace. This will no longer
-# be necessary under django-tables2 v2.3.5+. (See #5926)
-tables.TemplateColumn.value = stripped_value
-
-
 class BaseTable(tables.Table):
 class BaseTable(tables.Table):
     """
     """
     Default table for object lists
     Default table for object lists

+ 3 - 3
netbox/virtualization/models.py

@@ -116,7 +116,7 @@ class ClusterGroup(OrganizationalModel):
 # Clusters
 # Clusters
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Cluster(PrimaryModel):
 class Cluster(PrimaryModel):
     """
     """
     A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
     A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
@@ -199,7 +199,7 @@ class Cluster(PrimaryModel):
 # Virtual machines
 # Virtual machines
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class VirtualMachine(PrimaryModel, ConfigContextModel):
 class VirtualMachine(PrimaryModel, ConfigContextModel):
     """
     """
     A virtual machine which runs inside a Cluster.
     A virtual machine which runs inside a Cluster.
@@ -374,7 +374,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
 # Interfaces
 # Interfaces
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class VMInterface(PrimaryModel, BaseInterface):
 class VMInterface(PrimaryModel, BaseInterface):
     virtual_machine = models.ForeignKey(
     virtual_machine = models.ForeignKey(
         to='virtualization.VirtualMachine',
         to='virtualization.VirtualMachine',

+ 3 - 3
requirements.txt

@@ -1,4 +1,4 @@
-Django==3.2.2
+Django==3.2.3
 django-cacheops==6.0
 django-cacheops==6.0
 django-cors-headers==3.7.0
 django-cors-headers==3.7.0
 django-debug-toolbar==3.2.1
 django-debug-toolbar==3.2.1
@@ -7,13 +7,13 @@ django-mptt==0.12.0
 django-pglocks==1.0.4
 django-pglocks==1.0.4
 django-prometheus==2.1.0
 django-prometheus==2.1.0
 django-rq==2.4.1
 django-rq==2.4.1
-django-tables2==2.3.4
+django-tables2==2.4.0
 django-taggit==1.4.0
 django-taggit==1.4.0
 django-timezone-field==4.1.2
 django-timezone-field==4.1.2
 djangorestframework==3.12.4
 djangorestframework==3.12.4
 drf-yasg[validation]==1.20.0
 drf-yasg[validation]==1.20.0
 gunicorn==20.1.0
 gunicorn==20.1.0
-Jinja2==2.11.3
+Jinja2==3.0.1
 Markdown==3.3.4
 Markdown==3.3.4
 netaddr==0.8.0
 netaddr==0.8.0
 Pillow==8.2.0
 Pillow==8.2.0