소스 검색

Merge pull request #7018 from netbox-community/develop

Release v2.11.12
Jeremy Stretch 4 년 전
부모
커밋
9cc4992fad

+ 1 - 1
.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.11
+      placeholder: v2.11.12
     validations:
       required: true
   - type: dropdown

+ 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.11
+      placeholder: v2.11.12
     validations:
       required: true
   - type: dropdown

+ 10 - 0
docs/configuration/optional-settings.md

@@ -257,6 +257,16 @@ LOGGING = {
 
 ---
 
+## LOGIN_PERSISTENCE
+
+Default: False
+
+If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
+
+Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely.
+
+---
+
 ## LOGIN_REQUIRED
 
 Default: False

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

@@ -1,5 +1,26 @@
 # NetBox v2.11
 
+## v2.11.12 (2021-08-23)
+
+### Enhancements
+
+* [#6748](https://github.com/netbox-community/netbox/issues/6748) - Add site group filter to devices list
+* [#6790](https://github.com/netbox-community/netbox/issues/6790) - Recognize a /32 IPv4 address as a child of a /32 IPv4 prefix
+* [#6872](https://github.com/netbox-community/netbox/issues/6872) - Add table configuration button to child prefixes view
+* [#6929](https://github.com/netbox-community/netbox/issues/6929) - Introduce `LOGIN_PERSISTENCE` configuration parameter to persist user sessions
+* [#7011](https://github.com/netbox-community/netbox/issues/7011) - Add search field to VM interfaces filter form
+
+### Bug Fixes
+
+* [#5968](https://github.com/netbox-community/netbox/issues/5968) - Model forms should save empty custom field values as null
+* [#6326](https://github.com/netbox-community/netbox/issues/6326) - Enable filtering assigned VLANs by group in interface edit form
+* [#6686](https://github.com/netbox-community/netbox/issues/6686) - Force assignment of null custom field values to objects
+* [#6776](https://github.com/netbox-community/netbox/issues/6776) - Fix erroneous webhook dispatch on failure to save objects
+* [#6974](https://github.com/netbox-community/netbox/issues/6974) - Show contextual label for IP address role
+* [#7012](https://github.com/netbox-community/netbox/issues/7012) - Fix hidden "add components" dropdown on devices list
+
+---
+
 ## v2.11.11 (2021-08-12)
 
 ### Enhancements

+ 5 - 0
netbox/dcim/choices.py

@@ -553,6 +553,8 @@ class PowerOutletTypeChoices(ChoiceSet):
     # Proprietary
     TYPE_HDOT_CX = 'hdot-cx'
     TYPE_SAF_D_GRID = 'saf-d-grid'
+    # Other
+    TYPE_HARDWIRED = 'hardwired'
 
     CHOICES = (
         ('IEC 60320', (
@@ -654,6 +656,9 @@ class PowerOutletTypeChoices(ChoiceSet):
             (TYPE_HDOT_CX, 'HDOT Cx'),
             (TYPE_SAF_D_GRID, 'Saf-D-Grid'),
         )),
+        ('Other', (
+            (TYPE_HARDWIRED, 'Hardwired'),
+        )),
     )
 
 

+ 23 - 6
netbox/dcim/forms.py

@@ -18,7 +18,7 @@ from extras.forms import (
 )
 from extras.models import Tag
 from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
-from ipam.models import IPAddress, VLAN
+from ipam.models import IPAddress, VLAN, VLANGroup
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
@@ -2421,8 +2421,8 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
 class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm):
     model = Device
     field_order = [
-        'q', 'region_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id',
-        'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
+        'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id',
+        'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
     ]
     q = forms.CharField(
         required=False,
@@ -2433,11 +2433,17 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
         required=False,
         label=_('Region')
     )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group')
+    )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         required=False,
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
         },
         label=_('Site')
     )
@@ -3103,15 +3109,26 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
             'type': 'lag',
         }
     )
+    vlan_group = DynamicModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        label='VLAN group'
+    )
     untagged_vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         required=False,
-        label='Untagged VLAN'
+        label='Untagged VLAN',
+        query_params={
+            'group_id': '$vlan_group',
+        }
     )
     tagged_vlans = DynamicModelMultipleChoiceField(
         queryset=VLAN.objects.all(),
         required=False,
-        label='Tagged VLANs'
+        label='Tagged VLANs',
+        query_params={
+            'group_id': '$vlan_group',
+        }
     )
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),

+ 4 - 1
netbox/extras/context_managers.py

@@ -2,7 +2,7 @@ from contextlib import contextmanager
 
 from django.db.models.signals import m2m_changed, pre_delete, post_save
 
-from extras.signals import _handle_changed_object, _handle_deleted_object
+from extras.signals import clear_webhooks, _clear_webhook_queue, _handle_changed_object, _handle_deleted_object
 from utilities.utils import curry
 from .webhooks import flush_webhooks
 
@@ -20,11 +20,13 @@ def change_logging(request):
     # Curry signals receivers to pass the current request
     handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
     handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue)
+    clear_webhook_queue = curry(_clear_webhook_queue, webhook_queue)
 
     # Connect our receivers to the post_save and post_delete signals.
     post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
     m2m_changed.connect(handle_changed_object, dispatch_uid='handle_changed_object')
     pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
+    clear_webhooks.connect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
 
     yield
 
@@ -33,6 +35,7 @@ def change_logging(request):
     post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
     m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
     pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
+    clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
 
     # Flush queued webhooks to RQ
     flush_webhooks(webhook_queue)

+ 5 - 1
netbox/extras/forms.py

@@ -77,7 +77,11 @@ class CustomFieldModelForm(forms.ModelForm):
 
         # Save custom field data on instance
         for cf_name in self.custom_fields:
-            self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name)
+            key = cf_name[3:]  # Strip "cf_" from field name
+            value = self.cleaned_data.get(cf_name)
+            empty_values = self.fields[cf_name].empty_values
+            # Convert "empty" values to null
+            self.instance.custom_field_data[key] = value if value not in empty_values else None
 
         return super().clean()
 

+ 5 - 4
netbox/extras/management/commands/webhook_receiver.py

@@ -37,12 +37,10 @@ class WebhookHandler(BaseHTTPRequestHandler):
         self.end_headers()
         self.wfile.write(b'Webhook received!\n')
 
-        request_counter += 1
-
-        # Print the request headers to stdout
+        # Print the request headers
         if self.show_headers:
             for k, v in self.headers.items():
-                print('{}: {}'.format(k, v))
+                print(f'{k}: {v}')
             print()
 
         # Print the request body (if any)
@@ -55,8 +53,11 @@ class WebhookHandler(BaseHTTPRequestHandler):
         else:
             print('(No body)')
 
+        print(f'Completed request #{request_counter}')
         print('------------')
 
+        request_counter += 1
+
 
 class Command(BaseCommand):
     help = "Start a simple listener to display received HTTP requests"

+ 22 - 9
netbox/extras/models/customfields.py

@@ -120,16 +120,16 @@ class CustomField(BigIDModel):
         # Cache instance's original name so we can check later whether it has changed
         self._name = self.name
 
-    def rename_object_data(self, old_name, new_name):
+    def populate_initial_data(self, content_types):
         """
-        Called when a CustomField has been renamed. Updates all assigned object data.
+        Populate initial custom field data upon either a) the creation of a new CustomField, or
+        b) the assignment of an existing CustomField to new object types.
         """
-        for ct in self.content_types.all():
+        for ct in content_types:
             model = ct.model_class()
-            params = {f'custom_field_data__{old_name}__isnull': False}
-            instances = model.objects.filter(**params)
+            instances = model.objects.exclude(**{f'custom_field_data__contains': self.name})
             for instance in instances:
-                instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
+                instance.custom_field_data[self.name] = self.default
             model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
 
     def remove_stale_data(self, content_types):
@@ -139,9 +139,22 @@ class CustomField(BigIDModel):
         """
         for ct in content_types:
             model = ct.model_class()
-            for obj in model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False}):
-                del(obj.custom_field_data[self.name])
-                obj.save()
+            instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
+            for instance in instances:
+                del(instance.custom_field_data[self.name])
+            model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
+
+    def rename_object_data(self, old_name, new_name):
+        """
+        Called when a CustomField has been renamed. Updates all assigned object data.
+        """
+        for ct in self.content_types.all():
+            model = ct.model_class()
+            params = {f'custom_field_data__{old_name}__isnull': False}
+            instances = model.objects.filter(**params)
+            for instance in instances:
+                instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
+            model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
 
     def clean(self):
         super().clean()

+ 26 - 1
netbox/extras/signals.py

@@ -1,3 +1,4 @@
+import logging
 import random
 from datetime import timedelta
 
@@ -6,6 +7,7 @@ from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.db import DEFAULT_DB_ALIAS
 from django.db.models.signals import m2m_changed, post_save, pre_delete
+from django.dispatch import Signal
 from django.utils import timezone
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 from prometheus_client import Counter
@@ -19,6 +21,10 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
 # Change logging/webhooks
 #
 
+# Define a custom signal that can be sent to clear any queued webhooks
+clear_webhooks = Signal()
+
+
 def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
     """
     Fires when an object is created or updated.
@@ -104,10 +110,28 @@ def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
     model_deletes.labels(instance._meta.model_name).inc()
 
 
+def _clear_webhook_queue(webhook_queue, sender, **kwargs):
+    """
+    Delete any queued webhooks (e.g. because of an aborted bulk transaction)
+    """
+    logger = logging.getLogger('webhooks')
+    logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
+
+    webhook_queue.clear()
+
+
 #
 # Custom fields
 #
 
+def handle_cf_added_obj_types(instance, action, pk_set, **kwargs):
+    """
+    Handle the population of default/null values when a CustomField is added to one or more ContentTypes.
+    """
+    if action == 'post_add':
+        instance.populate_initial_data(ContentType.objects.filter(pk__in=pk_set))
+
+
 def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs):
     """
     Handle the cleanup of old custom field data when a CustomField is removed from one or more ContentTypes.
@@ -131,9 +155,10 @@ def handle_cf_deleted(instance, **kwargs):
     instance.remove_stale_data(instance.content_types.all())
 
 
-m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
 post_save.connect(handle_cf_renamed, sender=CustomField)
 pre_delete.connect(handle_cf_deleted, sender=CustomField)
+m2m_changed.connect(handle_cf_added_obj_types, sender=CustomField.content_types.through)
+m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
 
 
 #

+ 8 - 2
netbox/extras/tests/test_customfields.py

@@ -42,8 +42,11 @@ class CustomFieldTest(TestCase):
             cf.save()
             cf.content_types.set([obj_type])
 
-            # Assign a value to the first Site
+            # Check that the field has a null initial value
             site = Site.objects.first()
+            self.assertIsNone(site.custom_field_data[cf.name])
+
+            # Assign a value to the first Site
             site.custom_field_data[cf.name] = data['field_value']
             site.save()
 
@@ -73,8 +76,11 @@ class CustomFieldTest(TestCase):
         cf.save()
         cf.content_types.set([obj_type])
 
-        # Assign a value to the first Site
+        # Check that the field has a null initial value
         site = Site.objects.first()
+        self.assertIsNone(site.custom_field_data[cf.name])
+
+        # Assign a value to the first Site
         site.custom_field_data[cf.name] = 'Option A'
         site.save()
 

+ 53 - 0
netbox/extras/tests/test_forms.py

@@ -0,0 +1,53 @@
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase
+
+from dcim.forms import SiteForm
+from dcim.models import Site
+from extras.choices import CustomFieldTypeChoices
+from extras.models import CustomField
+
+
+class CustomFieldModelFormTest(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        obj_type = ContentType.objects.get_for_model(Site)
+        CHOICES = ('A', 'B', 'C')
+
+        cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
+        cf_text.content_types.set([obj_type])
+
+        cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER)
+        cf_integer.content_types.set([obj_type])
+
+        cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
+        cf_boolean.content_types.set([obj_type])
+
+        cf_date = CustomField.objects.create(name='date', type=CustomFieldTypeChoices.TYPE_DATE)
+        cf_date.content_types.set([obj_type])
+
+        cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL)
+        cf_url.content_types.set([obj_type])
+
+        cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
+        cf_select.content_types.set([obj_type])
+
+        cf_multiselect = CustomField.objects.create(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT,
+                                                    choices=CHOICES)
+        cf_multiselect.content_types.set([obj_type])
+
+    def test_empty_values(self):
+        """
+        Test that empty custom field values are stored as null
+        """
+        form = SiteForm({
+            'name': 'Site 1',
+            'slug': 'site-1',
+            'status': 'active',
+        })
+        self.assertTrue(form.is_valid())
+        instance = form.save()
+
+        for field_type, _ in CustomFieldTypeChoices.CHOICES:
+            self.assertIn(field_type, instance.custom_field_data)
+            self.assertIsNone(instance.custom_field_data[field_type])

+ 1 - 1
netbox/ipam/lookups.py

@@ -151,7 +151,7 @@ class NetHostContained(Lookup):
         lhs, lhs_params = self.process_lhs(qn, connection)
         rhs, rhs_params = self.process_rhs(qn, connection)
         params = lhs_params + rhs_params
-        return 'CAST(HOST(%s) AS INET) << %s' % (lhs, rhs), params
+        return 'CAST(HOST(%s) AS INET) <<= %s' % (lhs, rhs), params
 
 
 class NetFamily(Transform):

+ 3 - 1
netbox/ipam/views.py

@@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404, redirect, render
 
 from dcim.models import Device, Interface
 from netbox.views import generic
+from utilities.forms import TableConfigForm
 from utilities.tables import paginate_table
 from utilities.utils import count_related
 from virtualization.models import VirtualMachine, VMInterface
@@ -412,7 +413,7 @@ class PrefixPrefixesView(generic.ObjectView):
         if child_prefixes and request.GET.get('show_available', 'true') == 'true':
             child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
 
-        prefix_table = tables.PrefixDetailTable(child_prefixes)
+        prefix_table = tables.PrefixDetailTable(child_prefixes, user=request.user)
         if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
             prefix_table.columns.show('pk')
         paginate_table(prefix_table, request)
@@ -433,6 +434,7 @@ class PrefixPrefixesView(generic.ObjectView):
             'bulk_querystring': bulk_querystring,
             'active_tab': 'prefixes',
             'show_available': request.GET.get('show_available', 'true') == 'true',
+            'table_config_form': TableConfigForm(table=prefix_table),
         }
 
 

+ 4 - 0
netbox/netbox/configuration.example.py

@@ -149,6 +149,10 @@ INTERNAL_IPS = ('127.0.0.1', '::1')
 #   https://docs.djangoproject.com/en/stable/topics/logging/
 LOGGING = {}
 
+# Automatically reset the lifetime of a valid session upon each authenticated request. Enables users to remain
+# authenticated to NetBox indefinitely.
+LOGIN_PERSISTENCE = False
+
 # Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
 # are permitted to access most data in NetBox (excluding secrets) but not make any changes.
 LOGIN_REQUIRED = False

+ 3 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '2.11.11'
+VERSION = '2.11.12'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -103,6 +103,7 @@ NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '')
 NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30)
 NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
 PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
+LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
 PLUGINS = getattr(configuration, 'PLUGINS', [])
 PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
 PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
@@ -251,6 +252,7 @@ CACHING_REDIS_SKIP_TLS_VERIFY = CACHING_REDIS.get('INSECURE_SKIP_TLS_VERIFY', Fa
 if LOGIN_TIMEOUT is not None:
     # Django default is 1209600 seconds (14 days)
     SESSION_COOKIE_AGE = LOGIN_TIMEOUT
+SESSION_SAVE_EVERY_REQUEST = bool(LOGIN_PERSISTENCE)
 if SESSION_FILE_PATH is not None:
     SESSION_ENGINE = 'django.contrib.sessions.backends.file'
 

+ 12 - 3
netbox/netbox/views/generic.py

@@ -17,6 +17,7 @@ from django.views.generic import View
 from django_tables2.export import TableExport
 
 from extras.models import CustomField, ExportTemplate
+from extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortTransaction, PermissionsViolation
 from utilities.forms import (
@@ -325,6 +326,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 msg = "Object save failed due to object-level permissions violation"
                 logger.debug(msg)
                 form.add_error(None, msg)
+                clear_webhooks.send(sender=self)
 
         else:
             logger.debug("Form validation failed")
@@ -603,12 +605,13 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                                 raise ObjectDoesNotExist
 
                 except AbortTransaction:
-                    pass
+                    clear_webhooks.send(sender=self)
 
                 except PermissionsViolation:
                     msg = "Object creation failed due to object-level permissions violation"
                     logger.debug(msg)
                     form.add_error(None, msg)
+                    clear_webhooks.send(sender=self)
 
             if not model_form.errors:
                 logger.info(f"Import object {obj} (PK: {obj.pk})")
@@ -751,12 +754,13 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                     })
 
             except ValidationError:
-                pass
+                clear_webhooks.send(sender=self)
 
             except PermissionsViolation:
                 msg = "Object import failed due to object-level permissions violation"
                 logger.debug(msg)
                 form.add_error(None, msg)
+                clear_webhooks.send(sender=self)
 
         else:
             logger.debug("Form validation failed")
@@ -879,11 +883,13 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
                 except ValidationError as e:
                     messages.error(self.request, "{} failed validation: {}".format(obj, e))
+                    clear_webhooks.send(sender=self)
 
                 except PermissionsViolation:
                     msg = "Object update failed due to object-level permissions violation"
                     logger.debug(msg)
                     form.add_error(None, msg)
+                    clear_webhooks.send(sender=self)
 
             else:
                 logger.debug("Form validation failed")
@@ -987,6 +993,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                     msg = "Object update failed due to object-level permissions violation"
                     logger.debug(msg)
                     form.add_error(None, msg)
+                    clear_webhooks.send(sender=self)
 
         else:
             form = self.form(initial={'pk': request.POST.getlist('pk')})
@@ -1183,6 +1190,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
                     msg = "Component creation failed due to object-level permissions violation"
                     logger.debug(msg)
                     form.add_error(None, msg)
+                    clear_webhooks.send(sender=self)
 
         return render(request, self.template_name, {
             'component_type': self.queryset.model._meta.verbose_name,
@@ -1264,12 +1272,13 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
                             raise PermissionsViolation
 
                 except IntegrityError:
-                    pass
+                    clear_webhooks.send(sender=self)
 
                 except PermissionsViolation:
                     msg = "Component creation failed due to object-level permissions violation"
                     logger.debug(msg)
                     form.add_error(None, msg)
+                    clear_webhooks.send(sender=self)
 
                 if not form.errors:
                     msg = "Added {} {} to {} {}.".format(

+ 4 - 0
netbox/project-static/js/forms.js

@@ -337,22 +337,26 @@ $(document).ready(function() {
                 $('select#id_untagged_vlan').trigger('change');
                 $('select#id_tagged_vlans').val([]);
                 $('select#id_tagged_vlans').trigger('change');
+                $('select#id_vlan_group').parent().parent().hide();
                 $('select#id_untagged_vlan').parent().parent().hide();
                 $('select#id_tagged_vlans').parent().parent().hide();
             }
             else if ($(this).val() == 'access') {
                 $('select#id_tagged_vlans').val([]);
                 $('select#id_tagged_vlans').trigger('change');
+                $('select#id_vlan_group').parent().parent().show();
                 $('select#id_untagged_vlan').parent().parent().show();
                 $('select#id_tagged_vlans').parent().parent().hide();
             }
             else if ($(this).val() == 'tagged') {
+                $('select#id_vlan_group').parent().parent().show();
                 $('select#id_untagged_vlan').parent().parent().show();
                 $('select#id_tagged_vlans').parent().parent().show();
             }
             else if ($(this).val() == 'tagged-all') {
                 $('select#id_tagged_vlans').val([]);
                 $('select#id_tagged_vlans').trigger('change');
+                $('select#id_vlan_group').parent().parent().show();
                 $('select#id_untagged_vlan').parent().parent().show();
                 $('select#id_tagged_vlans').parent().parent().hide();
             }

+ 1 - 1
netbox/templates/dcim/device_list.html

@@ -2,7 +2,7 @@
 
 {% block bulk_buttons %}
     {% if perms.dcim.change_device %}
-        <div class="btn-group">
+        <div class="btn-group dropup">
             <button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                 <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Components <span class="caret"></span>
             </button>

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

@@ -33,6 +33,7 @@
         <div class="panel-heading"><strong>802.1Q Switching</strong></div>
         <div class="panel-body">
             {% render_field form.mode %}
+            {% render_field form.vlan_group %}
             {% render_field form.untagged_vlan %}
             {% render_field form.tagged_vlans %}
         </div>

+ 1 - 1
netbox/templates/ipam/ipaddress.html

@@ -56,7 +56,7 @@
                     <td>Role</td>
                     <td>
                         {% if object.role %}
-                            <a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a>
+                            <a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}" class="label label-{{ object.get_role_class }}">{{ object.get_role_display }}</a>
                         {% else %}
                             <span class="text-muted">None</span>
                         {% endif %}

+ 10 - 0
netbox/templates/ipam/prefix/prefixes.html

@@ -1,7 +1,12 @@
 {% extends 'ipam/prefix/base.html' %}
+{% load helpers %}
+{% load static %}
 
 {% block buttons %}
   {% include 'ipam/inc/toggle_available.html' %}
+  {% if request.user.is_authenticated and table_config_form %}
+      <button type="button" class="btn btn-default" data-toggle="modal" data-target="#PrefixDetailTable_config" title="Configure table"><i class="mdi mdi-cog"></i> Configure</button>
+  {% endif %}
   {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
     <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-success">
       <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Child Prefix
@@ -22,4 +27,9 @@
       {% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
     </div>
   </div>
+  {% table_config_form prefix_table table_name="PrefixDetailTable" %}
+{% endblock %}
+
+{% block javascript %}
+<script src="{% static 'js/tableconfig.js' %}"></script>
 {% endblock %}

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

@@ -28,6 +28,7 @@
         <div class="panel-heading"><strong>802.1Q Switching</strong></div>
         <div class="panel-body">
             {% render_field form.mode %}
+            {% render_field form.vlan_group %}
             {% render_field form.untagged_vlan %}
             {% render_field form.tagged_vlans %}
         </div>

+ 18 - 3
netbox/virtualization/forms.py

@@ -12,7 +12,7 @@ from extras.forms import (
     CustomFieldFilterForm,
 )
 from extras.models import Tag
-from ipam.models import IPAddress, VLAN
+from ipam.models import IPAddress, VLAN, VLANGroup
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
@@ -616,15 +616,26 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
         required=False,
         label='Parent interface'
     )
+    vlan_group = DynamicModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        label='VLAN group'
+    )
     untagged_vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         required=False,
-        label='Untagged VLAN'
+        label='Untagged VLAN',
+        query_params={
+            'group_id': '$vlan_group',
+        }
     )
     tagged_vlans = DynamicModelMultipleChoiceField(
         queryset=VLAN.objects.all(),
         required=False,
-        label='Tagged VLANs'
+        label='Tagged VLANs',
+        query_params={
+            'group_id': '$vlan_group',
+        }
     )
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
@@ -834,6 +845,10 @@ class VMInterfaceBulkRenameForm(BulkRenameForm):
 
 class VMInterfaceFilterForm(BootstrapMixin, forms.Form):
     model = VMInterface
+    q = forms.CharField(
+        required=False,
+        label=_('Search')
+    )
     cluster_id = DynamicModelMultipleChoiceField(
         queryset=Cluster.objects.all(),
         required=False,

+ 3 - 3
requirements.txt

@@ -1,9 +1,9 @@
 Django==3.2.6
 django-cacheops==6.0
-django-cors-headers==3.7.0
-django-debug-toolbar==3.2.1
+django-cors-headers==3.8.0
+django-debug-toolbar==3.2.2
 django-filter==2.4.0
-django-mptt==0.12.0
+django-mptt==0.13.1
 django-pglocks==1.0.4
 django-prometheus==2.1.0
 django-rq==2.4.1