mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
Merge branch 'develop' into feature
This commit is contained in:
commit
499005f84d
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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'),
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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(),
|
||||||
|
@ -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)
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -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
netbox/extras/tests/test_forms.py
Normal file
53
netbox/extras/tests/test_forms.py
Normal 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])
|
@ -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):
|
||||||
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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'
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
@ -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>
|
||||||
|
@ -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 %}
|
||||||
|
@ -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>
|
||||||
|
@ -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(),
|
||||||
|
Loading…
Reference in New Issue
Block a user