diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index aaab6db44..5bd7d1c95 100644 --- a/docs/configuration/optional-settings.md +++ b/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 diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 40df841c0..e30a6827e 100644 --- a/docs/release-notes/version-2.11.md +++ b/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 diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 1a7d72d7e..d20db1aa9 100644 --- a/netbox/dcim/choices.py +++ b/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'), + )), ) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 74ac2ed84..5c85ac661 100644 --- a/netbox/dcim/forms.py +++ b/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(), diff --git a/netbox/extras/context_managers.py b/netbox/extras/context_managers.py index 25a49b325..66b5ff94d 100644 --- a/netbox/extras/context_managers.py +++ b/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) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 18398a4fd..37dddf202 100644 --- a/netbox/extras/forms.py +++ b/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() diff --git a/netbox/extras/management/commands/webhook_receiver.py b/netbox/extras/management/commands/webhook_receiver.py index 147e4c261..382c830fd 100644 --- a/netbox/extras/management/commands/webhook_receiver.py +++ b/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" diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 5350bab6e..47480ed19 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -125,6 +125,30 @@ class CustomField(ChangeLoggedModel): # Cache instance's original name so we can check later whether it has changed self._name = self.name + def populate_initial_data(self, content_types): + """ + 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 content_types: + model = ct.model_class() + instances = model.objects.exclude(**{f'custom_field_data__contains': self.name}) + for instance in instances: + 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): + """ + Delete custom field data which is no longer relevant (either because the CustomField is + no longer assigned to a model, or because it has been deleted). + """ + for ct in content_types: + model = ct.model_class() + 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. @@ -137,17 +161,6 @@ class CustomField(ChangeLoggedModel): 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 remove_stale_data(self, content_types): - """ - Delete custom field data which is no longer relevant (either because the CustomField is - no longer assigned to a model, or because it has been deleted). - """ - 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() - def clean(self): super().clean() diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 2b2fc774c..4f09706be 100644 --- a/netbox/extras/signals.py +++ b/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) # diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index c14424ba6..b1bf10be6 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/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() diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py new file mode 100644 index 000000000..cb0a9c081 --- /dev/null +++ b/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]) diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py index 8d9071dee..c6abb5a26 100644 --- a/netbox/ipam/lookups.py +++ b/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): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 92908d8b4..271042f06 100644 --- a/netbox/ipam/views.py +++ b/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), } diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 8f8a00784..03023740f 100644 --- a/netbox/netbox/configuration.example.py +++ b/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 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 4e7f012dc..a96eb0345 100644 --- a/netbox/netbox/settings.py +++ b/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' diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 58e7d6299..2b3bf0b9a 100644 --- a/netbox/netbox/views/generic.py +++ b/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( diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 43f21b6e7..b44633b20 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -28,6 +28,7 @@

802.1Q Switching

{% render_field form.mode %} + {% render_field form.vlan_group %} {% render_field form.untagged_vlan %} {% render_field form.tagged_vlans %}
diff --git a/netbox/templates/ipam/prefix/prefixes.html b/netbox/templates/ipam/prefix/prefixes.html index baf58b673..998b7b70b 100644 --- a/netbox/templates/ipam/prefix/prefixes.html +++ b/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 %} + + {% endif %} {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %} 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 %} + {% table_config_form prefix_table table_name="PrefixDetailTable" %} +{% endblock %} + +{% block javascript %} + {% endblock %} diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index 022325dc8..6188190cb 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -24,6 +24,7 @@
802.1Q Switching
{% render_field form.mode %} + {% render_field form.vlan_group %} {% render_field form.untagged_vlan %} {% render_field form.tagged_vlans %}
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 2b352ed3f..6744cd6de 100644 --- a/netbox/virtualization/forms.py +++ b/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(),