Merge pull request #7018 from netbox-community/develop

Release v2.11.12
This commit is contained in:
Jeremy Stretch 2021-08-23 15:36:04 -04:00 committed by GitHub
commit 9cc4992fad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 248 additions and 42 deletions

View File

@ -17,7 +17,7 @@ body:
What version of NetBox are you currently running? (If you don't have access to the most
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
before opening a bug report to see if your issue has already been addressed.)
placeholder: v2.11.11
placeholder: v2.11.12
validations:
required: true
- type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v2.11.11
placeholder: v2.11.12
validations:
required: true
- type: dropdown

View File

@ -257,6 +257,16 @@ LOGGING = {
---
## LOGIN_PERSISTENCE
Default: False
If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely.
---
## LOGIN_REQUIRED
Default: False

View File

@ -1,5 +1,26 @@
# NetBox v2.11
## v2.11.12 (2021-08-23)
### Enhancements
* [#6748](https://github.com/netbox-community/netbox/issues/6748) - Add site group filter to devices list
* [#6790](https://github.com/netbox-community/netbox/issues/6790) - Recognize a /32 IPv4 address as a child of a /32 IPv4 prefix
* [#6872](https://github.com/netbox-community/netbox/issues/6872) - Add table configuration button to child prefixes view
* [#6929](https://github.com/netbox-community/netbox/issues/6929) - Introduce `LOGIN_PERSISTENCE` configuration parameter to persist user sessions
* [#7011](https://github.com/netbox-community/netbox/issues/7011) - Add search field to VM interfaces filter form
### Bug Fixes
* [#5968](https://github.com/netbox-community/netbox/issues/5968) - Model forms should save empty custom field values as null
* [#6326](https://github.com/netbox-community/netbox/issues/6326) - Enable filtering assigned VLANs by group in interface edit form
* [#6686](https://github.com/netbox-community/netbox/issues/6686) - Force assignment of null custom field values to objects
* [#6776](https://github.com/netbox-community/netbox/issues/6776) - Fix erroneous webhook dispatch on failure to save objects
* [#6974](https://github.com/netbox-community/netbox/issues/6974) - Show contextual label for IP address role
* [#7012](https://github.com/netbox-community/netbox/issues/7012) - Fix hidden "add components" dropdown on devices list
---
## v2.11.11 (2021-08-12)
### Enhancements

View File

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

View File

@ -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 (
@ -2421,8 +2421,8 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm):
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',
]
q = forms.CharField(
required=False,
@ -2433,11 +2433,17 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
required=False,
label=_('Region')
)
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')
)
@ -3103,15 +3109,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(),

View File

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

View File

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

View File

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

View File

@ -120,6 +120,30 @@ class CustomField(BigIDModel):
# 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.
@ -132,17 +156,6 @@ class CustomField(BigIDModel):
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()

View File

@ -1,3 +1,4 @@
import logging
import random
from datetime import timedelta
@ -6,6 +7,7 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import DEFAULT_DB_ALIAS
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import Signal
from django.utils import timezone
from django_prometheus.models import model_deletes, model_inserts, model_updates
from prometheus_client import Counter
@ -19,6 +21,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.
@ -104,10 +110,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.
@ -131,9 +155,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)
#

View File

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

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

View File

@ -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)
prefix_table = tables.PrefixDetailTable(child_prefixes)
prefix_table = tables.PrefixDetailTable(child_prefixes, user=request.user)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table.columns.show('pk')
paginate_table(prefix_table, request)
@ -433,6 +434,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=prefix_table),
}

View File

@ -149,6 +149,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 (excluding secrets) but not make any changes.
LOGIN_REQUIRED = False

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.11.11'
VERSION = '2.11.12'
# Hostname
HOSTNAME = platform.node()
@ -103,6 +103,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)
@ -251,6 +252,7 @@ CACHING_REDIS_SKIP_TLS_VERIFY = CACHING_REDIS.get('INSECURE_SKIP_TLS_VERIFY', Fa
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'

View File

@ -17,6 +17,7 @@ from django.views.generic import View
from django_tables2.export import TableExport
from extras.models import CustomField, ExportTemplate
from extras.signals import clear_webhooks
from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortTransaction, PermissionsViolation
from utilities.forms import (
@ -325,6 +326,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")
@ -603,12 +605,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})")
@ -751,12 +754,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")
@ -879,11 +883,13 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
except ValidationError as e:
messages.error(self.request, "{} failed validation: {}".format(obj, e))
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")
@ -987,6 +993,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')})
@ -1183,6 +1190,7 @@ 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 render(request, self.template_name, {
'component_type': self.queryset.model._meta.verbose_name,
@ -1264,12 +1272,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(

View File

@ -337,22 +337,26 @@ $(document).ready(function() {
$('select#id_untagged_vlan').trigger('change');
$('select#id_tagged_vlans').val([]);
$('select#id_tagged_vlans').trigger('change');
$('select#id_vlan_group').parent().parent().hide();
$('select#id_untagged_vlan').parent().parent().hide();
$('select#id_tagged_vlans').parent().parent().hide();
}
else if ($(this).val() == 'access') {
$('select#id_tagged_vlans').val([]);
$('select#id_tagged_vlans').trigger('change');
$('select#id_vlan_group').parent().parent().show();
$('select#id_untagged_vlan').parent().parent().show();
$('select#id_tagged_vlans').parent().parent().hide();
}
else if ($(this).val() == 'tagged') {
$('select#id_vlan_group').parent().parent().show();
$('select#id_untagged_vlan').parent().parent().show();
$('select#id_tagged_vlans').parent().parent().show();
}
else if ($(this).val() == 'tagged-all') {
$('select#id_tagged_vlans').val([]);
$('select#id_tagged_vlans').trigger('change');
$('select#id_vlan_group').parent().parent().show();
$('select#id_untagged_vlan').parent().parent().show();
$('select#id_tagged_vlans').parent().parent().hide();
}

View File

@ -2,7 +2,7 @@
{% block bulk_buttons %}
{% if perms.dcim.change_device %}
<div class="btn-group">
<div class="btn-group dropup">
<button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button>

View File

@ -33,6 +33,7 @@
<div class="panel-heading"><strong>802.1Q Switching</strong></div>
<div class="panel-body">
{% render_field form.mode %}
{% render_field form.vlan_group %}
{% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %}
</div>

View File

@ -56,7 +56,7 @@
<td>Role</td>
<td>
{% if object.role %}
<a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a>
<a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}" class="label label-{{ object.get_role_class }}">{{ object.get_role_display }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}

View File

@ -1,7 +1,12 @@
{% extends 'ipam/prefix/base.html' %}
{% load helpers %}
{% load static %}
{% block buttons %}
{% include 'ipam/inc/toggle_available.html' %}
{% if request.user.is_authenticated and table_config_form %}
<button type="button" class="btn btn-default" data-toggle="modal" data-target="#PrefixDetailTable_config" title="Configure table"><i class="mdi mdi-cog"></i> Configure</button>
{% endif %}
{% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-success">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Child Prefix
@ -22,4 +27,9 @@
{% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
</div>
</div>
{% table_config_form prefix_table table_name="PrefixDetailTable" %}
{% endblock %}
{% block javascript %}
<script src="{% static 'js/tableconfig.js' %}"></script>
{% endblock %}

View File

@ -28,6 +28,7 @@
<div class="panel-heading"><strong>802.1Q Switching</strong></div>
<div class="panel-body">
{% render_field form.mode %}
{% render_field form.vlan_group %}
{% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %}
</div>

View File

@ -12,7 +12,7 @@ from extras.forms import (
CustomFieldFilterForm,
)
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 (
@ -616,15 +616,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(),
@ -834,6 +845,10 @@ class VMInterfaceBulkRenameForm(BulkRenameForm):
class VMInterfaceFilterForm(BootstrapMixin, forms.Form):
model = VMInterface
q = forms.CharField(
required=False,
label=_('Search')
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,

View File

@ -1,9 +1,9 @@
Django==3.2.6
django-cacheops==6.0
django-cors-headers==3.7.0
django-debug-toolbar==3.2.1
django-cors-headers==3.8.0
django-debug-toolbar==3.2.2
django-filter==2.4.0
django-mptt==0.12.0
django-mptt==0.13.1
django-pglocks==1.0.4
django-prometheus==2.1.0
django-rq==2.4.1