Browse Source

Merge pull request #7018 from netbox-community/develop

Release v2.11.12
Jeremy Stretch 4 years ago
parent
commit
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
         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.11
+      placeholder: v2.11.12
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

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

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

@@ -1,5 +1,26 @@
 # NetBox v2.11
 # 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)
 ## v2.11.11 (2021-08-12)
 
 
 ### Enhancements
 ### Enhancements

+ 5 - 0
netbox/dcim/choices.py

@@ -553,6 +553,8 @@ class PowerOutletTypeChoices(ChoiceSet):
     # Proprietary
     # Proprietary
     TYPE_HDOT_CX = 'hdot-cx'
     TYPE_HDOT_CX = 'hdot-cx'
     TYPE_SAF_D_GRID = 'saf-d-grid'
     TYPE_SAF_D_GRID = 'saf-d-grid'
+    # Other
+    TYPE_HARDWIRED = 'hardwired'
 
 
     CHOICES = (
     CHOICES = (
         ('IEC 60320', (
         ('IEC 60320', (
@@ -654,6 +656,9 @@ class PowerOutletTypeChoices(ChoiceSet):
             (TYPE_HDOT_CX, 'HDOT Cx'),
             (TYPE_HDOT_CX, 'HDOT Cx'),
             (TYPE_SAF_D_GRID, 'Saf-D-Grid'),
             (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 extras.models import Tag
 from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
 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.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
@@ -2421,8 +2421,8 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
 class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm):
 class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm):
     model = Device
     model = Device
     field_order = [
     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(
     q = forms.CharField(
         required=False,
         required=False,
@@ -2433,11 +2433,17 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
         required=False,
         required=False,
         label=_('Region')
         label=_('Region')
     )
     )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group')
+    )
     site_id = DynamicModelMultipleChoiceField(
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         query_params={
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
         },
         },
         label=_('Site')
         label=_('Site')
     )
     )
@@ -3103,15 +3109,26 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
             'type': 'lag',
             'type': 'lag',
         }
         }
     )
     )
+    vlan_group = DynamicModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        label='VLAN group'
+    )
     untagged_vlan = DynamicModelChoiceField(
     untagged_vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
-        label='Untagged VLAN'
+        label='Untagged VLAN',
+        query_params={
+            'group_id': '$vlan_group',
+        }
     )
     )
     tagged_vlans = DynamicModelMultipleChoiceField(
     tagged_vlans = DynamicModelMultipleChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
-        label='Tagged VLANs'
+        label='Tagged VLANs',
+        query_params={
+            'group_id': '$vlan_group',
+        }
     )
     )
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         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 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 utilities.utils import curry
 from .webhooks import flush_webhooks
 from .webhooks import flush_webhooks
 
 
@@ -20,11 +20,13 @@ def change_logging(request):
     # Curry signals receivers to pass the current request
     # Curry signals receivers to pass the current request
     handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
     handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
     handle_deleted_object = curry(_handle_deleted_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.
     # Connect our receivers to the post_save and post_delete signals.
     post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
     post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
     m2m_changed.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')
     pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
+    clear_webhooks.connect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
 
 
     yield
     yield
 
 
@@ -33,6 +35,7 @@ def change_logging(request):
     post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
     post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
     m2m_changed.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')
     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 queued webhooks to RQ
     flush_webhooks(webhook_queue)
     flush_webhooks(webhook_queue)

+ 5 - 1
netbox/extras/forms.py

@@ -77,7 +77,11 @@ class CustomFieldModelForm(forms.ModelForm):
 
 
         # Save custom field data on instance
         # Save custom field data on instance
         for cf_name in self.custom_fields:
         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()
         return super().clean()
 
 

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

@@ -37,12 +37,10 @@ class WebhookHandler(BaseHTTPRequestHandler):
         self.end_headers()
         self.end_headers()
         self.wfile.write(b'Webhook received!\n')
         self.wfile.write(b'Webhook received!\n')
 
 
-        request_counter += 1
-
-        # Print the request headers to stdout
+        # Print the request headers
         if self.show_headers:
         if self.show_headers:
             for k, v in self.headers.items():
             for k, v in self.headers.items():
-                print('{}: {}'.format(k, v))
+                print(f'{k}: {v}')
             print()
             print()
 
 
         # Print the request body (if any)
         # Print the request body (if any)
@@ -55,8 +53,11 @@ class WebhookHandler(BaseHTTPRequestHandler):
         else:
         else:
             print('(No body)')
             print('(No body)')
 
 
+        print(f'Completed request #{request_counter}')
         print('------------')
         print('------------')
 
 
+        request_counter += 1
+
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
     help = "Start a simple listener to display received HTTP requests"
     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
         # Cache instance's original name so we can check later whether it has changed
         self._name = self.name
         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()
             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:
             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)
             model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
 
 
     def remove_stale_data(self, content_types):
     def remove_stale_data(self, content_types):
@@ -139,9 +139,22 @@ class CustomField(BigIDModel):
         """
         """
         for ct in content_types:
         for ct in content_types:
             model = ct.model_class()
             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):
     def clean(self):
         super().clean()
         super().clean()

+ 26 - 1
netbox/extras/signals.py

@@ -1,3 +1,4 @@
+import logging
 import random
 import random
 from datetime import timedelta
 from datetime import timedelta
 
 
@@ -6,6 +7,7 @@ from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db import DEFAULT_DB_ALIAS
 from django.db import DEFAULT_DB_ALIAS
 from django.db.models.signals import m2m_changed, post_save, pre_delete
 from django.db.models.signals import m2m_changed, post_save, pre_delete
+from django.dispatch import Signal
 from django.utils import timezone
 from django.utils import timezone
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 from prometheus_client import Counter
 from prometheus_client import Counter
@@ -19,6 +21,10 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
 # Change logging/webhooks
 # 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):
 def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
     """
     """
     Fires when an object is created or updated.
     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()
     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
 # 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):
 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.
     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())
     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)
 post_save.connect(handle_cf_renamed, sender=CustomField)
 pre_delete.connect(handle_cf_deleted, 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.save()
             cf.content_types.set([obj_type])
             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()
             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.custom_field_data[cf.name] = data['field_value']
             site.save()
             site.save()
 
 
@@ -73,8 +76,11 @@ class CustomFieldTest(TestCase):
         cf.save()
         cf.save()
         cf.content_types.set([obj_type])
         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()
         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.custom_field_data[cf.name] = 'Option A'
         site.save()
         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)
         lhs, lhs_params = self.process_lhs(qn, connection)
         rhs, rhs_params = self.process_rhs(qn, connection)
         rhs, rhs_params = self.process_rhs(qn, connection)
         params = lhs_params + rhs_params
         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):
 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 dcim.models import Device, Interface
 from netbox.views import generic
 from netbox.views import generic
+from utilities.forms import TableConfigForm
 from utilities.tables import paginate_table
 from utilities.tables import paginate_table
 from utilities.utils import count_related
 from utilities.utils import count_related
 from virtualization.models import VirtualMachine, VMInterface
 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':
         if child_prefixes and request.GET.get('show_available', 'true') == 'true':
             child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
             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'):
         if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
             prefix_table.columns.show('pk')
             prefix_table.columns.show('pk')
         paginate_table(prefix_table, request)
         paginate_table(prefix_table, request)
@@ -433,6 +434,7 @@ class PrefixPrefixesView(generic.ObjectView):
             'bulk_querystring': bulk_querystring,
             'bulk_querystring': bulk_querystring,
             'active_tab': 'prefixes',
             'active_tab': 'prefixes',
             'show_available': request.GET.get('show_available', 'true') == 'true',
             '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/
 #   https://docs.djangoproject.com/en/stable/topics/logging/
 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
 # 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.
 # are permitted to access most data in NetBox (excluding secrets) but not make any changes.
 LOGIN_REQUIRED = False
 LOGIN_REQUIRED = False

+ 3 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '2.11.11'
+VERSION = '2.11.12'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()
@@ -103,6 +103,7 @@ NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '')
 NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30)
 NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30)
 NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
 NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
 PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
 PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
+LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
 PLUGINS = getattr(configuration, 'PLUGINS', [])
 PLUGINS = getattr(configuration, 'PLUGINS', [])
 PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
 PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
 PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
 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:
 if LOGIN_TIMEOUT is not None:
     # Django default is 1209600 seconds (14 days)
     # Django default is 1209600 seconds (14 days)
     SESSION_COOKIE_AGE = LOGIN_TIMEOUT
     SESSION_COOKIE_AGE = LOGIN_TIMEOUT
+SESSION_SAVE_EVERY_REQUEST = bool(LOGIN_PERSISTENCE)
 if SESSION_FILE_PATH is not None:
 if SESSION_FILE_PATH is not None:
     SESSION_ENGINE = 'django.contrib.sessions.backends.file'
     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 django_tables2.export import TableExport
 
 
 from extras.models import CustomField, ExportTemplate
 from extras.models import CustomField, ExportTemplate
+from extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortTransaction, PermissionsViolation
 from utilities.exceptions import AbortTransaction, PermissionsViolation
 from utilities.forms import (
 from utilities.forms import (
@@ -325,6 +326,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 msg = "Object save failed due to object-level permissions violation"
                 msg = "Object save failed due to object-level permissions violation"
                 logger.debug(msg)
                 logger.debug(msg)
                 form.add_error(None, msg)
                 form.add_error(None, msg)
+                clear_webhooks.send(sender=self)
 
 
         else:
         else:
             logger.debug("Form validation failed")
             logger.debug("Form validation failed")
@@ -603,12 +605,13 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                                 raise ObjectDoesNotExist
                                 raise ObjectDoesNotExist
 
 
                 except AbortTransaction:
                 except AbortTransaction:
-                    pass
+                    clear_webhooks.send(sender=self)
 
 
                 except PermissionsViolation:
                 except PermissionsViolation:
                     msg = "Object creation failed due to object-level permissions violation"
                     msg = "Object creation failed due to object-level permissions violation"
                     logger.debug(msg)
                     logger.debug(msg)
                     form.add_error(None, msg)
                     form.add_error(None, msg)
+                    clear_webhooks.send(sender=self)
 
 
             if not model_form.errors:
             if not model_form.errors:
                 logger.info(f"Import object {obj} (PK: {obj.pk})")
                 logger.info(f"Import object {obj} (PK: {obj.pk})")
@@ -751,12 +754,13 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                     })
                     })
 
 
             except ValidationError:
             except ValidationError:
-                pass
+                clear_webhooks.send(sender=self)
 
 
             except PermissionsViolation:
             except PermissionsViolation:
                 msg = "Object import failed due to object-level permissions violation"
                 msg = "Object import failed due to object-level permissions violation"
                 logger.debug(msg)
                 logger.debug(msg)
                 form.add_error(None, msg)
                 form.add_error(None, msg)
+                clear_webhooks.send(sender=self)
 
 
         else:
         else:
             logger.debug("Form validation failed")
             logger.debug("Form validation failed")
@@ -879,11 +883,13 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
 
                 except ValidationError as e:
                 except ValidationError as e:
                     messages.error(self.request, "{} failed validation: {}".format(obj, e))
                     messages.error(self.request, "{} failed validation: {}".format(obj, e))
+                    clear_webhooks.send(sender=self)
 
 
                 except PermissionsViolation:
                 except PermissionsViolation:
                     msg = "Object update failed due to object-level permissions violation"
                     msg = "Object update failed due to object-level permissions violation"
                     logger.debug(msg)
                     logger.debug(msg)
                     form.add_error(None, msg)
                     form.add_error(None, msg)
+                    clear_webhooks.send(sender=self)
 
 
             else:
             else:
                 logger.debug("Form validation failed")
                 logger.debug("Form validation failed")
@@ -987,6 +993,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                     msg = "Object update failed due to object-level permissions violation"
                     msg = "Object update failed due to object-level permissions violation"
                     logger.debug(msg)
                     logger.debug(msg)
                     form.add_error(None, msg)
                     form.add_error(None, msg)
+                    clear_webhooks.send(sender=self)
 
 
         else:
         else:
             form = self.form(initial={'pk': request.POST.getlist('pk')})
             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"
                     msg = "Component creation failed due to object-level permissions violation"
                     logger.debug(msg)
                     logger.debug(msg)
                     form.add_error(None, msg)
                     form.add_error(None, msg)
+                    clear_webhooks.send(sender=self)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'component_type': self.queryset.model._meta.verbose_name,
             'component_type': self.queryset.model._meta.verbose_name,
@@ -1264,12 +1272,13 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
                             raise PermissionsViolation
                             raise PermissionsViolation
 
 
                 except IntegrityError:
                 except IntegrityError:
-                    pass
+                    clear_webhooks.send(sender=self)
 
 
                 except PermissionsViolation:
                 except PermissionsViolation:
                     msg = "Component creation failed due to object-level permissions violation"
                     msg = "Component creation failed due to object-level permissions violation"
                     logger.debug(msg)
                     logger.debug(msg)
                     form.add_error(None, msg)
                     form.add_error(None, msg)
+                    clear_webhooks.send(sender=self)
 
 
                 if not form.errors:
                 if not form.errors:
                     msg = "Added {} {} to {} {}.".format(
                     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_untagged_vlan').trigger('change');
                 $('select#id_tagged_vlans').val([]);
                 $('select#id_tagged_vlans').val([]);
                 $('select#id_tagged_vlans').trigger('change');
                 $('select#id_tagged_vlans').trigger('change');
+                $('select#id_vlan_group').parent().parent().hide();
                 $('select#id_untagged_vlan').parent().parent().hide();
                 $('select#id_untagged_vlan').parent().parent().hide();
                 $('select#id_tagged_vlans').parent().parent().hide();
                 $('select#id_tagged_vlans').parent().parent().hide();
             }
             }
             else if ($(this).val() == 'access') {
             else if ($(this).val() == 'access') {
                 $('select#id_tagged_vlans').val([]);
                 $('select#id_tagged_vlans').val([]);
                 $('select#id_tagged_vlans').trigger('change');
                 $('select#id_tagged_vlans').trigger('change');
+                $('select#id_vlan_group').parent().parent().show();
                 $('select#id_untagged_vlan').parent().parent().show();
                 $('select#id_untagged_vlan').parent().parent().show();
                 $('select#id_tagged_vlans').parent().parent().hide();
                 $('select#id_tagged_vlans').parent().parent().hide();
             }
             }
             else if ($(this).val() == 'tagged') {
             else if ($(this).val() == 'tagged') {
+                $('select#id_vlan_group').parent().parent().show();
                 $('select#id_untagged_vlan').parent().parent().show();
                 $('select#id_untagged_vlan').parent().parent().show();
                 $('select#id_tagged_vlans').parent().parent().show();
                 $('select#id_tagged_vlans').parent().parent().show();
             }
             }
             else if ($(this).val() == 'tagged-all') {
             else if ($(this).val() == 'tagged-all') {
                 $('select#id_tagged_vlans').val([]);
                 $('select#id_tagged_vlans').val([]);
                 $('select#id_tagged_vlans').trigger('change');
                 $('select#id_tagged_vlans').trigger('change');
+                $('select#id_vlan_group').parent().parent().show();
                 $('select#id_untagged_vlan').parent().parent().show();
                 $('select#id_untagged_vlan').parent().parent().show();
                 $('select#id_tagged_vlans').parent().parent().hide();
                 $('select#id_tagged_vlans').parent().parent().hide();
             }
             }

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

@@ -2,7 +2,7 @@
 
 
 {% block bulk_buttons %}
 {% block bulk_buttons %}
     {% if perms.dcim.change_device %}
     {% 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">
             <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>
                 <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Components <span class="caret"></span>
             </button>
             </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-heading"><strong>802.1Q Switching</strong></div>
         <div class="panel-body">
         <div class="panel-body">
             {% render_field form.mode %}
             {% render_field form.mode %}
+            {% render_field form.vlan_group %}
             {% render_field form.untagged_vlan %}
             {% render_field form.untagged_vlan %}
             {% render_field form.tagged_vlans %}
             {% render_field form.tagged_vlans %}
         </div>
         </div>

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

@@ -56,7 +56,7 @@
                     <td>Role</td>
                     <td>Role</td>
                     <td>
                     <td>
                         {% if object.role %}
                         {% 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 %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>
                         {% endif %}
                         {% endif %}

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

@@ -1,7 +1,12 @@
 {% extends 'ipam/prefix/base.html' %}
 {% extends 'ipam/prefix/base.html' %}
+{% load helpers %}
+{% load static %}
 
 
 {% block buttons %}
 {% block buttons %}
   {% include 'ipam/inc/toggle_available.html' %}
   {% 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 %}
   {% 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">
     <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
       <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 %}
       {% 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>
   </div>
   </div>
+  {% table_config_form prefix_table table_name="PrefixDetailTable" %}
+{% endblock %}
+
+{% block javascript %}
+<script src="{% static 'js/tableconfig.js' %}"></script>
 {% endblock %}
 {% 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-heading"><strong>802.1Q Switching</strong></div>
         <div class="panel-body">
         <div class="panel-body">
             {% render_field form.mode %}
             {% render_field form.mode %}
+            {% render_field form.vlan_group %}
             {% render_field form.untagged_vlan %}
             {% render_field form.untagged_vlan %}
             {% render_field form.tagged_vlans %}
             {% render_field form.tagged_vlans %}
         </div>
         </div>

+ 18 - 3
netbox/virtualization/forms.py

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

+ 3 - 3
requirements.txt

@@ -1,9 +1,9 @@
 Django==3.2.6
 Django==3.2.6
 django-cacheops==6.0
 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-filter==2.4.0
-django-mptt==0.12.0
+django-mptt==0.13.1
 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