Merge branch 'develop' into feature

This commit is contained in:
jeremystretch 2021-08-23 13:23:39 -04:00
commit 499005f84d
20 changed files with 234 additions and 37 deletions

View File

@ -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 ## LOGIN_REQUIRED
Default: False Default: False

View File

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

View File

@ -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'),
)),
) )

View File

@ -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 (
@ -2522,9 +2522,8 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
model = Device model = Device
field_order = [ field_order = [
'q', 'region_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id',
'tenant_group_id', 'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
'mac_address', 'has_primary_ip',
] ]
field_groups = [ field_groups = [
['q'], ['q'],
@ -2545,11 +2544,17 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
label=_('Region'), label=_('Region'),
fetch_trigger='open' fetch_trigger='open'
) )
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'),
fetch_trigger='open' fetch_trigger='open'
@ -3257,15 +3262,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(),

View File

@ -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)

View File

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

View File

@ -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
# Print the request headers to stdout
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"

View File

@ -125,6 +125,30 @@ class CustomField(ChangeLoggedModel):
# 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 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): def rename_object_data(self, old_name, new_name):
""" """
Called when a CustomField has been renamed. Updates all assigned object data. 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) instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
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):
"""
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): def clean(self):
super().clean() super().clean()

View File

@ -1,9 +1,10 @@
import logging
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
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 receiver from django.dispatch import receiver, Signal
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 netbox.signals import post_clean from netbox.signals import post_clean
from .choices import ObjectChangeActionChoices from .choices import ObjectChangeActionChoices
@ -15,6 +16,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.
@ -95,10 +100,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.
@ -122,9 +145,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)
# #

View File

@ -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()

View File

@ -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])

View File

@ -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):

View File

@ -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)
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'): if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
table.columns.show('pk') table.columns.show('pk')
paginate_table(table, request) paginate_table(table, request)
@ -425,6 +426,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=table),
} }

View File

@ -163,6 +163,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 but not make any changes. # are permitted to access most data in NetBox but not make any changes.
LOGIN_REQUIRED = False LOGIN_REQUIRED = False

View File

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

View File

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

View File

@ -28,6 +28,7 @@
<div class="field-group"> <div class="field-group">
<h4 class="mb-3">802.1Q Switching</h4> <h4 class="mb-3">802.1Q Switching</h4>
{% 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>

View File

@ -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 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 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 %}

View File

@ -24,6 +24,7 @@
<div class="field-group"> <div class="field-group">
<h5 class="text-center">802.1Q Switching</h5> <h5 class="text-center">802.1Q Switching</h5>
{% 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>

View File

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