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

Merge branch 'develop' into feature

jeremystretch 4 лет назад
Родитель
Сommit
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
         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.)
-      placeholder: v2.11.3
+      placeholder: v2.11.4
     validations:
       required: true
   - type: dropdown
@@ -39,8 +39,9 @@ body:
         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
         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: |
         1. Click on "create widget"
         2. Set foo to 12 and bar to G

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

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v2.11.3
+      placeholder: v2.11.4
     validations:
       required: true
   - 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">
   <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/)
   &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/)

+ 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
 
-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
 $ python netbox/manage.py invalidate dcim.Device.34

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

@@ -1,6 +1,6 @@
 # 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:
 

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

@@ -1,5 +1,28 @@
 # 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)
 
 ### 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):
     """
     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
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class ProviderNetwork(PrimaryModel):
     """
     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):
     """
     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:
         model = ConsolePortTemplate
         fields = [
-            'device_type', 'name', 'label', 'type',
+            'device_type', 'name', 'label', 'type', 'description',
         ]
 
 
@@ -1827,7 +1827,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
         model = ConsoleServerPortTemplate
         fields = [
-            'device_type', 'name', 'label', 'type',
+            'device_type', 'name', 'label', 'type', 'description',
         ]
 
 
@@ -1836,7 +1836,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
         model = PowerPortTemplate
         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:
         model = PowerOutletTemplate
         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:
         model = InterfaceTemplate
         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:
         model = FrontPortTemplate
         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:
         model = RearPortTemplate
         fields = [
-            'device_type', 'name', 'type', 'positions',
+            'device_type', 'name', 'type', 'positions', 'label', 'description',
         ]
 
 
@@ -1900,7 +1900,7 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
         model = DeviceBayTemplate
         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
 
-        # 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['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
         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
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Cable(PrimaryModel):
     """
     A physical connection between two endpoints.

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

@@ -211,7 +211,7 @@ class PathEndpoint(models.Model):
 # 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):
     """
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@@ -254,7 +254,7 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
 # 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):
     """
     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
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class PowerPort(ComponentModel, CableTermination, PathEndpoint):
     """
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@@ -408,7 +408,7 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
 # 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):
     """
     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()
 
 
-@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):
     """
     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
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class FrontPort(ComponentModel, CableTermination):
     """
     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):
     """
     A pass-through port on the rear of a Device.
@@ -801,7 +801,7 @@ class RearPort(ComponentModel, CableTermination):
 # Device bays
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class DeviceBay(ComponentModel):
     """
     An empty space within a Device which can house a child device
@@ -860,7 +860,7 @@ class DeviceBay(ComponentModel):
 # 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):
     """
     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):
     """
     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,
                     'type': c.type,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 for c in self.consoleporttemplates.all()
             ]
@@ -191,6 +193,8 @@ class DeviceType(PrimaryModel):
                 {
                     'name': c.name,
                     'type': c.type,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 for c in self.consoleserverporttemplates.all()
             ]
@@ -201,6 +205,8 @@ class DeviceType(PrimaryModel):
                     'type': c.type,
                     'maximum_draw': c.maximum_draw,
                     'allocated_draw': c.allocated_draw,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 for c in self.powerporttemplates.all()
             ]
@@ -211,6 +217,8 @@ class DeviceType(PrimaryModel):
                     'type': c.type,
                     'power_port': c.power_port.name if c.power_port else None,
                     'feed_leg': c.feed_leg,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 for c in self.poweroutlettemplates.all()
             ]
@@ -220,6 +228,8 @@ class DeviceType(PrimaryModel):
                     'name': c.name,
                     'type': c.type,
                     'mgmt_only': c.mgmt_only,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 for c in self.interfacetemplates.all()
             ]
@@ -230,6 +240,8 @@ class DeviceType(PrimaryModel):
                     'type': c.type,
                     'rear_port': c.rear_port.name,
                     'rear_port_position': c.rear_port_position,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 for c in self.frontporttemplates.all()
             ]
@@ -239,6 +251,8 @@ class DeviceType(PrimaryModel):
                     'name': c.name,
                     'type': c.type,
                     'positions': c.positions,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 for c in self.rearporttemplates.all()
             ]
@@ -246,6 +260,8 @@ class DeviceType(PrimaryModel):
             data['device-bays'] = [
                 {
                     'name': c.name,
+                    'label': c.label,
+                    'description': c.description,
                 }
                 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):
     """
     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
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class VirtualChassis(PrimaryModel):
     """
     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
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class PowerPanel(PrimaryModel):
     """
     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):
     """
     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):
     """
     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)
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class RackReservation(PrimaryModel):
     """
     One or more reserved units within a Rack.

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

@@ -130,7 +130,7 @@ class SiteGroup(NestedGroupModel):
 # Sites
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Site(PrimaryModel):
     """
     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():
         for cp in cable_paths:
-            invalidate_obj(cp.origin)
             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',
     'export_templates',
     'job_results',
+    'tags',
     '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 netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet
 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 .choices import *
 from .models import *
@@ -114,6 +114,12 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
         method='search',
         label='Search',
     )
+    content_type = MultiValueCharFilter(
+        method='_content_type'
+    )
+    content_type_id = MultiValueNumberFilter(
+        method='_content_type_id'
+    )
 
     class Meta:
         model = Tag
@@ -127,6 +133,32 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
             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):
     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 utilities.forms import (
     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 .choices import *
 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):
     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):

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

@@ -42,7 +42,7 @@ class InstalledPluginsAPIView(APIView):
             'author': plugin_app_config.author,
             'author_email': plugin_app_config.author_email,
             'description': plugin_app_config.description,
-            'verison': plugin_app_config.version
+            'version': plugin_app_config.version
         }
 
     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.test import TestCase
 
+from circuits.models import Provider
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
 from extras.filtersets import *
@@ -537,6 +538,13 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         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):
         params = {'name': ['Tag 1', 'Tag 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -549,6 +557,14 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'color': ['ff0000', '00ff00']}
         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):
     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 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 netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import OrganizationalModelSerializer
@@ -115,8 +115,7 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
     scope_type = ContentTypeField(
         queryset=ContentType.objects.filter(
-            app_label='dcim',
-            model__in=['region', 'sitegroup', 'site', 'location', 'rack']
+            model__in=VLANGROUP_SCOPE_TYPES
         ),
         required=False
     )

+ 14 - 1
netbox/ipam/filtersets.py

@@ -468,7 +468,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = IPAddress
-        fields = ['id', 'dns_name']
+        fields = ['id', 'dns_name', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -536,6 +536,10 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 
 
 class VLANGroupFilterSet(OrganizationalModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     scope_type = ContentTypeFilter()
     region = django_filters.NumberFilter(
         method='filter_scope'
@@ -563,6 +567,15 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
         model = VLANGroup
         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):
         return queryset.filter(
             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'],
         ['location', 'rack']
     ]
+    q = forms.CharField(
+        required=False,
+        label=_('Search')
+    )
     region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         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):
     """
     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):
     """
     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)
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class IPAddress(PrimaryModel):
     """
     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):
     """
     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
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 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

+ 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):
     """
     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):
     """
     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)
     scope_type = ContentTypeColumn()
     scope = tables.Column(
-        linkify=True
+        linkify=True,
+        orderable=False
     )
     vlan_count = LinkedCountColumn(
         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)
 
         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.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.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::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'),
@@ -598,6 +598,10 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'dns_name': ['ipaddress-a', 'ipaddress-b']}
         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):
         params = {'parent': '10.0.0.0/24'}
         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
 
     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:
-            # 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)
 
 

+ 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):
     """
     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')}),
     )
     filter_horizontal = ('groups',)
+    list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name')
 
     def get_inlines(self, request, obj):
         if obj is not None:

+ 5 - 3
netbox/utilities/choices.py

@@ -130,22 +130,24 @@ class ColorChoices(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'
     BLUE = 'primary'
-    GREY = 'secondary'
+    CYAN = 'info'
     GREEN = 'success'
     RED = 'danger'
     YELLOW = 'warning'
+    GREY = 'secondary'
     BLACK = 'dark'
 
     CHOICES = (
         (DEFAULT, 'Default'),
         (BLUE, 'Blue'),
-        (GREY, 'Grey'),
+        (CYAN, 'Cyan'),
         (GREEN, 'Green'),
         (RED, 'Red'),
         (YELLOW, 'Yellow'),
+        (GREY, 'Grey'),
         (BLACK, 'Black')
     )

+ 10 - 2
netbox/utilities/paginator.py

@@ -4,7 +4,9 @@ from django.core.paginator import Paginator, Page
 
 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:
             per_page = int(per_page)
             if per_page < 1:
@@ -12,7 +14,13 @@ class EnhancedPaginator(Paginator):
         except ValueError:
             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):
         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.db.models.fields.related import RelatedField
 from django.urls import reverse
-from django.utils.html import strip_tags
 from django.utils.safestring import mark_safe
 from django_tables2 import RequestConfig
 from django_tables2.data import TableQuerysetData
@@ -15,19 +14,6 @@ from extras.models import CustomField
 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):
     """
     Default table for object lists

+ 3 - 3
netbox/virtualization/models.py

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

+ 3 - 3
requirements.txt

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