- {# Render the field label, except for checkboxes #}
- {% if field|widget_type != 'checkboxinput' %}
+ {# Render the field label (if any), except for checkboxes #}
+ {% if label and not field|widget_type == 'checkboxinput' %}
{{ label }}
diff --git a/netbox/utilities/templates/widgets/apiselect.html b/netbox/utilities/templates/widgets/apiselect.html
new file mode 100644
index 000000000..81320dba5
--- /dev/null
+++ b/netbox/utilities/templates/widgets/apiselect.html
@@ -0,0 +1,18 @@
+{% if widget.attrs.selector and not widget.attrs.disabled %}
+
+ {% include 'django/forms/widgets/select.html' %}
+
+
+
+
+{% else %}
+ {% include 'django/forms/widgets/select.html' %}
+{% endif %}
diff --git a/netbox/utilities/templates/widgets/number_with_options.html b/netbox/utilities/templates/widgets/number_with_options.html
new file mode 100644
index 000000000..ed518650d
--- /dev/null
+++ b/netbox/utilities/templates/widgets/number_with_options.html
@@ -0,0 +1,11 @@
+
+ {% include 'django/forms/widgets/number.html' %}
+
+
+
diff --git a/netbox/utilities/templates/widgets/select_duration.html b/netbox/utilities/templates/widgets/select_duration.html
deleted file mode 100644
index 639075a8c..000000000
--- a/netbox/utilities/templates/widgets/select_duration.html
+++ /dev/null
@@ -1,11 +0,0 @@
-
- {% include 'django/forms/widgets/number.html' %}
-
-
-
diff --git a/netbox/utilities/templates/widgets/select_speed.html b/netbox/utilities/templates/widgets/select_speed.html
deleted file mode 100644
index d9c63c44a..000000000
--- a/netbox/utilities/templates/widgets/select_speed.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
- {% include 'django/forms/widgets/number.html' %}
-
-
-
diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py
index 8c9315ffe..a52a38116 100644
--- a/netbox/utilities/templatetags/builtins/filters.py
+++ b/netbox/utilities/templatetags/builtins/filters.py
@@ -13,6 +13,21 @@ from netbox.config import get_config
from utilities.markdown import StrikethroughExtension
from utilities.utils import clean_html, foreground_color, title
+__all__ = (
+ 'bettertitle',
+ 'content_type',
+ 'content_type_id',
+ 'fgcolor',
+ 'linkify',
+ 'meta',
+ 'placeholder',
+ 'render_json',
+ 'render_markdown',
+ 'render_yaml',
+ 'split',
+ 'tzoffset',
+)
+
register = template.Library()
diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py
index ed464b332..35aec1000 100644
--- a/netbox/utilities/templatetags/builtins/tags.py
+++ b/netbox/utilities/templatetags/builtins/tags.py
@@ -1,4 +1,15 @@
from django import template
+from django.http import QueryDict
+
+from utilities.utils import dict_to_querydict
+
+__all__ = (
+ 'badge',
+ 'checkmark',
+ 'copy_content',
+ 'customfield_value',
+ 'tag',
+)
register = template.Library()
@@ -67,3 +78,32 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
'true_label': true,
'false_label': false,
}
+
+
+@register.inclusion_tag('builtins/copy_content.html')
+def copy_content(target, prefix=None, color='primary'):
+ """
+ Display a copy button to copy the content of a field.
+ """
+ return {
+ 'target': f'#{prefix or ""}{target}',
+ 'color': f'btn-{color}'
+ }
+
+
+@register.inclusion_tag('builtins/htmx_table.html', takes_context=True)
+def htmx_table(context, viewname, return_url=None, **kwargs):
+ """
+ Embed an object list table retrieved using HTMX. Any extra keyword arguments are passed as URL query parameters.
+
+ Args:
+ context: The current request context
+ viewname: The name of the view to use for the HTMX request (e.g. `dcim:site_list`)
+ return_url: The URL to pass as the `return_url`. If not provided, the current request's path will be used.
+ """
+ url_params = dict_to_querydict(kwargs)
+ url_params['return_url'] = return_url or context['request'].path
+ return {
+ 'viewname': viewname,
+ 'url_params': url_params,
+ }
diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py
index dbd0240b9..1556b29a0 100644
--- a/netbox/utilities/templatetags/buttons.py
+++ b/netbox/utilities/templatetags/buttons.py
@@ -5,6 +5,18 @@ from django.urls import NoReverseMatch, reverse
from extras.models import ExportTemplate
from utilities.utils import get_viewname, prepare_cloned_fields
+__all__ = (
+ 'add_button',
+ 'bulk_delete_button',
+ 'bulk_edit_button',
+ 'clone_button',
+ 'delete_button',
+ 'edit_button',
+ 'export_button',
+ 'import_button',
+ 'sync_button',
+)
+
register = template.Library()
@@ -48,6 +60,16 @@ def delete_button(instance):
}
+@register.inclusion_tag('buttons/sync.html')
+def sync_button(instance):
+ viewname = get_viewname(instance, 'sync')
+ url = reverse(viewname, kwargs={'pk': instance.pk})
+
+ return {
+ 'url': url,
+ }
+
+
#
# List buttons
#
diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py
index 089a3ced9..f4fd8b819 100644
--- a/netbox/utilities/templatetags/form_helpers.py
+++ b/netbox/utilities/templatetags/form_helpers.py
@@ -1,5 +1,14 @@
from django import template
+__all__ = (
+ 'getfield',
+ 'render_custom_fields',
+ 'render_errors',
+ 'render_field',
+ 'render_form',
+ 'widget_type',
+)
+
register = template.Library()
@@ -11,9 +20,12 @@ register = template.Library()
@register.filter()
def getfield(form, fieldname):
"""
- Return the specified field of a Form.
+ Return the specified bound field of a Form.
"""
- return form[fieldname]
+ try:
+ return form[fieldname]
+ except KeyError:
+ return None
@register.filter(name='widget_type')
diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py
index 471413bf0..aaee9679c 100644
--- a/netbox/utilities/templatetags/helpers.py
+++ b/netbox/utilities/templatetags/helpers.py
@@ -1,5 +1,4 @@
import datetime
-import decimal
import json
from urllib.parse import quote
from typing import Dict, Any
@@ -15,6 +14,29 @@ from django.utils.safestring import mark_safe
from utilities.forms import get_selected_values, TableConfigForm
from utilities.utils import get_viewname
+__all__ = (
+ 'annotated_date',
+ 'annotated_now',
+ 'applied_filters',
+ 'as_range',
+ 'divide',
+ 'get_item',
+ 'get_key',
+ 'humanize_megabytes',
+ 'humanize_speed',
+ 'icon_from_status',
+ 'kg_to_pounds',
+ 'meters_to_feet',
+ 'percentage',
+ 'querystring',
+ 'startswith',
+ 'status_from_tag',
+ 'table_config_form',
+ 'utilization_graph',
+ 'validated_viewname',
+ 'viewname',
+)
+
register = template.Library()
@@ -83,19 +105,6 @@ def humanize_megabytes(mb):
return f'{mb} MB'
-@register.filter()
-def simplify_decimal(value):
- """
- Return the simplest expression of a decimal value. Examples:
- 1.00 => '1'
- 1.20 => '1.2'
- 1.23 => '1.23'
- """
- if type(value) is not decimal.Decimal:
- return value
- return str(value).rstrip('0').rstrip('.')
-
-
@register.filter(expects_localtime=True)
def annotated_date(date_value):
"""
@@ -105,7 +114,7 @@ def annotated_date(date_value):
if not date_value:
return ''
- if type(date_value) == datetime.date:
+ if type(date_value) is datetime.date:
long_ts = date(date_value, 'DATE_FORMAT')
short_ts = date(date_value, 'SHORT_DATE_FORMAT')
else:
@@ -145,14 +154,6 @@ def percentage(x, y):
return round(x / y * 100, 1)
-@register.filter()
-def has_perms(user, permissions_list):
- """
- Return True if the user has *all* permissions in the list.
- """
- return user.has_perms(permissions_list)
-
-
@register.filter()
def as_range(n):
"""
diff --git a/netbox/utilities/templatetags/navigation.py b/netbox/utilities/templatetags/navigation.py
index a34ef9816..4a229e952 100644
--- a/netbox/utilities/templatetags/navigation.py
+++ b/netbox/utilities/templatetags/navigation.py
@@ -4,6 +4,10 @@ from django.template import Context
from netbox.navigation.menu import MENUS
+__all__ = (
+ 'nav',
+)
+
register = template.Library()
diff --git a/netbox/utilities/templatetags/perms.py b/netbox/utilities/templatetags/perms.py
index f1bbf7549..2eef7e580 100644
--- a/netbox/utilities/templatetags/perms.py
+++ b/netbox/utilities/templatetags/perms.py
@@ -1,5 +1,13 @@
from django import template
+__all__ = (
+ 'can_add',
+ 'can_change',
+ 'can_delete',
+ 'can_sync',
+ 'can_view',
+)
+
register = template.Library()
@@ -28,3 +36,8 @@ def can_change(user, instance):
@register.filter()
def can_delete(user, instance):
return _check_permission(user, instance, 'delete')
+
+
+@register.filter()
+def can_sync(user, instance):
+ return _check_permission(user, instance, 'sync')
diff --git a/netbox/utilities/templatetags/tabs.py b/netbox/utilities/templatetags/tabs.py
index d41766794..678fec9ab 100644
--- a/netbox/utilities/templatetags/tabs.py
+++ b/netbox/utilities/templatetags/tabs.py
@@ -6,6 +6,10 @@ from django.utils.module_loading import import_string
from netbox.registry import registry
from utilities.utils import get_viewname
+__all__ = (
+ 'model_view_tabs',
+)
+
register = template.Library()
diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py
index 2280331be..f7539482d 100644
--- a/netbox/utilities/testing/views.py
+++ b/netbox/utilities/testing/views.py
@@ -9,6 +9,7 @@ from django.urls import reverse
from extras.choices import ObjectChangeActionChoices, CustomFieldTypeChoices
from extras.models import ObjectChange, CustomField
from netbox.models import CustomFieldsMixin
+from netbox.models.features import ChangeLoggingMixin
from users.models import ObjectPermission
from utilities.choices import ImportFormatChoices
from .base import ModelTestCase
@@ -405,12 +406,13 @@ class ViewTestCases:
self._get_queryset().get(pk=instance.pk)
# Verify ObjectChange creation
- objectchanges = ObjectChange.objects.filter(
- changed_object_type=ContentType.objects.get_for_model(instance),
- changed_object_id=instance.pk
- )
- self.assertEqual(len(objectchanges), 1)
- self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_DELETE)
+ if issubclass(instance.__class__, ChangeLoggingMixin):
+ objectchanges = ObjectChange.objects.filter(
+ changed_object_type=ContentType.objects.get_for_model(instance),
+ changed_object_id=instance.pk
+ )
+ self.assertEqual(len(objectchanges), 1)
+ self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_DELETE)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_delete_object_with_constrained_permission(self):
@@ -645,7 +647,7 @@ class ViewTestCases:
self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
# Test POST with permission
- self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
+ self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
@@ -670,7 +672,7 @@ class ViewTestCases:
obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Test POST with permission
- self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
+ self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
self.assertEqual(initial_count, self._get_queryset().count())
reader = csv.DictReader(array, delimiter=',')
@@ -712,7 +714,7 @@ class ViewTestCases:
obj_perm.save()
# Import permitted objects
- self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
+ self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1)
class BulkEditObjectsViewTestCase(ModelViewTestCase):
diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py
index e341442be..1cc3487b1 100644
--- a/netbox/utilities/tests/test_api.py
+++ b/netbox/utilities/tests/test_api.py
@@ -249,9 +249,9 @@ class APIDocsTestCase(TestCase):
def test_api_docs(self):
url = reverse('api_docs')
- params = {
- "format": "openapi",
- }
-
- response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ url = reverse('schema')
+ response = self.client.get(url)
self.assertEqual(response.status_code, 200)
diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py
index 65b6943a0..79ba3f4d8 100644
--- a/netbox/utilities/tests/test_forms.py
+++ b/netbox/utilities/tests/test_forms.py
@@ -1,10 +1,8 @@
from django import forms
from django.test import TestCase
-from ipam.forms import IPAddressImportForm
from utilities.choices import ImportFormatChoices
-from utilities.forms import ImportForm
-from utilities.forms.fields import CSVDataField
+from utilities.forms.bulk_import import BulkImportForm
from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
@@ -266,8 +264,9 @@ class ExpandAlphanumeric(TestCase):
self.assertEqual(sorted(expand_alphanumeric_pattern('r[a-9]a')), [])
def test_invalid_range_bounds(self):
- self.assertEqual(sorted(expand_alphanumeric_pattern('r[9-8]a')), [])
- self.assertEqual(sorted(expand_alphanumeric_pattern('r[b-a]a')), [])
+ with self.assertRaises(forms.ValidationError):
+ sorted(expand_alphanumeric_pattern('r[9-8]a'))
+ sorted(expand_alphanumeric_pattern('r[b-a]a'))
def test_invalid_range_len(self):
with self.assertRaises(forms.ValidationError):
@@ -287,108 +286,10 @@ class ExpandAlphanumeric(TestCase):
sorted(expand_alphanumeric_pattern('r[a,,b]a'))
-class CSVDataFieldTest(TestCase):
-
- def setUp(self):
- self.field = CSVDataField(from_form=IPAddressImportForm)
-
- def test_clean(self):
- input = """
- address,status,vrf
- 192.0.2.1/32,Active,Test VRF
- """
- output = (
- {'address': None, 'status': None, 'vrf': None},
- [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': 'Test VRF'}]
- )
- self.assertEqual(self.field.clean(input), output)
-
- def test_clean_invalid_header(self):
- input = """
- address,status,vrf,xxx
- 192.0.2.1/32,Active,Test VRF,123
- """
- with self.assertRaises(forms.ValidationError):
- self.field.clean(input)
-
- def test_clean_missing_required_header(self):
- input = """
- status,vrf
- Active,Test VRF
- """
- with self.assertRaises(forms.ValidationError):
- self.field.clean(input)
-
- def test_duplicate_header(self):
- input = """
- status,status
- Active,Active
- """
- with self.assertRaisesRegex(forms.ValidationError, 'Duplicate'):
- self.field.clean(input)
-
- def test_duplicate_header_key(self):
- input = """
- vrf.name,vrf.rd
- Test VRF,123:456
- """
- with self.assertRaisesRegex(forms.ValidationError, 'Duplicate'):
- self.field.clean(input)
-
- def test_clean_default_to_field(self):
- input = """
- address,status,vrf.name
- 192.0.2.1/32,Active,Test VRF
- """
- output = (
- {'address': None, 'status': None, 'vrf': 'name'},
- [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': 'Test VRF'}]
- )
- self.assertEqual(self.field.clean(input), output)
-
- def test_clean_pk_to_field(self):
- input = """
- address,status,vrf.pk
- 192.0.2.1/32,Active,123
- """
- output = (
- {'address': None, 'status': None, 'vrf': 'pk'},
- [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': '123'}]
- )
- self.assertEqual(self.field.clean(input), output)
-
- def test_clean_custom_to_field(self):
- input = """
- address,status,vrf.rd
- 192.0.2.1/32,Active,123:456
- """
- output = (
- {'address': None, 'status': None, 'vrf': 'rd'},
- [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': '123:456'}]
- )
- self.assertEqual(self.field.clean(input), output)
-
- def test_clean_invalid_to_field(self):
- input = """
- address,status,vrf.xxx
- 192.0.2.1/32,Active,123:456
- """
- with self.assertRaises(forms.ValidationError):
- self.field.clean(input)
-
- def test_clean_to_field_on_non_object(self):
- input = """
- address,status.foo,vrf
- 192.0.2.1/32,Bar,Test VRF
- """
- with self.assertRaises(forms.ValidationError):
- self.field.clean(input)
-
-
class ImportFormTest(TestCase):
def test_format_detection(self):
- form = ImportForm()
+ form = BulkImportForm()
data = (
"a,b,c\n"
diff --git a/netbox/utilities/urls.py b/netbox/utilities/urls.py
index f344b9b61..a1132d81c 100644
--- a/netbox/utilities/urls.py
+++ b/netbox/utilities/urls.py
@@ -4,6 +4,10 @@ from django.views.generic import View
from netbox.registry import registry
+__all__ = (
+ 'get_model_urls',
+)
+
def get_model_urls(app_label, model_name):
"""
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index 57092bb7d..9524e242c 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -11,8 +11,9 @@ from django.core import serializers
from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django.http import QueryDict
-from django.utils.html import escape
from django.utils import timezone
+from django.utils.datastructures import MultiValueDict
+from django.utils.html import escape
from django.utils.timezone import localtime
from jinja2.sandbox import SandboxedEnvironment
from mptt.models import MPTTModel
@@ -231,6 +232,19 @@ def dict_to_filter_params(d, prefix=''):
return params
+def dict_to_querydict(d, mutable=True):
+ """
+ Create a QueryDict instance from a regular Python dictionary.
+ """
+ qd = QueryDict(mutable=True)
+ for k, v in d.items():
+ item = MultiValueDict({k: v}) if isinstance(v, (list, tuple, set)) else {k: v}
+ qd.update(item)
+ if not mutable:
+ qd._mutable = False
+ return qd
+
+
def normalize_querydict(querydict):
"""
Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,
@@ -302,7 +316,7 @@ def to_meters(length, unit):
if unit == CableLengthUnitChoices.UNIT_FOOT:
return length * Decimal(0.3048)
if unit == CableLengthUnitChoices.UNIT_INCH:
- return length * Decimal(0.3048) * 12
+ return length * Decimal(0.0254)
raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.")
@@ -491,20 +505,22 @@ def clean_html(html, schemes):
Also takes a list of allowed URI schemes.
"""
- ALLOWED_TAGS = [
+ ALLOWED_TAGS = {
"div", "pre", "code", "blockquote", "del",
"hr", "h1", "h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li", "p", "br",
"strong", "em", "a", "b", "i", "img",
"table", "thead", "tbody", "tr", "th", "td",
"dl", "dt", "dd",
- ]
+ }
ALLOWED_ATTRIBUTES = {
"div": ['class'],
"h1": ["id"], "h2": ["id"], "h3": ["id"], "h4": ["id"], "h5": ["id"], "h6": ["id"],
"a": ["href", "title"],
"img": ["src", "title", "alt"],
+ "th": ["align"],
+ "td": ["align"],
}
return bleach.clean(
diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py
index 9c3413893..eaea1c34b 100644
--- a/netbox/utilities/validators.py
+++ b/netbox/utilities/validators.py
@@ -1,10 +1,24 @@
import re
from django.core.exceptions import ValidationError
-from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
+from django.core.validators import BaseValidator, RegexValidator, URLValidator, _lazy_re_compile
from netbox.config import get_config
+__all__ = (
+ 'ColorValidator',
+ 'EnhancedURLValidator',
+ 'ExclusionValidator',
+ 'validate_regex',
+)
+
+
+ColorValidator = RegexValidator(
+ regex='^[0-9a-f]{6}$',
+ message='Enter a valid hexadecimal RGB color code.',
+ code='invalid'
+)
+
class EnhancedURLValidator(URLValidator):
"""
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 43ca9a589..589b71f50 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -178,7 +178,7 @@ def register_model_view(model, name='', path=None, kwargs=None):
This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject
additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model:
- @netbox_model_view(Site, 'myview', path='my-custom-view')
+ @register_model_view(Site, 'myview', path='my-custom-view')
class MyView(ObjectView):
...
diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py
index 07a9f5d13..8c3f57c1d 100644
--- a/netbox/virtualization/api/nested_serializers.py
+++ b/netbox/virtualization/api/nested_serializers.py
@@ -1,3 +1,4 @@
+from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers
from netbox.api.serializers import WritableNestedSerializer
@@ -16,6 +17,9 @@ __all__ = [
#
+@extend_schema_serializer(
+ exclude_fields=('cluster_count',),
+)
class NestedClusterTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
cluster_count = serializers.IntegerField(read_only=True)
@@ -25,6 +29,9 @@ class NestedClusterTypeSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name', 'slug', 'cluster_count']
+@extend_schema_serializer(
+ exclude_fields=('cluster_count',),
+)
class NestedClusterGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
cluster_count = serializers.IntegerField(read_only=True)
@@ -34,6 +41,9 @@ class NestedClusterGroupSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name', 'slug', 'cluster_count']
+@extend_schema_serializer(
+ exclude_fields=('virtualmachine_count',),
+)
class NestedClusterSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
virtualmachine_count = serializers.IntegerField(read_only=True)
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index 2fd52146c..f72215b98 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -1,4 +1,4 @@
-from drf_yasg.utils import swagger_serializer_method
+from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from dcim.api.nested_serializers import (
@@ -100,7 +100,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
- @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+ @extend_schema_field(serializers.JSONField(allow_null=True))
def get_config_context(self, obj):
return obj.get_config_context()
@@ -123,9 +123,14 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
many=True
)
vrf = NestedVRFSerializer(required=False, allow_null=True)
- l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
+ l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True)
count_ipaddresses = serializers.IntegerField(read_only=True)
count_fhrp_groups = serializers.IntegerField(read_only=True)
+ mac_address = serializers.CharField(
+ required=False,
+ default=None,
+ allow_null=True
+ )
class Meta:
model = VMInterface
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index d2a90ae34..5b9cf4117 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -1,7 +1,7 @@
from rest_framework.routers import APIRootView
from dcim.models import Device
-from extras.api.views import ConfigContextQuerySetMixin
+from extras.api.mixins import ConfigContextQuerySetMixin
from netbox.api.viewsets import NetBoxModelViewSet
from utilities.utils import count_related
from virtualization import filtersets
diff --git a/netbox/virtualization/forms/bulk_create.py b/netbox/virtualization/forms/bulk_create.py
index 54722c7b1..0b762f38e 100644
--- a/netbox/virtualization/forms/bulk_create.py
+++ b/netbox/virtualization/forms/bulk_create.py
@@ -1,7 +1,8 @@
from django import forms
from django.utils.translation import gettext as _
-from utilities.forms import BootstrapMixin, ExpandableNameField, form_from_model
+from utilities.forms import BootstrapMixin, form_from_model
+from utilities.forms.fields import ExpandableNameField
from virtualization.models import VMInterface, VirtualMachine
__all__ = (
diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py
index de68412bd..9aa771d29 100644
--- a/netbox/virtualization/forms/bulk_edit.py
+++ b/netbox/virtualization/forms/bulk_edit.py
@@ -7,10 +7,9 @@ from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from ipam.models import VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
-from utilities.forms import (
- add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField,
- DynamicModelMultipleChoiceField, StaticSelect
-)
+from utilities.forms import BulkRenameForm, add_blank_choice
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.widgets import BulkEditNullBooleanSelect
from virtualization.choices import *
from virtualization.models import *
@@ -62,8 +61,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
status = forms.ChoiceField(
choices=add_blank_choice(ClusterStatusChoices),
required=False,
- initial='',
- widget=StaticSelect()
+ initial=''
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
@@ -108,7 +106,6 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
choices=add_blank_choice(VirtualMachineStatusChoices),
required=False,
initial='',
- widget=StaticSelect(),
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
@@ -206,8 +203,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
)
mode = forms.ChoiceField(
choices=add_blank_choice(InterfaceModeChoices),
- required=False,
- widget=StaticSelect()
+ required=False
)
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py
index 8ed83b46c..15651f2ae 100644
--- a/netbox/virtualization/forms/bulk_import.py
+++ b/netbox/virtualization/forms/bulk_import.py
@@ -1,10 +1,11 @@
+from django.utils.translation import gettext as _
+
from dcim.choices import InterfaceModeChoices
from dcim.models import Device, DeviceRole, Platform, Site
-from django.utils.translation import gettext as _
from ipam.models import VRF
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
-from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
+from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
from virtualization.choices import *
from virtualization.models import *
@@ -64,7 +65,7 @@ class ClusterImportForm(NetBoxModelImportForm):
class Meta:
model = Cluster
- fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments', 'tags')
+ fields = ('name', 'type', 'group', 'status', 'site', 'tenant', 'description', 'comments', 'tags')
class VirtualMachineImportForm(NetBoxModelImportForm):
diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py
index 46318c916..3e228742c 100644
--- a/netbox/virtualization/forms/filtersets.py
+++ b/netbox/virtualization/forms/filtersets.py
@@ -6,9 +6,8 @@ from extras.forms import LocalConfigContextFilterForm
from ipam.models import L2VPN, VRF
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
-from utilities.forms import (
- DynamicModelMultipleChoiceField, MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
-)
+from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
+from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
from virtualization.choices import *
from virtualization.models import *
@@ -54,7 +53,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
required=False,
label=_('Region')
)
- status = MultipleChoiceField(
+ status = forms.MultipleChoiceField(
choices=ClusterStatusChoices,
required=False
)
@@ -148,7 +147,7 @@ class VirtualMachineFilterForm(
},
label=_('Role')
)
- status = MultipleChoiceField(
+ status = forms.MultipleChoiceField(
choices=VirtualMachineStatusChoices,
required=False
)
@@ -165,7 +164,7 @@ class VirtualMachineFilterForm(
has_primary_ip = forms.NullBooleanField(
required=False,
label=_('Has a primary IP'),
- widget=StaticSelect(
+ widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@@ -194,7 +193,7 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
)
enabled = forms.NullBooleanField(
required=False,
- widget=StaticSelect(
+ widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py
index 037af0b5c..b4051dec2 100644
--- a/netbox/virtualization/forms/model_forms.py
+++ b/netbox/virtualization/forms/model_forms.py
@@ -4,15 +4,15 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from dcim.forms.common import InterfaceCommonForm
-from dcim.forms.model_forms import INTERFACE_MODE_HELP_TEXT
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
from ipam.models import IPAddress, VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
-from utilities.forms import (
- BootstrapMixin, CommentField, ConfirmationForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
- JSONField, SlugField, StaticSelect,
+from utilities.forms import BootstrapMixin, ConfirmationForm
+from utilities.forms.fields import (
+ CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField,
)
+from utilities.forms.widgets import HTMXSelect
from virtualization.models import *
__all__ = (
@@ -66,45 +66,23 @@ class ClusterForm(TenancyForm, NetBoxModelForm):
queryset=ClusterGroup.objects.all(),
required=False
)
- region = DynamicModelChoiceField(
- queryset=Region.objects.all(),
- required=False,
- initial_params={
- 'sites': '$site'
- }
- )
- site_group = DynamicModelChoiceField(
- queryset=SiteGroup.objects.all(),
- required=False,
- initial_params={
- 'sites': '$site'
- }
- )
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
- query_params={
- 'region_id': '$region',
- 'group_id': '$site_group',
- }
+ selector=True
)
comments = CommentField()
fieldsets = (
- ('Cluster', ('name', 'type', 'group', 'status', 'description', 'tags')),
- ('Site', ('region', 'site_group', 'site')),
+ ('Cluster', ('name', 'type', 'group', 'site', 'status', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')),
)
class Meta:
model = Cluster
fields = (
- 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'description', 'comments',
- 'tags',
+ 'name', 'type', 'group', 'status', 'tenant', 'site', 'description', 'comments', 'tags',
)
- widgets = {
- 'status': StaticSelect(),
- }
class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
@@ -182,20 +160,12 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
queryset=Site.objects.all(),
required=False
)
- cluster_group = DynamicModelChoiceField(
- queryset=ClusterGroup.objects.all(),
- required=False,
- null_option='None',
- initial_params={
- 'clusters': '$cluster'
- }
- )
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
required=False,
+ selector=True,
query_params={
'site_id': '$site',
- 'group_id': '$cluster_group',
}
)
device = DynamicModelChoiceField(
@@ -226,7 +196,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
fieldsets = (
('Virtual Machine', ('name', 'role', 'status', 'description', 'tags')),
- ('Site/Cluster', ('site', 'cluster_group', 'cluster', 'device')),
+ ('Site/Cluster', ('site', 'cluster', 'device')),
('Tenancy', ('tenant_group', 'tenant')),
('Management', ('platform', 'primary_ip4', 'primary_ip6')),
('Resources', ('vcpus', 'memory', 'disk')),
@@ -236,19 +206,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
class Meta:
model = VirtualMachine
fields = [
- 'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant',
- 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags',
- 'local_context_data',
+ 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
+ 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags', 'local_context_data',
]
- help_texts = {
- 'local_context_data': _("Local config context data overwrites all sources contexts in the final rendered "
- "config context"),
- }
- widgets = {
- "status": StaticSelect(),
- 'primary_ip4': StaticSelect(),
- 'primary_ip6': StaticSelect(),
- }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -293,7 +253,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
virtual_machine = DynamicModelChoiceField(
- queryset=VirtualMachine.objects.all()
+ queryset=VirtualMachine.objects.all(),
+ selector=True
)
parent = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
@@ -354,14 +315,11 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
]
- widgets = {
- 'mode': StaticSelect()
- }
labels = {
'mode': '802.1Q Mode',
}
- help_texts = {
- 'mode': INTERFACE_MODE_HELP_TEXT,
+ widgets = {
+ 'mode': HTMXSelect(),
}
def __init__(self, *args, **kwargs):
diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py
index 2e0cc5db1..c36ce00ee 100644
--- a/netbox/virtualization/forms/object_create.py
+++ b/netbox/virtualization/forms/object_create.py
@@ -1,4 +1,4 @@
-from utilities.forms import ExpandableNameField
+from utilities.forms.fields import ExpandableNameField
from .model_forms import VMInterfaceForm
__all__ = (
diff --git a/netbox/virtualization/graphql/schema.py b/netbox/virtualization/graphql/schema.py
index e22532214..88e6aac64 100644
--- a/netbox/virtualization/graphql/schema.py
+++ b/netbox/virtualization/graphql/schema.py
@@ -2,20 +2,37 @@ import graphene
from netbox.graphql.fields import ObjectField, ObjectListField
from .types import *
+from utilities.graphql_optimizer import gql_query_optimizer
+from virtualization import models
class VirtualizationQuery(graphene.ObjectType):
cluster = ObjectField(ClusterType)
cluster_list = ObjectListField(ClusterType)
+ def resolve_cluster_list(root, info, **kwargs):
+ return gql_query_optimizer(models.Cluster.objects.all(), info)
+
cluster_group = ObjectField(ClusterGroupType)
cluster_group_list = ObjectListField(ClusterGroupType)
+ def resolve_cluster_group_list(root, info, **kwargs):
+ return gql_query_optimizer(models.ClusterGroup.objects.all(), info)
+
cluster_type = ObjectField(ClusterTypeType)
cluster_type_list = ObjectListField(ClusterTypeType)
+ def resolve_cluster_type_list(root, info, **kwargs):
+ return gql_query_optimizer(models.ClusterType.objects.all(), info)
+
virtual_machine = ObjectField(VirtualMachineType)
virtual_machine_list = ObjectListField(VirtualMachineType)
+ def resolve_virtual_machine_list(root, info, **kwargs):
+ return gql_query_optimizer(models.VirtualMachine.objects.all(), info)
+
vm_interface = ObjectField(VMInterfaceType)
vm_interface_list = ObjectListField(VMInterfaceType)
+
+ def resolve_vm_interface_list(root, info, **kwargs):
+ return gql_query_optimizer(models.VMInterface.objects.all(), info)
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 1ff21f1e0..92a91f47e 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -1,3 +1,5 @@
+from collections import defaultdict
+
from django.contrib import messages
from django.db import transaction
from django.db.models import Prefetch, Sum
@@ -9,9 +11,10 @@ from dcim.filtersets import DeviceFilterSet
from dcim.models import Device
from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView
-from ipam.models import IPAddress, Service
-from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
+from ipam.models import IPAddress
+from ipam.tables import InterfaceVLANTable
from netbox.views import generic
+from tenancy.views import ObjectContactsView
from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view
from . import filtersets, forms, tables
@@ -36,17 +39,12 @@ class ClusterTypeView(generic.ObjectView):
queryset = ClusterType.objects.all()
def get_extra_context(self, request, instance):
- clusters = Cluster.objects.restrict(request.user, 'view').filter(
- type=instance
- ).annotate(
- device_count=count_related(Device, 'cluster'),
- vm_count=count_related(VirtualMachine, 'cluster')
+ related_models = (
+ (Cluster.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'),
)
- clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('type',))
- clusters_table.configure(request)
return {
- 'clusters_table': clusters_table,
+ 'related_models': related_models,
}
@@ -64,7 +62,6 @@ class ClusterTypeDeleteView(generic.ObjectDeleteView):
class ClusterTypeBulkImportView(generic.BulkImportView):
queryset = ClusterType.objects.all()
model_form = forms.ClusterTypeImportForm
- table = tables.ClusterTypeTable
class ClusterTypeBulkEditView(generic.BulkEditView):
@@ -102,17 +99,12 @@ class ClusterGroupView(generic.ObjectView):
queryset = ClusterGroup.objects.all()
def get_extra_context(self, request, instance):
- clusters = Cluster.objects.restrict(request.user, 'view').filter(
- group=instance
- ).annotate(
- device_count=count_related(Device, 'cluster'),
- vm_count=count_related(VirtualMachine, 'cluster')
+ related_models = (
+ (Cluster.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
)
- clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('group',))
- clusters_table.configure(request)
return {
- 'clusters_table': clusters_table,
+ 'related_models': related_models,
}
@@ -132,7 +124,6 @@ class ClusterGroupBulkImportView(generic.BulkImportView):
cluster_count=count_related(Cluster, 'group')
)
model_form = forms.ClusterGroupImportForm
- table = tables.ClusterGroupTable
class ClusterGroupBulkEditView(generic.BulkEditView):
@@ -152,6 +143,11 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
table = tables.ClusterGroupTable
+@register_model_view(ClusterGroup, 'contacts')
+class ClusterGroupContactsView(ObjectContactsView):
+ queryset = ClusterGroup.objects.all()
+
+
#
# Clusters
#
@@ -181,7 +177,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
child_model = VirtualMachine
table = tables.VirtualMachineTable
filterset = filtersets.VirtualMachineFilterSet
- template_name = 'virtualization/cluster/virtual_machines.html'
+ template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Virtual Machines'),
badge=lambda obj: obj.virtual_machines.count(),
@@ -200,6 +196,13 @@ class ClusterDevicesView(generic.ObjectChildrenView):
table = DeviceTable
filterset = DeviceFilterSet
template_name = 'virtualization/cluster/devices.html'
+ actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_remove_devices')
+ action_perms = defaultdict(set, **{
+ 'add': {'add'},
+ 'import': {'add'},
+ 'bulk_edit': {'change'},
+ 'bulk_remove_devices': {'change'},
+ })
tab = ViewTab(
label=_('Devices'),
badge=lambda obj: obj.devices.count(),
@@ -225,7 +228,6 @@ class ClusterDeleteView(generic.ObjectDeleteView):
class ClusterBulkImportView(generic.BulkImportView):
queryset = Cluster.objects.all()
model_form = forms.ClusterImportForm
- table = tables.ClusterTable
class ClusterBulkEditView(generic.BulkEditView):
@@ -325,6 +327,11 @@ class ClusterRemoveDevicesView(generic.ObjectEditView):
})
+@register_model_view(Cluster, 'contacts')
+class ClusterContactsView(ObjectContactsView):
+ queryset = Cluster.objects.all()
+
+
#
# Virtual machines
#
@@ -339,32 +346,7 @@ class VirtualMachineListView(generic.ObjectListView):
@register_model_view(VirtualMachine)
class VirtualMachineView(generic.ObjectView):
- queryset = VirtualMachine.objects.prefetch_related('tenant__group')
-
- def get_extra_context(self, request, instance):
- # Interfaces
- vminterfaces = VMInterface.objects.restrict(request.user, 'view').filter(
- virtual_machine=instance
- ).prefetch_related(
- Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user))
- )
- vminterface_table = tables.VirtualMachineVMInterfaceTable(vminterfaces, user=request.user, orderable=False)
- if request.user.has_perm('virtualization.change_vminterface') or \
- request.user.has_perm('virtualization.delete_vminterface'):
- vminterface_table.columns.show('pk')
-
- # Services
- services = Service.objects.restrict(request.user, 'view').filter(
- virtual_machine=instance
- ).prefetch_related(
- Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user)),
- 'virtual_machine'
- )
-
- return {
- 'vminterface_table': vminterface_table,
- 'services': services,
- }
+ queryset = VirtualMachine.objects.all()
@register_model_view(VirtualMachine, 'interfaces')
@@ -380,6 +362,14 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
permission='virtualization.view_vminterface',
weight=500
)
+ actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+ action_perms = defaultdict(set, **{
+ 'add': {'add'},
+ 'import': {'add'},
+ 'bulk_edit': {'change'},
+ 'bulk_delete': {'delete'},
+ 'bulk_rename': {'change'},
+ })
def get_children(self, request, parent):
return parent.interfaces.restrict(request.user, 'view').prefetch_related(
@@ -413,7 +403,6 @@ class VirtualMachineDeleteView(generic.ObjectDeleteView):
class VirtualMachineBulkImportView(generic.BulkImportView):
queryset = VirtualMachine.objects.all()
model_form = forms.VirtualMachineImportForm
- table = tables.VirtualMachineTable
class VirtualMachineBulkEditView(generic.BulkEditView):
@@ -429,6 +418,11 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
table = tables.VirtualMachineTable
+@register_model_view(VirtualMachine, 'contacts')
+class VirtualMachineContactsView(ObjectContactsView):
+ queryset = VirtualMachine.objects.all()
+
+
#
# VM interfaces
#
@@ -438,7 +432,6 @@ class VMInterfaceListView(generic.ObjectListView):
filterset = filtersets.VMInterfaceFilterSet
filterset_form = forms.VMInterfaceFilterForm
table = tables.VMInterfaceTable
- actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(VMInterface)
@@ -446,11 +439,6 @@ class VMInterfaceView(generic.ObjectView):
queryset = VMInterface.objects.all()
def get_extra_context(self, request, instance):
- # Get assigned IP addresses
- ipaddress_table = AssignedIPAddressesTable(
- data=instance.ip_addresses.restrict(request.user, 'view'),
- orderable=False
- )
# Get child interfaces
child_interfaces = VMInterface.objects.restrict(request.user, 'view').filter(parent=instance)
@@ -475,7 +463,6 @@ class VMInterfaceView(generic.ObjectView):
)
return {
- 'ipaddress_table': ipaddress_table,
'child_interfaces_table': child_interfaces_tables,
'vlan_table': vlan_table,
}
@@ -501,7 +488,6 @@ class VMInterfaceDeleteView(generic.ObjectDeleteView):
class VMInterfaceBulkImportView(generic.BulkImportView):
queryset = VMInterface.objects.all()
model_form = forms.VMInterfaceImportForm
- table = tables.VMInterfaceTable
class VMInterfaceBulkEditView(generic.BulkEditView):
diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py
index 0e8404266..53f2a6354 100644
--- a/netbox/wireless/api/nested_serializers.py
+++ b/netbox/wireless/api/nested_serializers.py
@@ -1,3 +1,4 @@
+from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers
from netbox.api.serializers import WritableNestedSerializer
@@ -10,6 +11,9 @@ __all__ = (
)
+@extend_schema_serializer(
+ exclude_fields=('wirelesslan_count',),
+)
class NestedWirelessLANGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail')
wirelesslan_count = serializers.IntegerField(read_only=True)
diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py
index 69613a8c6..4fec0b87f 100644
--- a/netbox/wireless/forms/bulk_edit.py
+++ b/netbox/wireless/forms/bulk_edit.py
@@ -5,7 +5,8 @@ from dcim.choices import LinkStatusChoices
from ipam.models import VLAN
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
-from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import CommentField, DynamicModelChoiceField
from wireless.choices import *
from wireless.constants import SSID_MAX_LENGTH
from wireless.models import *
diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py
index 236ad9c1d..f29e24260 100644
--- a/netbox/wireless/forms/bulk_import.py
+++ b/netbox/wireless/forms/bulk_import.py
@@ -1,10 +1,11 @@
from django.utils.translation import gettext as _
+
from dcim.choices import LinkStatusChoices
from dcim.models import Interface
from ipam.models import VLAN
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
-from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
+from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
from wireless.choices import *
from wireless.models import *
diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py
index 70aef9610..f281ed0db 100644
--- a/netbox/wireless/forms/filtersets.py
+++ b/netbox/wireless/forms/filtersets.py
@@ -4,7 +4,8 @@ from django.utils.translation import gettext as _
from dcim.choices import LinkStatusChoices
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm
-from utilities.forms import add_blank_choice, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
from wireless.choices import *
from wireless.models import *
@@ -45,18 +46,15 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
)
status = forms.ChoiceField(
required=False,
- choices=add_blank_choice(WirelessLANStatusChoices),
- widget=StaticSelect()
+ choices=add_blank_choice(WirelessLANStatusChoices)
)
auth_type = forms.ChoiceField(
required=False,
- choices=add_blank_choice(WirelessAuthTypeChoices),
- widget=StaticSelect()
+ choices=add_blank_choice(WirelessAuthTypeChoices)
)
auth_cipher = forms.ChoiceField(
required=False,
- choices=add_blank_choice(WirelessAuthCipherChoices),
- widget=StaticSelect()
+ choices=add_blank_choice(WirelessAuthCipherChoices)
)
auth_psk = forms.CharField(
required=False
@@ -78,18 +76,15 @@ class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
)
status = forms.ChoiceField(
required=False,
- choices=add_blank_choice(LinkStatusChoices),
- widget=StaticSelect()
+ choices=add_blank_choice(LinkStatusChoices)
)
auth_type = forms.ChoiceField(
required=False,
- choices=add_blank_choice(WirelessAuthTypeChoices),
- widget=StaticSelect()
+ choices=add_blank_choice(WirelessAuthTypeChoices)
)
auth_cipher = forms.ChoiceField(
required=False,
- choices=add_blank_choice(WirelessAuthCipherChoices),
- widget=StaticSelect()
+ choices=add_blank_choice(WirelessAuthCipherChoices)
)
auth_psk = forms.CharField(
required=False
diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py
index 8b45b0116..2523ff7a9 100644
--- a/netbox/wireless/forms/model_forms.py
+++ b/netbox/wireless/forms/model_forms.py
@@ -1,9 +1,11 @@
+from django.forms import PasswordInput
from django.utils.translation import gettext as _
-from dcim.models import Device, Interface, Location, Region, Site, SiteGroup
-from ipam.models import VLAN, VLANGroup
+
+from dcim.models import Device, Interface, Location, Site
+from ipam.models import VLAN
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
-from utilities.forms import CommentField, DynamicModelChoiceField, SlugField, StaticSelect
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField
from wireless.models import *
__all__ = (
@@ -38,55 +40,16 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm):
queryset=WirelessLANGroup.objects.all(),
required=False
)
- region = DynamicModelChoiceField(
- queryset=Region.objects.all(),
- required=False,
- initial_params={
- 'sites': '$site'
- }
- )
- site_group = DynamicModelChoiceField(
- queryset=SiteGroup.objects.all(),
- required=False,
- initial_params={
- 'sites': '$site'
- }
- )
- site = DynamicModelChoiceField(
- queryset=Site.objects.all(),
- required=False,
- null_option='None',
- query_params={
- 'region_id': '$region',
- 'group_id': '$site_group',
- }
- )
- vlan_group = DynamicModelChoiceField(
- queryset=VLANGroup.objects.all(),
- required=False,
- label=_('VLAN group'),
- null_option='None',
- query_params={
- 'site': '$site'
- },
- initial_params={
- 'vlans': '$vlan'
- }
- )
vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
- label=_('VLAN'),
- query_params={
- 'site_id': '$site',
- 'group_id': '$vlan_group',
- }
+ selector=True,
+ label=_('VLAN')
)
comments = CommentField()
fieldsets = (
- ('Wireless LAN', ('ssid', 'group', 'status', 'description', 'tags')),
- ('VLAN', ('region', 'site_group', 'site', 'vlan_group', 'vlan',)),
+ ('Wireless LAN', ('ssid', 'group', 'vlan', 'status', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')),
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
)
@@ -94,13 +57,14 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm):
class Meta:
model = WirelessLAN
fields = [
- 'ssid', 'group', 'region', 'site_group', 'site', 'status', 'vlan_group', 'vlan', 'tenant_group', 'tenant',
- 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags',
+ 'ssid', 'group', 'status', 'vlan', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk',
+ 'description', 'comments', 'tags',
]
widgets = {
- 'status': StaticSelect,
- 'auth_type': StaticSelect,
- 'auth_cipher': StaticSelect,
+ 'auth_psk': PasswordInput(
+ render_value=True,
+ attrs={'data-toggle': 'password'}
+ ),
}
@@ -203,9 +167,10 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm):
'comments', 'tags',
]
widgets = {
- 'status': StaticSelect,
- 'auth_type': StaticSelect,
- 'auth_cipher': StaticSelect,
+ 'auth_psk': PasswordInput(
+ render_value=True,
+ attrs={'data-toggle': 'password'}
+ ),
}
labels = {
'auth_type': 'Type',
diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py
index cd8fd9f52..e6e46be3f 100644
--- a/netbox/wireless/graphql/schema.py
+++ b/netbox/wireless/graphql/schema.py
@@ -2,14 +2,25 @@ import graphene
from netbox.graphql.fields import ObjectField, ObjectListField
from .types import *
+from utilities.graphql_optimizer import gql_query_optimizer
+from wireless import models
class WirelessQuery(graphene.ObjectType):
wireless_lan = ObjectField(WirelessLANType)
wireless_lan_list = ObjectListField(WirelessLANType)
+ def resolve_wireless_lan_list(root, info, **kwargs):
+ return gql_query_optimizer(models.WirelessLAN.objects.all(), info)
+
wireless_lan_group = ObjectField(WirelessLANGroupType)
wireless_lan_group_list = ObjectListField(WirelessLANGroupType)
+ def resolve_wireless_lan_group_list(root, info, **kwargs):
+ return gql_query_optimizer(models.WirelessLANGroup.objects.all(), info)
+
wireless_link = ObjectField(WirelessLinkType)
wireless_link_list = ObjectListField(WirelessLinkType)
+
+ def resolve_wireless_link_list(root, info, **kwargs):
+ return gql_query_optimizer(models.WirelessLink.objects.all(), info)
diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py
index 0e164cb1e..e1eb6fd7d 100644
--- a/netbox/wireless/views.py
+++ b/netbox/wireless/views.py
@@ -28,14 +28,13 @@ class WirelessLANGroupView(generic.ObjectView):
queryset = WirelessLANGroup.objects.all()
def get_extra_context(self, request, instance):
- wirelesslans = WirelessLAN.objects.restrict(request.user, 'view').filter(
- group=instance
+ groups = instance.get_descendants(include_self=True)
+ related_models = (
+ (WirelessLAN.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
)
- wirelesslans_table = tables.WirelessLANTable(wirelesslans, user=request.user, exclude=('group',))
- wirelesslans_table.configure(request)
return {
- 'wirelesslans_table': wirelesslans_table,
+ 'related_models': related_models,
}
@@ -53,7 +52,6 @@ class WirelessLANGroupDeleteView(generic.ObjectDeleteView):
class WirelessLANGroupBulkImportView(generic.BulkImportView):
queryset = WirelessLANGroup.objects.all()
model_form = forms.WirelessLANGroupImportForm
- table = tables.WirelessLANGroupTable
class WirelessLANGroupBulkEditView(generic.BulkEditView):
@@ -124,7 +122,6 @@ class WirelessLANDeleteView(generic.ObjectDeleteView):
class WirelessLANBulkImportView(generic.BulkImportView):
queryset = WirelessLAN.objects.all()
model_form = forms.WirelessLANImportForm
- table = tables.WirelessLANTable
class WirelessLANBulkEditView(generic.BulkEditView):
@@ -170,7 +167,6 @@ class WirelessLinkDeleteView(generic.ObjectDeleteView):
class WirelessLinkBulkImportView(generic.BulkImportView):
queryset = WirelessLink.objects.all()
model_form = forms.WirelessLinkImportForm
- table = tables.WirelessLinkTable
class WirelessLinkBulkEditView(generic.BulkEditView):
diff --git a/requirements.txt b/requirements.txt
index ce79ac1b8..2ea0f2522 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,36 +1,37 @@
-bleach==5.0.1
-Django==4.1.8
-django-cors-headers==3.14.0
-django-debug-toolbar==4.0.0
-django-filter==23.1
+bleach==6.0.0
+boto3==1.28.26
+Django==4.1.10
+django-cors-headers==4.2.0
+django-debug-toolbar==4.2.0
+django-filter==23.2
django-graphiql-debug-toolbar==0.2.0
django-mptt==0.14
django-pglocks==1.0.4
-django-prometheus==2.2.0
-django-redis==5.2.0
-django-rich==1.5.0
-django-rq==2.7.0
-django-tables2==2.5.3
-django-taggit==3.1.0
-django-timezone-field==5.0
+django-prometheus==2.3.1
+django-redis==5.3.0
+django-rich==1.7.0
+django-rq==2.8.1
+django-tables2==2.6.0
+django-taggit==4.0.0
+django-timezone-field==5.1
djangorestframework==3.14.0
-drf-yasg[validation]==1.21.5
+drf-spectacular==0.26.4
+drf-spectacular-sidecar==2023.8.1
+dulwich==0.21.5
+feedparser==6.0.10
graphene-django==3.0.0
-gunicorn==20.1.0
+gunicorn==21.2.0
Jinja2==3.1.2
Markdown==3.3.7
-mkdocs-material==9.1.6
-mkdocstrings[python-legacy]==0.21.2
+mkdocs-material==9.1.21
+mkdocstrings[python-legacy]==0.22.0
netaddr==0.8.0
-Pillow==9.5.0
-psycopg2-binary==2.9.6
-PyYAML==6.0
-sentry-sdk==1.19.1
-social-auth-app-django==5.0.0
-social-auth-core[openidconnect]==4.4.1
+Pillow==10.0.0
+psycopg2-binary==2.9.7
+PyYAML==6.0.1
+sentry-sdk==1.29.2
+social-auth-app-django==5.2.0
+social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3
-tablib==3.4.0
+tablib==3.5.0
tzdata==2023.3
-
-# Workaround for #7401
-jsonschema==3.2.0