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

Merge branch 'develop' into feature

jeremystretch 4 лет назад
Родитель
Сommit
499005f84d

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

@@ -273,6 +273,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 (FUTURE)
+
+### 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 - 7
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 (
@@ -2522,9 +2522,8 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
 class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
     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',
     ]
     field_groups = [
         ['q'],
@@ -2545,11 +2544,17 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
         label=_('Region'),
         fetch_trigger='open'
     )
+    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'),
         fetch_trigger='open'
@@ -3257,15 +3262,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

@@ -480,7 +480,11 @@ class CustomFieldModelForm(CustomFieldsMixin, 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

@@ -125,16 +125,16 @@ class CustomField(ChangeLoggedModel):
         # 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):
@@ -144,9 +144,22 @@ class CustomField(ChangeLoggedModel):
         """
         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()

+ 27 - 3
netbox/extras/signals.py

@@ -1,9 +1,10 @@
+import logging
+
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.db.models.signals import m2m_changed, post_save, pre_delete
-from django.dispatch import receiver
+from django.dispatch import receiver, Signal
 from django_prometheus.models import model_deletes, model_inserts, model_updates
-from prometheus_client import Counter
 
 from netbox.signals import post_clean
 from .choices import ObjectChangeActionChoices
@@ -15,6 +16,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.
@@ -95,10 +100,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.
@@ -122,9 +145,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)
 
-        table = tables.PrefixDetailTable(child_prefixes)
+        table = tables.PrefixDetailTable(child_prefixes, user=request.user)
         if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
             table.columns.show('pk')
         paginate_table(table, request)
@@ -425,6 +426,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=table),
         }
 
 

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

@@ -163,6 +163,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 but not make any changes.
 LOGIN_REQUIRED = False

+ 2 - 0
netbox/netbox/settings.py

@@ -108,6 +108,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)
@@ -263,6 +264,7 @@ if CACHING_REDIS_SKIP_TLS_VERIFY:
 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'
 

+ 13 - 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 ExportTemplate
+from extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortTransaction, PermissionsViolation
 from utilities.forms import (
@@ -302,6 +303,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")
@@ -580,12 +582,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})")
@@ -728,12 +731,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")
@@ -856,11 +860,13 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
                 except ValidationError as e:
                     messages.error(self.request, "{} failed validation: {}".format(obj, ", ".join(e.messages)))
+                    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")
@@ -964,6 +970,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')})
@@ -1177,6 +1184,8 @@ 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 None
 
 
@@ -1253,12 +1262,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(

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

@@ -28,6 +28,7 @@
     <div class="field-group">
         <h4 class="mb-3">802.1Q Switching</h4>
         {% render_field form.mode %}
+        {% render_field form.vlan_group %}
         {% render_field form.untagged_vlan %}
         {% render_field form.tagged_vlans %}
     </div>

+ 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 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

@@ -24,6 +24,7 @@
     <div class="field-group">
         <h5 class="text-center">802.1Q Switching</h5>
         {% render_field form.mode %}
+        {% render_field form.vlan_group %}
         {% render_field form.untagged_vlan %}
         {% render_field form.tagged_vlans %}
     </div>

+ 14 - 3
netbox/virtualization/forms.py

@@ -12,7 +12,7 @@ from extras.forms import (
     CustomFieldModelFilterForm, CustomFieldsMixin,
 )
 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 (
@@ -648,15 +648,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(),