From c21ec2139d5f675777b9dcfde258398525e02fe9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 23 Feb 2024 10:15:14 -0500 Subject: [PATCH 01/56] Delete obsolete file --- netbox/vpn/admin.py | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 netbox/vpn/admin.py diff --git a/netbox/vpn/admin.py b/netbox/vpn/admin.py deleted file mode 100644 index 8c38f3f3d..000000000 --- a/netbox/vpn/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. From 17ec264f3a0936e1157585133b075cac27f47647 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 23 Feb 2024 23:53:08 +0530 Subject: [PATCH 02/56] added display on virtual disk api #15241 --- netbox/virtualization/api/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 1dcb413ec..34e4037e9 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -172,6 +172,6 @@ class VirtualDiskSerializer(NetBoxModelSerializer): class Meta: model = VirtualDisk fields = [ - 'id', 'url', 'virtual_machine', 'name', 'description', 'size', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'virtual_machine', 'name', 'description', 'size', 'tags', 'custom_fields', + 'created', 'last_updated', ] From edb7d24b458b60fa82b16bc37e79f37821e3685f Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 23 Feb 2024 12:54:47 -0800 Subject: [PATCH 03/56] Added installed_module on NestedModuleBaySerializer (#15245) * added installed_module on NestedModuleBaySerializer #15243 * Update test --------- Co-authored-by: Jeremy Stretch --- netbox/dcim/api/nested_serializers.py | 4 ++-- netbox/dcim/api/serializers.py | 3 +-- netbox/dcim/tests/test_api.py | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index c8440612d..419d9b175 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -414,11 +414,11 @@ class NestedFrontPortSerializer(WritableNestedSerializer): class NestedModuleBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') - module = NestedModuleSerializer(required=False, read_only=True, allow_null=True) + installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True) class Meta: model = models.ModuleBay - fields = ['id', 'url', 'display', 'module', 'name'] + fields = ['id', 'url', 'display', 'installed_module', 'name'] class NestedDeviceBaySerializer(WritableNestedSerializer): diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f8541f013..ab3177de5 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1039,8 +1039,7 @@ class ModuleBaySerializer(NetBoxModelSerializer): model = ModuleBay fields = [ 'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', - 'custom_fields', - 'created', 'last_updated', + 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index d02422c6f..b0670fdff 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1755,7 +1755,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase): class ModuleBayTest(APIViewTestCases.APIViewTestCase): model = ModuleBay - brief_fields = ['display', 'id', 'module', 'name', 'url'] + brief_fields = ['display', 'id', 'installed_module', 'name', 'url'] bulk_update_data = { 'description': 'New description', } From 55ef24d56d6d45fa40f766d22ca8f21ed75b2558 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 29 Feb 2024 14:54:41 -0500 Subject: [PATCH 04/56] Fixes #15316: Fix selection of 3DES encryption for IKE & IPSec proposals --- netbox/vpn/choices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py index c4ae67619..4aa97f615 100644 --- a/netbox/vpn/choices.py +++ b/netbox/vpn/choices.py @@ -124,7 +124,7 @@ class EncryptionAlgorithmChoices(ChoiceSet): (ENCRYPTION_AES256_CBC, '256-bit AES (CBC)'), (ENCRYPTION_AES256_GCM, '256-bit AES (GCM)'), (ENCRYPTION_3DES, '3DES'), - (ENCRYPTION_3DES, 'DES'), + (ENCRYPTION_DES, 'DES'), ) From 8afbb4421bc0f58734ad44dca8052652ae73802a Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 27 Feb 2024 09:36:12 -0800 Subject: [PATCH 05/56] 15232 fix inventory item template permission --- netbox/dcim/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2a2fe39e3..d0e92ff56 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1079,7 +1079,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView): tab = ViewTab( label=_('Inventory Items'), badge=lambda obj: obj.inventory_item_template_count, - permission='dcim.view_invenotryitemtemplate', + permission='dcim.view_inventoryitemtemplate', weight=590, hide_if_empty=True ) From c45acf0a7c38cd8d04e38895baaf8765b53a5e32 Mon Sep 17 00:00:00 2001 From: Jeff Gehlbach Date: Fri, 23 Feb 2024 15:56:14 -0500 Subject: [PATCH 06/56] Fixes: Use systemctl enable --now shortcut in docs #15249 --- docs/installation/1-postgresql.md | 3 +-- docs/installation/2-redis.md | 3 +-- docs/installation/4-gunicorn.md | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 184fc26d2..9d30f4514 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -31,8 +31,7 @@ This section entails the installation and configuration of a local PostgreSQL da Once PostgreSQL has been installed, start the service and enable it to run at boot: ```no-highlight - sudo systemctl start postgresql - sudo systemctl enable postgresql + sudo systemctl enable --now postgresql ``` Before continuing, verify that you have installed PostgreSQL 12 or later: diff --git a/docs/installation/2-redis.md b/docs/installation/2-redis.md index 7c364947e..2756a1ab0 100644 --- a/docs/installation/2-redis.md +++ b/docs/installation/2-redis.md @@ -14,8 +14,7 @@ ```no-highlight sudo yum install -y redis - sudo systemctl start redis - sudo systemctl enable redis + sudo systemctl enable --now redis ``` Before continuing, verify that your installed version of Redis is at least v4.0: diff --git a/docs/installation/4-gunicorn.md b/docs/installation/4-gunicorn.md index e31c48466..1e8d49453 100644 --- a/docs/installation/4-gunicorn.md +++ b/docs/installation/4-gunicorn.md @@ -27,8 +27,7 @@ sudo systemctl daemon-reload Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time: ```no-highlight -sudo systemctl start netbox netbox-rq -sudo systemctl enable netbox netbox-rq +sudo systemctl enable --now netbox netbox-rq ``` You can use the command `systemctl status netbox` to verify that the WSGI service is running: From bdcf4c4154bcfc73f3c09c7f8cbc558af22e125c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 29 Feb 2024 16:03:54 -0500 Subject: [PATCH 07/56] Fixes #15220: Move IP mask validation logic from form to model --- netbox/ipam/forms/model_forms.py | 14 -------------- netbox/ipam/models/ip.py | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index c7e3f92a3..71aa32d52 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -367,20 +367,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): 'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.") ) - # Do not allow assigning a network ID or broadcast address to an interface. - if interface and (address := self.cleaned_data.get('address')): - if address.ip == address.network: - msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(ip=address.ip) - if address.version == 4 and address.prefixlen not in (31, 32): - raise ValidationError(msg) - if address.version == 6 and address.prefixlen not in (127, 128): - raise ValidationError(msg) - if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32): - msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format( - ip=address.ip - ) - raise ValidationError(msg) - def save(self, *args, **kwargs): ipaddress = super().save(*args, **kwargs) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 76fae2990..ca9592d6e 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -844,6 +844,25 @@ class IPAddress(PrimaryModel): 'address': _("Cannot create IP address with /0 mask.") }) + # Do not allow assigning a network ID or broadcast address to an interface. + if self.assigned_object: + if self.address.ip == self.address.network: + msg = _("{ip} is a network ID, which may not be assigned to an interface.").format( + ip=self.address.ip + ) + if self.address.version == 4 and self.address.prefixlen not in (31, 32): + raise ValidationError(msg) + if self.address.version == 6 and self.address.prefixlen not in (127, 128): + raise ValidationError(msg) + if ( + self.address.version == 4 and self.address.ip == self.address.broadcast and + self.address.prefixlen not in (31, 32) + ): + msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format( + ip=self.address.ip + ) + raise ValidationError(msg) + # Enforce unique IP space (if applicable) if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_ips = self.get_duplicates() From 663bd324641818e5bb79ae2f14d1610160860882 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 7 Mar 2024 08:41:34 -0800 Subject: [PATCH 08/56] 10587 script pagination (#15343) * 10587 temp commit * 10587 temp commit * 10587 fix migrations * 10587 pagination * 10587 pagination * 10587 pagination * 10587 review changes --- .../0108_convert_reports_to_scripts.py | 3 - netbox/extras/migrations/0109_script_model.py | 16 +- ...0110_remove_eventrule_action_parameters.py | 3 + netbox/extras/tables/tables.py | 62 ++++++- netbox/extras/views.py | 57 ++++++- .../templates/extras/htmx/script_result.html | 159 ++++++------------ netbox/templates/extras/script_result.html | 74 ++++++-- 7 files changed, 241 insertions(+), 133 deletions(-) diff --git a/netbox/extras/migrations/0108_convert_reports_to_scripts.py b/netbox/extras/migrations/0108_convert_reports_to_scripts.py index 072353550..b547c41c3 100644 --- a/netbox/extras/migrations/0108_convert_reports_to_scripts.py +++ b/netbox/extras/migrations/0108_convert_reports_to_scripts.py @@ -25,7 +25,4 @@ class Migration(migrations.Migration): migrations.DeleteModel( name='Report', ), - migrations.DeleteModel( - name='ReportModule', - ), ] diff --git a/netbox/extras/migrations/0109_script_model.py b/netbox/extras/migrations/0109_script_model.py index 89b343a82..7570077a7 100644 --- a/netbox/extras/migrations/0109_script_model.py +++ b/netbox/extras/migrations/0109_script_model.py @@ -82,10 +82,12 @@ def update_scripts(apps, schema_editor): ContentType = apps.get_model('contenttypes', 'ContentType') Script = apps.get_model('extras', 'Script') ScriptModule = apps.get_model('extras', 'ScriptModule') + ReportModule = apps.get_model('extras', 'ReportModule') Job = apps.get_model('core', 'Job') - script_ct = ContentType.objects.get_for_model(Script) - scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule) + script_ct = ContentType.objects.get_for_model(Script, for_concrete_model=False) + scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule, for_concrete_model=False) + reportmodule_ct = ContentType.objects.get_for_model(ReportModule, for_concrete_model=False) for module in ScriptModule.objects.all(): for script_name in get_module_scripts(module): @@ -96,10 +98,16 @@ def update_scripts(apps, schema_editor): # Update all Jobs associated with this ScriptModule & script name to point to the new Script object Job.objects.filter( - object_type=scriptmodule_ct, + object_type_id=scriptmodule_ct.id, object_id=module.pk, name=script_name - ).update(object_type=script_ct, object_id=script.pk) + ).update(object_type_id=script_ct.id, object_id=script.pk) + # Update all Jobs associated with this ScriptModule & script name to point to the new Script object + Job.objects.filter( + object_type_id=reportmodule_ct.id, + object_id=module.pk, + name=script_name + ).update(object_type_id=script_ct.id, object_id=script.pk) def update_event_rules(apps, schema_editor): diff --git a/netbox/extras/migrations/0110_remove_eventrule_action_parameters.py b/netbox/extras/migrations/0110_remove_eventrule_action_parameters.py index 910352462..b7373bdce 100644 --- a/netbox/extras/migrations/0110_remove_eventrule_action_parameters.py +++ b/netbox/extras/migrations/0110_remove_eventrule_action_parameters.py @@ -12,4 +12,7 @@ class Migration(migrations.Migration): model_name='eventrule', name='action_parameters', ), + migrations.DeleteModel( + name='ReportModule', + ), ] diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index fee0c9f29..819beb1a5 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -5,7 +5,7 @@ from django.conf import settings from django.utils.translation import gettext_lazy as _ from extras.models import * -from netbox.tables import NetBoxTable, columns +from netbox.tables import BaseTable, NetBoxTable, columns from .template_code import * __all__ = ( @@ -21,6 +21,8 @@ __all__ = ( 'JournalEntryTable', 'ObjectChangeTable', 'SavedFilterTable', + 'ReportResultsTable', + 'ScriptResultsTable', 'TaggedItemTable', 'TagTable', 'WebhookTable', @@ -507,3 +509,61 @@ class JournalEntryTable(NetBoxTable): default_columns = ( 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments' ) + + +class ScriptResultsTable(BaseTable): + index = tables.Column( + verbose_name=_('Line') + ) + time = tables.Column( + verbose_name=_('Time') + ) + status = tables.TemplateColumn( + template_code="""{% load log_levels %}{% log_level record.status %}""", + verbose_name=_('Level') + ) + message = tables.Column( + verbose_name=_('Message') + ) + + class Meta(BaseTable.Meta): + empty_text = _('No results found') + fields = ( + 'index', 'time', 'status', 'message', + ) + + +class ReportResultsTable(BaseTable): + index = tables.Column( + verbose_name=_('Line') + ) + method = tables.Column( + verbose_name=_('Method') + ) + time = tables.Column( + verbose_name=_('Time') + ) + status = tables.Column( + empty_values=(), + verbose_name=_('Level') + ) + status = tables.TemplateColumn( + template_code="""{% load log_levels %}{% log_level record.status %}""", + verbose_name=_('Level') + ) + + object = tables.Column( + verbose_name=_('Object') + ) + url = tables.Column( + verbose_name=_('URL') + ) + message = tables.Column( + verbose_name=_('Message') + ) + + class Meta(BaseTable.Meta): + empty_text = _('No results found') + fields = ( + 'index', 'method', 'time', 'status', 'object', 'url', 'message', + ) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 73fdb6b83..1fa2a30aa 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -17,6 +17,7 @@ from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.utils import get_widget_class from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic +from netbox.views.generic.mixins import TableMixin from utilities.forms import ConfirmationForm, get_field_value from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.rqworker import get_workers_for_queue @@ -26,6 +27,7 @@ from utilities.views import ContentTypePermissionRequiredMixin, register_model_v from . import filtersets, forms, tables from .models import * from .scripts import run_script +from .tables import ReportResultsTable, ScriptResultsTable # @@ -1143,19 +1145,72 @@ class LegacyScriptRedirectView(ContentTypePermissionRequiredMixin, View): return redirect(f'{url}{path}') -class ScriptResultView(generic.ObjectView): +class ScriptResultView(TableMixin, generic.ObjectView): queryset = Job.objects.all() def get_required_permission(self): return 'extras.view_script' + def get_table(self, job, request, bulk_actions=True): + data = [] + tests = None + table = None + index = 0 + if job.data: + if 'log' in job.data: + if 'tests' in job.data: + tests = job.data['tests'] + + for log in job.data['log']: + index += 1 + result = { + 'index': index, + 'time': log.get('time'), + 'status': log.get('status'), + 'message': log.get('message'), + } + data.append(result) + + table = ScriptResultsTable(data, user=request.user) + table.configure(request) + else: + # for legacy reports + tests = job.data + + if tests: + for method, test_data in tests.items(): + if 'log' in test_data: + for time, status, obj, url, message in test_data['log']: + index += 1 + result = { + 'index': index, + 'method': method, + 'time': time, + 'status': status, + 'object': obj, + 'url': url, + 'message': message, + } + data.append(result) + + table = ReportResultsTable(data, user=request.user) + table.configure(request) + + return table + def get(self, request, **kwargs): + table = None job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk')) + if job.completed: + table = self.get_table(job, request, bulk_actions=False) + context = { 'script': job.object, 'job': job, + 'table': table, } + if job.data and 'log' in job.data: # Script context['tests'] = job.data.get('tests', {}) diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index ed5dd9cbd..e532e07e1 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -3,124 +3,63 @@ {% load log_levels %} {% load i18n %} -

- {% if job.started %} - {% trans "Started" %}: {{ job.started|annotated_date }} - {% elif job.scheduled %} - {% trans "Scheduled for" %}: {{ job.scheduled|annotated_date }} ({{ job.scheduled|naturaltime }}) - {% else %} - {% trans "Created" %}: {{ job.created|annotated_date }} - {% endif %} +

+

+ {% if job.started %} + {% trans "Started" %}: {{ job.started|annotated_date }} + {% elif job.scheduled %} + {% trans "Scheduled for" %}: {{ job.scheduled|annotated_date }} ({{ job.scheduled|naturaltime }}) + {% else %} + {% trans "Created" %}: {{ job.created|annotated_date }} + {% endif %} + {% if job.completed %} + {% trans "Duration" %}: {{ job.duration }} + {% endif %} + {% badge job.get_status_display job.get_status_color %} +

{% if job.completed %} - {% trans "Duration" %}: {{ job.duration }} - {% endif %} - {% badge job.get_status_display job.get_status_color %} -

-{% if job.completed %} - - {# Script log. Legacy reports will not have this. #} - {% if 'log' in job.data %} -
-
{% trans "Log" %}
- {% if job.data.log %} - - - - - - - - {% for log in job.data.log %} + {% if tests %} + {# Summary of test methods #} +
+
{% trans "Test Summary" %}
+
{% trans "Line" %}{% trans "Time" %}{% trans "Level" %}{% trans "Message" %}
+ {% for test, data in tests.items %} - - - - + + {% endfor %}
{{ forloop.counter }}{{ log.time|placeholder }}{% log_level log.status %}{{ log.message|markdown }}{{ test }} + {{ data.success }} + {{ data.info }} + {{ data.warning }} + {{ data.failure }} +
- {% else %} -
{% trans "None" %}
- {% endif %} -
- {% endif %} +
+ {% endif %} - {# Script output. Legacy reports will not have this. #} - {% if 'output' in job.data %} -
-
{% trans "Output" %}
- {% if job.data.output %} -
{{ job.data.output }}
- {% else %} -
{% trans "None" %}
- {% endif %} -
- {% endif %} - - {# Test method logs (for legacy Reports) #} - {% if tests %} - - {# Summary of test methods #} + {% if table %}
-
{% trans "Test Summary" %}
- - {% for test, data in tests.items %} - - - - - {% endfor %} -
{{ test }} - {{ data.success }} - {{ data.info }} - {{ data.warning }} - {{ data.failure }} -
+
+
{% trans "Log" %}
+ {% include 'htmx/table.html' %} +
+ {% endif %} - {# Detailed results for individual tests #} -
-
{% trans "Test Details" %}
- - - - - - - - - - - {% for test, data in tests.items %} - - - - {% for time, level, obj, url, message in data.log %} - - - - - - - {% endfor %} - {% endfor %} - -
{% trans "Time" %}{% trans "Level" %}{% trans "Object" %}{% trans "Message" %}
- {{ test }} -
{{ time }} - - - {% if obj and url %} - {{ obj }} - {% elif obj %} - {{ obj }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {{ message|markdown }}
-
+ {# Script output. Legacy reports will not have this. #} + {% if 'output' in job.data %} +
+
{% trans "Output" %}
+ {% if job.data.output %} +
{{ job.data.output }}
+ {% else %} +
{% trans "None" %}
+ {% endif %} +
+ {% endif %} + {% elif job.started %} + {% include 'extras/inc/result_pending.html' %} {% endif %} -{% elif job.started %} - {% include 'extras/inc/result_pending.html' %} -{% endif %} + diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html index 8f6d817c7..030e73903 100644 --- a/netbox/templates/extras/script_result.html +++ b/netbox/templates/extras/script_result.html @@ -32,28 +32,74 @@ {% block tabs %} {% endblock %} {% block content %} -
-
-
- {% include 'extras/htmx/script_result.html' %} + {# Object list tab #} +
+ + {# Object table controls #} +
+
+ {% if request.user.is_authenticated %} +
+ +
+ {% endif %} +
+ +
+ {% csrf_token %} + {# "Select all" form #} + {% if table.paginator.num_pages > 1 %} +
+
+
+
+ + +
+
+
+
+ {% endif %} + +
+ {% csrf_token %} + + + {# Objects table #} +
+ {% include 'extras/htmx/script_result.html' %} +
+ {# /Objects table #} + +
+
-
-
-

{{ script.filename }}

-
{{ script.source }}
-
+ {# /Object list tab #} + + {# Filters tab #} + {% if filter_form %} +
+ {% include 'inc/filter_list.html' %} +
+ {% endif %} + {# /Filters tab #} + {% endblock content %} {% block modals %} - {% include 'inc/htmx_modal.html' %} + {% table_config_form table table_name="ObjectTable" %} {% endblock modals %} From 6629c941483704c5092bc20d564ad9527a83908d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 Mar 2024 16:48:39 -0500 Subject: [PATCH 09/56] Closes #15297: Linkify platform column in device & virtual machine tables --- docs/release-notes/version-3.7.md | 4 ++++ netbox/dcim/tables/devices.py | 6 +++++- netbox/virtualization/tables/virtualmachines.py | 10 +++++++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 21e7489c3..097cd70e5 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -2,6 +2,10 @@ ## v3.7.4 (FUTURE) +### Enhancements + +* [#15297](https://github.com/netbox-community/netbox/issues/15297) - Linkify platform column in device & virtual machine tables + --- ## v3.7.3 (2024-02-21) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index d4c9641b6..98dcfcb3c 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -210,6 +210,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): linkify=True, verbose_name=_('Type') ) + platform = tables.Column( + linkify=True, + verbose_name=_('Platform') + ) primary_ip = tables.Column( linkify=True, order_by=('primary_ip4', 'primary_ip6'), @@ -294,7 +298,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): model = models.Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'role', 'manufacturer', 'device_type', - 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', + 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', 'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated', diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 632e6878a..36030baee 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -64,6 +64,10 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable) role = columns.ColoredLabelColumn( verbose_name=_('Role'), ) + platform = tables.Column( + linkify=True, + verbose_name=_('Platform') + ) comments = columns.MarkdownColumn( verbose_name=_('Comments'), ) @@ -97,9 +101,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable) class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform', - 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments', - 'config_template', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'vcpus', + 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments', 'config_template', + 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', From 8bb49d229692fbc9e33edb4733a0d1d20f82ff37 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 Mar 2024 16:58:04 -0500 Subject: [PATCH 10/56] Closes #15291: Add tunnel termination buttons to VM interfaces table --- docs/release-notes/version-3.7.md | 1 + netbox/virtualization/tables/virtualmachines.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 097cd70e5..846a7a115 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -4,6 +4,7 @@ ### Enhancements +* [#15291](https://github.com/netbox-community/netbox/issues/15291) - Add tunnel termination buttons to VM interfaces table * [#15297](https://github.com/netbox-community/netbox/issues/15297) - Linkify platform column in device & virtual machine tables --- diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 36030baee..ba5360a62 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -33,6 +33,15 @@ VMINTERFACE_BUTTONS = """ {% endif %} +{% if perms.vpn.add_tunnel and not record.tunnel_termination %} + + + +{% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %} + + + +{% endif %} """ From eeb732d96e18c9c897db849f469e77b9924ac04a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 Mar 2024 17:03:18 -0500 Subject: [PATCH 11/56] Fixes #15336: Correct label for recurring scheduled jobs --- docs/release-notes/version-3.7.md | 4 ++++ netbox/templates/core/job.html | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 846a7a115..fd39b9255 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -7,6 +7,10 @@ * [#15291](https://github.com/netbox-community/netbox/issues/15291) - Add tunnel termination buttons to VM interfaces table * [#15297](https://github.com/netbox-community/netbox/issues/15297) - Linkify platform column in device & virtual machine tables +### Bug Fixes + +* [#15336](https://github.com/netbox-community/netbox/issues/15336) - Correct label for recurring scheduled jobs + --- ## v3.7.3 (2024-02-21) diff --git a/netbox/templates/core/job.html b/netbox/templates/core/job.html index deb651739..14c7815c0 100644 --- a/netbox/templates/core/job.html +++ b/netbox/templates/core/job.html @@ -63,7 +63,7 @@ {{ object.scheduled|annotated_date|placeholder }} {% if object.interval %} - ({% blocktrans with interval=object.interval %}every {{ interval }} seconds{% endblocktrans %}) + ({% blocktrans with interval=object.interval %}every {{ interval }} minutes{% endblocktrans %}) {% endif %} From de622801f107b320ae934938895c2e6b8cdb4b2a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 Mar 2024 17:05:10 -0500 Subject: [PATCH 12/56] Changelog for #15220, #15232, #15241, #15243, #15316 --- docs/release-notes/version-3.7.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index fd39b9255..17a3bf9cc 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -9,6 +9,11 @@ ### Bug Fixes +* [#15220](https://github.com/netbox-community/netbox/issues/15220) - Fix validation check when bulk editing the mask length of IP addresses +* [#15232](https://github.com/netbox-community/netbox/issues/15232) - Permit user with sufficient permissions to assign an inventory item to a device type +* [#15241](https://github.com/netbox-community/netbox/issues/15241) - Restore missing `display` field on VirtualDisk serialization in REST API +* [#15243](https://github.com/netbox-community/netbox/issues/15243) - Correct representation of installed module when listing module bays using REST API brief mode +* [#15316](https://github.com/netbox-community/netbox/issues/15316) - Fix selection of 3DES encryption for IKE & IPSec proposals * [#15336](https://github.com/netbox-community/netbox/issues/15336) - Correct label for recurring scheduled jobs --- From 78dd65219f89293cde539e2029d9a2284a70544b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Sat, 9 Mar 2024 06:16:17 -0500 Subject: [PATCH 13/56] Closes #15357: Rename CustomField.object_type to related_object_type (#15366) --- docs/models/extras/customfield.md | 2 +- netbox/extras/api/customfields.py | 6 ++-- .../extras/api/serializers_/customfields.py | 10 +++--- netbox/extras/filtersets.py | 4 +++ netbox/extras/forms/bulk_import.py | 4 +-- netbox/extras/forms/filtersets.py | 8 ++--- netbox/extras/forms/model_forms.py | 6 ++-- .../0113_customfield_rename_object_type.py | 16 +++++++++ netbox/extras/models/customfields.py | 16 ++++----- netbox/extras/tables/tables.py | 9 +++-- netbox/extras/tests/test_customfields.py | 34 +++++++++++++------ netbox/extras/tests/test_filtersets.py | 16 +++++++++ netbox/extras/tests/test_forms.py | 4 +-- netbox/extras/tests/test_views.py | 2 +- netbox/templates/extras/customfield.html | 4 ++- 15 files changed, 97 insertions(+), 44 deletions(-) create mode 100644 netbox/extras/migrations/0113_customfield_rename_object_type.py diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index e68ddb79d..495c4e2e8 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -38,7 +38,7 @@ The type of data this field holds. This must be one of the following: | Object | A single NetBox object of the type defined by `object_type` | | Multiple object | One or more NetBox objects of the type defined by `object_type` | -### Object Type +### Related Object Type For object and multiple-object fields only. Designates the type of NetBox object being referenced. diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 81535a147..09f247929 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -57,10 +57,10 @@ class CustomFieldsDataField(Field): for cf in self._get_custom_fields(): value = cf.deserialize(obj.get(cf.name)) if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT: - serializer = get_serializer_for_model(cf.object_type.model_class()) + serializer = get_serializer_for_model(cf.related_object_type.model_class()) value = serializer(value, nested=True, context=self.parent.context).data elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - serializer = get_serializer_for_model(cf.object_type.model_class()) + serializer = get_serializer_for_model(cf.related_object_type.model_class()) value = serializer(value, nested=True, many=True, context=self.parent.context).data data[cf.name] = value @@ -79,7 +79,7 @@ class CustomFieldsDataField(Field): CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT ): - serializer_class = get_serializer_for_model(cf.object_type.model_class()) + serializer_class = get_serializer_for_model(cf.related_object_type.model_class()) many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT serializer = serializer_class(data=data[cf.name], nested=True, many=many, context=self.parent.context) if serializer.is_valid(): diff --git a/netbox/extras/api/serializers_/customfields.py b/netbox/extras/api/serializers_/customfields.py index efd6db063..79bb39557 100644 --- a/netbox/extras/api/serializers_/customfields.py +++ b/netbox/extras/api/serializers_/customfields.py @@ -44,7 +44,7 @@ class CustomFieldSerializer(ValidatedModelSerializer): many=True ) type = ChoiceField(choices=CustomFieldTypeChoices) - object_type = ContentTypeField( + related_object_type = ContentTypeField( queryset=ObjectType.objects.all(), required=False, allow_null=True @@ -62,10 +62,10 @@ class CustomFieldSerializer(ValidatedModelSerializer): class Meta: model = CustomField fields = [ - 'id', 'url', 'display', 'object_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', - 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', - 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', - 'created', 'last_updated', + 'id', 'url', 'display', 'object_types', 'type', 'related_object_type', 'data_type', 'name', 'label', + 'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', + 'is_cloneable', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'choice_set', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 6cb309580..d88b8c9b3 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -132,6 +132,10 @@ class CustomFieldFilterSet(BaseFilterSet): object_type = ContentTypeFilter( field_name='object_types' ) + related_object_type_id = MultiValueNumberFilter( + field_name='related_object_type__id' + ) + related_object_type = ContentTypeFilter() choice_set_id = django_filters.ModelMultipleChoiceFilter( queryset=CustomFieldChoiceSet.objects.all() ) diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 39d2933a7..55f71dbd2 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -40,7 +40,7 @@ class CustomFieldImportForm(CSVModelForm): choices=CustomFieldTypeChoices, help_text=_('Field data type (e.g. text, integer, etc.)') ) - object_type = CSVContentTypeField( + related_object_type = CSVContentTypeField( label=_('Object type'), queryset=ObjectType.objects.public(), required=False, @@ -69,7 +69,7 @@ class CustomFieldImportForm(CSVModelForm): class Meta: model = CustomField fields = ( - 'name', 'label', 'group_name', 'type', 'object_types', 'object_type', 'required', 'description', + 'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'description', 'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 285e7618f..73751872f 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -38,14 +38,14 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), (_('Attributes'), ( - 'type', 'object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', 'ui_editable', - 'is_cloneable', + 'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', + 'ui_editable', 'is_cloneable', )), ) - object_type_id = ContentTypeMultipleChoiceField( + related_object_type_id = ContentTypeMultipleChoiceField( queryset=ObjectType.objects.with_feature('custom_fields'), required=False, - label=_('Object type') + label=_('Related object type') ) type = forms.MultipleChoiceField( choices=CustomFieldTypeChoices, diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 7f36db657..09d2d9535 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -42,8 +42,8 @@ class CustomFieldForm(forms.ModelForm): label=_('Object types'), queryset=ObjectType.objects.with_feature('custom_fields') ) - object_type = ContentTypeChoiceField( - label=_('Object type'), + related_object_type = ContentTypeChoiceField( + label=_('Related object type'), queryset=ObjectType.objects.public(), required=False, help_text=_("Type of the related object (for object/multi-object fields only)") @@ -55,7 +55,7 @@ class CustomFieldForm(forms.ModelForm): fieldsets = ( (_('Custom Field'), ( - 'object_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description', + 'object_types', 'name', 'label', 'group_name', 'type', 'related_object_type', 'required', 'description', )), (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')), (_('Values'), ('default', 'choice_set')), diff --git a/netbox/extras/migrations/0113_customfield_rename_object_type.py b/netbox/extras/migrations/0113_customfield_rename_object_type.py new file mode 100644 index 000000000..73c4a2a61 --- /dev/null +++ b/netbox/extras/migrations/0113_customfield_rename_object_type.py @@ -0,0 +1,16 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0112_tag_update_object_types'), + ] + + operations = [ + migrations.RenameField( + model_name='customfield', + old_name='object_type', + new_name='related_object_type', + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 681bd4f2a..a14c71c63 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -78,7 +78,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): default=CustomFieldTypeChoices.TYPE_TEXT, help_text=_('The type of data this custom field holds') ) - object_type = models.ForeignKey( + related_object_type = models.ForeignKey( to='core.ObjectType', on_delete=models.PROTECT, blank=True, @@ -209,7 +209,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): objects = CustomFieldManager() clone_fields = ( - 'object_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight', + 'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable', ) @@ -344,11 +344,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): # Object fields must define an object_type; other fields must not if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT): - if not self.object_type: + if not self.related_object_type: raise ValidationError({ 'object_type': _("Object fields must define an object type.") }) - elif self.object_type: + elif self.related_object_type: raise ValidationError({ 'object_type': _( "{type} fields may not define an object type.") @@ -388,10 +388,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): except ValueError: return value if self.type == CustomFieldTypeChoices.TYPE_OBJECT: - model = self.object_type.model_class() + model = self.related_object_type.model_class() return model.objects.filter(pk=value).first() if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - model = self.object_type.model_class() + model = self.related_object_type.model_class() return model.objects.filter(pk__in=value) return value @@ -488,7 +488,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): # Object elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: - model = self.object_type.model_class() + model = self.related_object_type.model_class() field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField field = field_class( queryset=model.objects.all(), @@ -498,7 +498,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): # Multiple objects elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - model = self.object_type.model_class() + model = self.related_object_type.model_class() field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField field = field_class( queryset=model.objects.all(), diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 819beb1a5..a0f504931 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -57,6 +57,9 @@ class CustomFieldTable(NetBoxTable): description = columns.MarkdownColumn( verbose_name=_('Description') ) + related_object_type = columns.ContentTypeColumn( + verbose_name=_('Related Object Type') + ) choice_set = tables.Column( linkify=True, verbose_name=_('Choice Set') @@ -73,9 +76,9 @@ class CustomFieldTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = CustomField fields = ( - 'pk', 'id', 'name', 'object_types', 'label', 'type', 'group_name', 'required', 'default', 'description', - 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', 'weight', 'choice_set', - 'choices', 'created', 'last_updated', + 'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_type', 'group_name', 'required', + 'default', 'description', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', + 'weight', 'choice_set', 'choices', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'description') diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 7ca18250c..0c8b86f93 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -350,7 +350,7 @@ class CustomFieldTest(TestCase): cf = CustomField.objects.create( name='object_field', type=CustomFieldTypeChoices.TYPE_OBJECT, - object_type=ObjectType.objects.get_for_model(VLAN), + related_object_type=ObjectType.objects.get_for_model(VLAN), required=False ) cf.object_types.set([self.object_type]) @@ -382,7 +382,7 @@ class CustomFieldTest(TestCase): cf = CustomField.objects.create( name='object_field', type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, - object_type=ObjectType.objects.get_for_model(VLAN), + related_object_type=ObjectType.objects.get_for_model(VLAN), required=False ) cf.object_types.set([self.object_type]) @@ -498,16 +498,28 @@ class CustomFieldTest(TestCase): ).full_clean() # Object - CustomField(name='test', type='object', required=True, object_type=object_type, default=site.pk).full_clean() - with self.assertRaises(ValidationError): - CustomField(name='test', type='object', required=True, object_type=object_type, default="xxx").full_clean() + CustomField( + name='test', + type='object', + required=True, + related_object_type=object_type, + default=site.pk + ).full_clean() + with (self.assertRaises(ValidationError)): + CustomField( + name='test', + type='object', + required=True, + related_object_type=object_type, + default="xxx" + ).full_clean() # Multi-object CustomField( name='test', type='multiobject', required=True, - object_type=object_type, + related_object_type=object_type, default=[site.pk] ).full_clean() with self.assertRaises(ValidationError): @@ -515,7 +527,7 @@ class CustomFieldTest(TestCase): name='test', type='multiobject', required=True, - object_type=object_type, + related_object_type=object_type, default=["xxx"] ).full_clean() @@ -581,13 +593,13 @@ class CustomFieldAPITest(APITestCase): CustomField( type=CustomFieldTypeChoices.TYPE_OBJECT, name='object_field', - object_type=ObjectType.objects.get_for_model(VLAN), + related_object_type=ObjectType.objects.get_for_model(VLAN), default=vlans[0].pk, ), CustomField( type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, name='multiobject_field', - object_type=ObjectType.objects.get_for_model(VLAN), + related_object_type=ObjectType.objects.get_for_model(VLAN), default=[vlans[0].pk, vlans[1].pk], ), ) @@ -1410,7 +1422,7 @@ class CustomFieldModelFilterTest(TestCase): cf = CustomField( name='cf11', type=CustomFieldTypeChoices.TYPE_OBJECT, - object_type=ObjectType.objects.get_for_model(Manufacturer) + related_object_type=ObjectType.objects.get_for_model(Manufacturer) ) cf.save() cf.object_types.set([object_type]) @@ -1419,7 +1431,7 @@ class CustomFieldModelFilterTest(TestCase): cf = CustomField( name='cf12', type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, - object_type=ObjectType.objects.get_for_model(Manufacturer) + related_object_type=ObjectType.objects.get_for_model(Manufacturer) ) cf.save() cf.object_types.set([object_type]) diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 4f9279831..bec62c688 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -86,6 +86,16 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): ui_editable=CustomFieldUIEditableChoices.HIDDEN, choice_set=choice_sets[1] ), + CustomField( + name='Custom Field 6', + type=CustomFieldTypeChoices.TYPE_OBJECT, + related_object_type=ObjectType.objects.get_by_natural_key('dcim', 'site'), + required=False, + weight=600, + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, + ui_visible=CustomFieldUIVisibleChoices.HIDDEN, + ui_editable=CustomFieldUIEditableChoices.HIDDEN + ), ) CustomField.objects.bulk_create(custom_fields) custom_fields[0].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'site')) @@ -108,6 +118,12 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): params = {'object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_related_object_type(self): + params = {'related_object_type': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'related_object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_required(self): params = {'required': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index 4c96e72d6..66c4e245e 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -62,14 +62,14 @@ class CustomFieldModelFormTest(TestCase): cf_object = CustomField.objects.create( name='object', type=CustomFieldTypeChoices.TYPE_OBJECT, - object_type=ObjectType.objects.get_for_model(Site) + related_object_type=ObjectType.objects.get_for_model(Site) ) cf_object.object_types.set([object_type]) cf_multiobject = CustomField.objects.create( name='multiobject', type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, - object_type=ObjectType.objects.get_for_model(Site) + related_object_type=ObjectType.objects.get_for_model(Site) ) cf_multiobject.object_types.set([object_type]) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ca6ad9174..fd478acd4 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -54,7 +54,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'name,label,type,object_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visible,ui_editable', + 'name,label,type,object_types,related_object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visible,ui_editable', 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},always,yes', 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,always,yes', 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,always,yes', diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index ddc6b30f4..1fec35417 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -17,7 +17,9 @@ Type {{ object.get_type_display }} - {% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %} + {% if object.related_object_type %} + ({{ object.related_object_type.model|bettertitle }}) + {% endif %} From f0e137133f9cea63e9ddd6101196bf41e83a20ca Mon Sep 17 00:00:00 2001 From: Leo Chen Date: Mon, 4 Mar 2024 23:18:38 +0800 Subject: [PATCH 14/56] Fixes: #14832 Extend GraphQL FHRPGroupType with IPAddressesMixin --- netbox/ipam/graphql/types.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index b4350f9f2..d19837fd1 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,6 +1,7 @@ import graphene from ipam import filtersets, models +from .mixins import IPAddressesMixin from netbox.graphql.scalars import BigInt from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType @@ -71,7 +72,7 @@ class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType): filterset_class = filtersets.AggregateFilterSet -class FHRPGroupType(NetBoxObjectType): +class FHRPGroupType(NetBoxObjectType, IPAddressesMixin): class Meta: model = models.FHRPGroup From 1ff4e1287fec67721e8446707f32eecb224ed9e3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 11 Mar 2024 09:50:10 -0500 Subject: [PATCH 15/56] Fixes: #13722 - Correct range expansion code when a numeric set is used (#15301) * Fixes: #13722 - Correct range expansion code when a numeric set is used * Correct to my own suggestion * Clean up logic * Simplify range detection --------- Co-authored-by: Jeremy Stretch --- netbox/utilities/forms/utils.py | 43 ++++++++++++++++------------ netbox/utilities/tests/test_forms.py | 11 ++++++- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 689fbebbf..0429fe571 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -51,36 +51,43 @@ def parse_alphanumeric_range(string): '0-3,a-d' => [0, 1, 2, 3, a, b, c, d] """ values = [] - for dash_range in string.split(','): + for value in string.split(','): + if '-' not in value: + # Item is not a range + values.append(value) + continue + + # Find the range's beginning & end values try: - begin, end = dash_range.split('-') + begin, end = value.split('-') vals = begin + end # Break out of loop if there's an invalid pattern to return an error if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())): return [] except ValueError: - begin, end = dash_range, dash_range + raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=value)) + + # Numeric range if begin.isdigit() and end.isdigit(): if int(begin) >= int(end): - raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range)) - + raise forms.ValidationError( + _('Invalid range: Ending value ({end}) must be greater than beginning value ({begin}).').format( + begin=begin, end=end + ) + ) for n in list(range(int(begin), int(end) + 1)): values.append(n) + + # Alphanumeric range else: - # Value-based - if begin == end: - values.append(begin) - # Range-based - else: - # Not a valid range (more than a single character) - if not len(begin) == len(end) == 1: - raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range)) + # Not a valid range (more than a single character) + if not len(begin) == len(end) == 1: + raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=value)) + if ord(begin) >= ord(end): + raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=value)) + for n in list(range(ord(begin), ord(end) + 1)): + values.append(chr(n)) - if ord(begin) >= ord(end): - raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range)) - - for n in list(range(ord(begin), ord(end) + 1)): - values.append(chr(n)) return values diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index d014d4bbd..aab9af870 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -191,7 +191,16 @@ class ExpandAlphanumeric(TestCase): self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output) - def test_set(self): + def test_set_numeric(self): + input = 'r[1,2]a' + output = sorted([ + 'r1a', + 'r2a', + ]) + + self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output) + + def test_set_alpha(self): input = '[r,t]1a' output = sorted([ 'r1a', From 21de3f954fe3b2bee4e175ab6d6a892592c54c16 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 Mar 2024 11:49:04 -0400 Subject: [PATCH 16/56] #15357: Rename CustomField object_type to related_object_type --- netbox/users/migrations/0005_alter_user_table.py | 2 +- netbox/users/migrations/0006_custom_group_model.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/users/migrations/0005_alter_user_table.py b/netbox/users/migrations/0005_alter_user_table.py index e07db6875..22c6bdd42 100644 --- a/netbox/users/migrations/0005_alter_user_table.py +++ b/netbox/users/migrations/0005_alter_user_table.py @@ -14,7 +14,7 @@ def update_content_types(apps, schema_editor): if netboxuser_ct: user_ct = ContentType.objects.filter(app_label='users', model='user').first() CustomField = apps.get_model('extras', 'CustomField') - CustomField.objects.filter(object_type_id=netboxuser_ct.id).update(object_type_id=user_ct.id) + CustomField.objects.filter(related_object_type_id=netboxuser_ct.id).update(related_object_type_id=user_ct.id) netboxuser_ct.delete() diff --git a/netbox/users/migrations/0006_custom_group_model.py b/netbox/users/migrations/0006_custom_group_model.py index 282da3ce0..04f4d0fd8 100644 --- a/netbox/users/migrations/0006_custom_group_model.py +++ b/netbox/users/migrations/0006_custom_group_model.py @@ -12,7 +12,7 @@ def update_custom_fields(apps, schema_editor): if old_ct := ContentType.objects.filter(app_label='users', model='netboxgroup').first(): new_ct = ContentType.objects.get_for_model(Group) - CustomField.objects.filter(object_type=old_ct).update(object_type=new_ct) + CustomField.objects.filter(related_object_type=old_ct).update(related_object_type=new_ct) class Migration(migrations.Migration): From d6acc18c29b776145e045309b5a45e1e05a922ce Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 Mar 2024 12:32:06 -0400 Subject: [PATCH 17/56] Closes #15383: Standardize filtering logic for the parents of recursively-nested models --- netbox/dcim/filtersets.py | 40 ++++++- netbox/dcim/tests/test_filtersets.py | 131 ++++++++++++++++------- netbox/tenancy/filtersets.py | 30 +++++- netbox/tenancy/tests/test_filtersets.py | 94 ++++++++++------ netbox/wireless/filtersets.py | 11 ++ netbox/wireless/tests/test_filtersets.py | 46 +++++--- 6 files changed, 262 insertions(+), 90 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6b1611694..082659b8f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -89,6 +89,19 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): to_field_name='slug', label=_('Parent region (slug)'), ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Region (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', + label=_('Region (slug)'), + ) class Meta: model = Region @@ -106,6 +119,19 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): to_field_name='slug', label=_('Parent site group (slug)'), ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Site group (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', + label=_('Site group (slug)'), + ) class Meta: model = SiteGroup @@ -214,13 +240,23 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM to_field_name='slug', label=_('Site (slug)'), ) - parent_id = TreeNodeMultipleChoiceFilter( + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=Location.objects.all(), + label=_('Parent location (ID)'), + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=Location.objects.all(), + to_field_name='slug', + label=_('Parent location (slug)'), + ) + ancestor_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='parent', lookup_expr='in', label=_('Location (ID)'), ) - parent = TreeNodeMultipleChoiceFilter( + ancestor = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='parent', lookup_expr='in', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index b255c283e..f1eeddbb5 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -64,21 +64,32 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): - regions = ( + parent_regions = ( Region(name='Region 1', slug='region-1', description='foobar1'), Region(name='Region 2', slug='region-2', description='foobar2'), Region(name='Region 3', slug='region-3', description='foobar3'), ) + for region in parent_regions: + region.save() + + regions = ( + Region(name='Region 1A', slug='region-1a', parent=parent_regions[0]), + Region(name='Region 1B', slug='region-1b', parent=parent_regions[0]), + Region(name='Region 2A', slug='region-2a', parent=parent_regions[1]), + Region(name='Region 2B', slug='region-2b', parent=parent_regions[1]), + Region(name='Region 3A', slug='region-3a', parent=parent_regions[2]), + Region(name='Region 3B', slug='region-3b', parent=parent_regions[2]), + ) for region in regions: region.save() child_regions = ( - Region(name='Region 1A', slug='region-1a', parent=regions[0]), - Region(name='Region 1B', slug='region-1b', parent=regions[0]), - Region(name='Region 2A', slug='region-2a', parent=regions[1]), - Region(name='Region 2B', slug='region-2b', parent=regions[1]), - Region(name='Region 3A', slug='region-3a', parent=regions[2]), - Region(name='Region 3B', slug='region-3b', parent=regions[2]), + Region(name='Region 1A1', slug='region-1a1', parent=regions[0]), + Region(name='Region 1B1', slug='region-1b1', parent=regions[1]), + Region(name='Region 2A1', slug='region-2a1', parent=regions[2]), + Region(name='Region 2B1', slug='region-2b1', parent=regions[3]), + Region(name='Region 3A1', slug='region-3a1', parent=regions[4]), + Region(name='Region 3B1', slug='region-3b1', parent=regions[5]), ) for region in child_regions: region.save() @@ -100,12 +111,19 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - parent_regions = Region.objects.filter(parent__isnull=True)[:2] - params = {'parent_id': [parent_regions[0].pk, parent_regions[1].pk]} + regions = Region.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [regions[0].pk, regions[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'parent': [parent_regions[0].slug, parent_regions[1].slug]} + params = {'parent': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_ancestor(self): + regions = Region.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'ancestor': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = SiteGroup.objects.all() @@ -114,24 +132,35 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): - sitegroups = ( + parent_groups = ( SiteGroup(name='Site Group 1', slug='site-group-1', description='foobar1'), SiteGroup(name='Site Group 2', slug='site-group-2', description='foobar2'), SiteGroup(name='Site Group 3', slug='site-group-3', description='foobar3'), ) - for sitegroup in sitegroups: - sitegroup.save() + for site_group in parent_groups: + site_group.save() - child_sitegroups = ( - SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=sitegroups[0]), - SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=sitegroups[0]), - SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=sitegroups[1]), - SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=sitegroups[1]), - SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=sitegroups[2]), - SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=sitegroups[2]), + groups = ( + SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=parent_groups[0]), + SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=parent_groups[0]), + SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=parent_groups[1]), + SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=parent_groups[1]), + SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=parent_groups[2]), + SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2]), ) - for sitegroup in child_sitegroups: - sitegroup.save() + for site_group in groups: + site_group.save() + + child_groups = ( + SiteGroup(name='Site Group 1A1', slug='site-group-1a1', parent=groups[0]), + SiteGroup(name='Site Group 1B1', slug='site-group-1b1', parent=groups[1]), + SiteGroup(name='Site Group 2A1', slug='site-group-2a1', parent=groups[2]), + SiteGroup(name='Site Group 2B1', slug='site-group-2b1', parent=groups[3]), + SiteGroup(name='Site Group 3A1', slug='site-group-3a1', parent=groups[4]), + SiteGroup(name='Site Group 3B1', slug='site-group-3b1', parent=groups[5]), + ) + for site_group in child_groups: + site_group.save() def test_q(self): params = {'q': 'foobar1'} @@ -150,12 +179,19 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - parent_sitegroups = SiteGroup.objects.filter(parent__isnull=True)[:2] - params = {'parent_id': [parent_sitegroups[0].pk, parent_sitegroups[1].pk]} + site_groups = SiteGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [site_groups[0].pk, site_groups[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'parent': [parent_sitegroups[0].slug, parent_sitegroups[1].slug]} + params = {'parent': [site_groups[0].slug, site_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_ancestor(self): + site_groups = SiteGroup.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'ancestor': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Site.objects.all() @@ -314,21 +350,29 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): Site.objects.bulk_create(sites) parent_locations = ( - Location(name='Parent Location 1', slug='parent-location-1', site=sites[0]), - Location(name='Parent Location 2', slug='parent-location-2', site=sites[1]), - Location(name='Parent Location 3', slug='parent-location-3', site=sites[2]), + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), ) for location in parent_locations: location.save() locations = ( - Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='foobar1'), - Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='foobar2'), - Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='foobar3'), + Location(name='Location 1A', slug='location-1a', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='foobar1'), + Location(name='Location 2A', slug='location-2a', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='foobar2'), + Location(name='Location 3A', slug='location-3a', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='foobar3'), ) for location in locations: location.save() + child_locations = ( + Location(name='Location 1A1', slug='location-1a1', site=sites[0], parent=locations[0]), + Location(name='Location 2A1', slug='location-2a1', site=sites[1], parent=locations[1]), + Location(name='Location 3A1', slug='location-3a1', site=sites[2], parent=locations[2]), + ) + for location in child_locations: + location.save() + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -352,31 +396,38 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'region': [regions[0].slug, regions[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_site_group(self): site_groups = SiteGroup.objects.all()[:2] params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'site': [sites[0].slug, sites[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_parent(self): - parent_groups = Location.objects.filter(name__startswith='Parent')[:2] - params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} + locations = Location.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [locations[0].pk, locations[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} + params = {'parent': [locations[0].slug, locations[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_ancestor(self): + locations = Location.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'ancestor': [locations[0].slug, locations[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackRole.objects.all() diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 295d20774..7af3dc082 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -26,12 +26,25 @@ __all__ = ( class ContactGroupFilterSet(OrganizationalModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - label=_('Contact group (ID)'), + label=_('Parent contact group (ID)'), ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', queryset=ContactGroup.objects.all(), to_field_name='slug', + label=_('Parent contact group (slug)'), + ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Contact group (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', label=_('Contact group (slug)'), ) @@ -155,12 +168,25 @@ class ContactModelFilterSet(django_filters.FilterSet): class TenantGroupFilterSet(OrganizationalModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=TenantGroup.objects.all(), - label=_('Tenant group (ID)'), + label=_('Parent tenant group (ID)'), ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', queryset=TenantGroup.objects.all(), to_field_name='slug', + label=_('Parent tenant group (slug)'), + ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=TenantGroup.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Tenant group (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=TenantGroup.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', label=_('Tenant group (slug)'), ) diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index 3bcbddd4b..f6890a3d4 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -15,35 +15,43 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): def setUpTestData(cls): parent_tenant_groups = ( - TenantGroup(name='Parent Tenant Group 1', slug='parent-tenant-group-1'), - TenantGroup(name='Parent Tenant Group 2', slug='parent-tenant-group-2'), - TenantGroup(name='Parent Tenant Group 3', slug='parent-tenant-group-3'), + TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), + TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), ) - for tenantgroup in parent_tenant_groups: - tenantgroup.save() + for tenant_group in parent_tenant_groups: + tenant_group.save() tenant_groups = ( TenantGroup( - name='Tenant Group 1', - slug='tenant-group-1', + name='Tenant Group 1A', + slug='tenant-group-1a', parent=parent_tenant_groups[0], description='foobar1' ), TenantGroup( - name='Tenant Group 2', - slug='tenant-group-2', + name='Tenant Group 2A', + slug='tenant-group-2a', parent=parent_tenant_groups[1], description='foobar2' ), TenantGroup( - name='Tenant Group 3', - slug='tenant-group-3', + name='Tenant Group 3A', + slug='tenant-group-3a', parent=parent_tenant_groups[2], description='foobar3' ), ) - for tenantgroup in tenant_groups: - tenantgroup.save() + for tenant_group in tenant_groups: + tenant_group.save() + + child_tenant_groups = ( + TenantGroup(name='Tenant Group 1A1', slug='tenant-group-1a1', parent=tenant_groups[0]), + TenantGroup(name='Tenant Group 2A1', slug='tenant-group-2a1', parent=tenant_groups[1]), + TenantGroup(name='Tenant Group 3A1', slug='tenant-group-3a1', parent=tenant_groups[2]), + ) + for tenant_group in child_tenant_groups: + tenant_group.save() def test_q(self): params = {'q': 'foobar1'} @@ -62,12 +70,19 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - parent_groups = TenantGroup.objects.filter(name__startswith='Parent')[:2] - params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} + tenant_groups = TenantGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [tenant_groups[0].pk, tenant_groups[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} + params = {'parent': [tenant_groups[0].slug, tenant_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_ancestor(self): + tenant_groups = TenantGroup.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [tenant_groups[0].pk, tenant_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'ancestor': [tenant_groups[0].slug, tenant_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class TenantTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Tenant.objects.all() @@ -123,35 +138,43 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): def setUpTestData(cls): parent_contact_groups = ( - ContactGroup(name='Parent Contact Group 1', slug='parent-contact-group-1'), - ContactGroup(name='Parent Contact Group 2', slug='parent-contact-group-2'), - ContactGroup(name='Parent Contact Group 3', slug='parent-contact-group-3'), + ContactGroup(name='Contact Group 1', slug='contact-group-1'), + ContactGroup(name='Contact Group 2', slug='contact-group-2'), + ContactGroup(name='Contact Group 3', slug='contact-group-3'), ) - for contactgroup in parent_contact_groups: - contactgroup.save() + for contact_group in parent_contact_groups: + contact_group.save() contact_groups = ( ContactGroup( - name='Contact Group 1', - slug='contact-group-1', + name='Contact Group 1A', + slug='contact-group-1a', parent=parent_contact_groups[0], description='foobar1' ), ContactGroup( - name='Contact Group 2', - slug='contact-group-2', + name='Contact Group 2A', + slug='contact-group-2a', parent=parent_contact_groups[1], description='foobar2' ), ContactGroup( - name='Contact Group 3', - slug='contact-group-3', + name='Contact Group 3A', + slug='contact-group-3a', parent=parent_contact_groups[2], description='foobar3' ), ) - for contactgroup in contact_groups: - contactgroup.save() + for contact_group in contact_groups: + contact_group.save() + + child_contact_groups = ( + ContactGroup(name='Contact Group 1A1', slug='contact-group-1a1', parent=contact_groups[0]), + ContactGroup(name='Contact Group 2A1', slug='contact-group-2a1', parent=contact_groups[1]), + ContactGroup(name='Contact Group 3A1', slug='contact-group-3a1', parent=contact_groups[2]), + ) + for contact_group in child_contact_groups: + contact_group.save() def test_q(self): params = {'q': 'foobar1'} @@ -170,12 +193,19 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - parent_groups = ContactGroup.objects.filter(parent__isnull=True)[:2] - params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} + contact_groups = ContactGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [contact_groups[0].pk, contact_groups[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} + params = {'parent': [contact_groups[0].slug, contact_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_ancestor(self): + contact_groups = ContactGroup.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [contact_groups[0].pk, contact_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'ancestor': [contact_groups[0].slug, contact_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ContactRole.objects.all() diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 6ffb9cb91..50b1f78b1 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -25,6 +25,17 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): queryset=WirelessLANGroup.objects.all(), to_field_name='slug' ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all(), + field_name='parent', + lookup_expr='in' + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug' + ) class Meta: model = WirelessLANGroup diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index 4184d5392..78e50edb7 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/netbox/wireless/tests/test_filtersets.py @@ -17,21 +17,32 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): - groups = ( + parent_groups = ( WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1', description='A'), WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2', description='B'), WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C'), ) + for group in parent_groups: + group.save() + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1A', slug='wireless-lan-group-1a', parent=parent_groups[0], description='foobar1'), + WirelessLANGroup(name='Wireless LAN Group 1B', slug='wireless-lan-group-1b', parent=parent_groups[0], description='foobar2'), + WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=parent_groups[1]), + WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=parent_groups[1]), + WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=parent_groups[2]), + WirelessLANGroup(name='Wireless LAN Group 3B', slug='wireless-lan-group-3b', parent=parent_groups[2]), + ) for group in groups: group.save() child_groups = ( - WirelessLANGroup(name='Wireless LAN Group 1A', slug='wireless-lan-group-1a', parent=groups[0], description='foobar1'), - WirelessLANGroup(name='Wireless LAN Group 1B', slug='wireless-lan-group-1b', parent=groups[0], description='foobar2'), - WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=groups[1]), - WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=groups[1]), - WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=groups[2]), - WirelessLANGroup(name='Wireless LAN Group 3B', slug='wireless-lan-group-3b', parent=groups[2]), + WirelessLANGroup(name='Wireless LAN Group 1A1', slug='wireless-lan-group-1a1', parent=groups[0]), + WirelessLANGroup(name='Wireless LAN Group 1B1', slug='wireless-lan-group-1b1', parent=groups[1]), + WirelessLANGroup(name='Wireless LAN Group 2A1', slug='wireless-lan-group-2a1', parent=groups[2]), + WirelessLANGroup(name='Wireless LAN Group 2B1', slug='wireless-lan-group-2b1', parent=groups[3]), + WirelessLANGroup(name='Wireless LAN Group 3A1', slug='wireless-lan-group-3a1', parent=groups[4]), + WirelessLANGroup(name='Wireless LAN Group 3B1', slug='wireless-lan-group-3b1', parent=groups[5]), ) for group in child_groups: group.save() @@ -48,17 +59,24 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['wireless-lan-group-1', 'wireless-lan-group-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_parent(self): - parent_groups = WirelessLANGroup.objects.filter(parent__isnull=True)[:2] - params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_parent(self): + groups = WirelessLANGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'parent': [groups[0].slug, groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_ancestor(self): + groups = WirelessLANGroup.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'ancestor': [groups[0].slug, groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = WirelessLAN.objects.all() From 6af12b18148d21f522591966862fb1faab4f6ac2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 5 Mar 2024 17:14:42 -0500 Subject: [PATCH 18/56] Add tests for missing FilterSet filters --- netbox/circuits/tests/test_filtersets.py | 1 + netbox/core/tests/test_filtersets.py | 2 + netbox/dcim/tests/test_filtersets.py | 8 ++ netbox/extras/tests/test_filtersets.py | 30 +++--- netbox/ipam/tests/test_filtersets.py | 2 + netbox/users/tests/test_filtersets.py | 3 + netbox/utilities/testing/filtersets.py | 91 ++++++++++++++++++- .../virtualization/tests/test_filtersets.py | 1 + netbox/vpn/tests/test_filtersets.py | 4 +- 9 files changed, 126 insertions(+), 16 deletions(-) diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 6553179ec..bbd2438d7 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -330,6 +330,7 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = CircuitTermination.objects.all() filterset = CircuitTerminationFilterSet + ignore_fields = ('cable',) @classmethod def setUpTestData(cls): diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index 8ff104142..aefb9eed0 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -10,6 +10,7 @@ from ..models import * class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DataSource.objects.all() filterset = DataSourceFilterSet + ignore_fields = ('ignore_rules', 'parameters') @classmethod def setUpTestData(cls): @@ -70,6 +71,7 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DataFile.objects.all() filterset = DataFileFilterSet + ignore_fields = ('data',) @classmethod def setUpTestData(cls): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f1eeddbb5..8ec1da713 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -196,6 +196,7 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Site.objects.all() filterset = SiteFilterSet + ignore_fields = ('physical_address', 'shipping_address') @classmethod def setUpTestData(cls): @@ -467,6 +468,7 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests): class RackTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Rack.objects.all() filterset = RackFilterSet + ignore_fields = ('units',) @classmethod def setUpTestData(cls): @@ -726,6 +728,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackReservation.objects.all() filterset = RackReservationFilterSet + ignore_fields = ('units',) @classmethod def setUpTestData(cls): @@ -889,6 +892,7 @@ class ManufacturerTestCase(TestCase, ChangeLoggedFilterSetTests): class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceType.objects.all() filterset = DeviceTypeFilterSet + ignore_fields = ('front_image', 'rear_image') @classmethod def setUpTestData(cls): @@ -1880,6 +1884,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Device.objects.all() filterset = DeviceFilterSet + ignore_fields = ('primary_ip4', 'primary_ip6', 'oob_ip', 'local_context_data') @classmethod def setUpTestData(cls): @@ -2332,6 +2337,7 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Module.objects.all() filterset = ModuleFilterSet + ignore_fields = ('local_context_data',) @classmethod def setUpTestData(cls): @@ -3229,6 +3235,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = Interface.objects.all() filterset = InterfaceFilterSet + ignore_fields = ('untagged_vlan',) @classmethod def setUpTestData(cls): @@ -5332,6 +5339,7 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VirtualDeviceContext.objects.all() filterset = VirtualDeviceContextFilterSet + ignore_fields = ('primary_ip4', 'primary_ip6') @classmethod def setUpTestData(cls): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index bec62c688..ccccaa793 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -23,9 +23,10 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType User = get_user_model() -class CustomFieldTestCase(TestCase, BaseFilterSetTests): +class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = CustomField.objects.all() filterset = CustomFieldFilterSet + ignore_fields = ('default',) @classmethod def setUpTestData(cls): @@ -155,9 +156,10 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests): +class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = CustomFieldChoiceSet.objects.all() filterset = CustomFieldChoiceSetFilterSet + ignore_fields = ('extra_choices',) @classmethod def setUpTestData(cls): @@ -188,6 +190,7 @@ class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests): class WebhookTestCase(TestCase, BaseFilterSetTests): queryset = Webhook.objects.all() filterset = WebhookFilterSet + ignore_fields = ('additional_headers', 'body_template') @classmethod def setUpTestData(cls): @@ -252,6 +255,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): class EventRuleTestCase(TestCase, BaseFilterSetTests): queryset = EventRule.objects.all() filterset = EventRuleFilterSet + ignore_fields = ('action_data', 'conditions') @classmethod def setUpTestData(cls): @@ -405,7 +409,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class CustomLinkTestCase(TestCase, BaseFilterSetTests): +class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = CustomLink.objects.all() filterset = CustomLinkFilterSet @@ -474,9 +478,10 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class SavedFilterTestCase(TestCase, BaseFilterSetTests): +class SavedFilterTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = SavedFilter.objects.all() filterset = SavedFilterFilterSet + ignore_fields = ('parameters',) @classmethod def setUpTestData(cls): @@ -647,9 +652,10 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) -class ExportTemplateTestCase(TestCase, BaseFilterSetTests): +class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ExportTemplate.objects.all() filterset = ExportTemplateFilterSet + ignore_fields = ('template_code', 'data_path') @classmethod def setUpTestData(cls): @@ -683,9 +689,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ImageAttachmentTestCase(TestCase, BaseFilterSetTests): +class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ImageAttachment.objects.all() filterset = ImageAttachmentFilterSet + ignore_fields = ('image',) @classmethod def setUpTestData(cls): @@ -760,12 +767,6 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests): } self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_created(self): - pk_list = self.queryset.values_list('pk', flat=True)[:2] - self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) - params = {'created': '2021-01-01T00:00:00'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = JournalEntry.objects.all() @@ -873,6 +874,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests): class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConfigContext.objects.all() filterset = ConfigContextFilterSet + ignore_fields = ('data', 'data_path') @classmethod def setUpTestData(cls): @@ -1096,9 +1098,10 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ConfigTemplateTestCase(TestCase, BaseFilterSetTests): +class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConfigTemplate.objects.all() filterset = ConfigTemplateFilterSet + ignore_fields = ('template_code', 'environment_params', 'data_path') @classmethod def setUpTestData(cls): @@ -1193,6 +1196,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): class ObjectChangeTestCase(TestCase, BaseFilterSetTests): queryset = ObjectChange.objects.all() filterset = ObjectChangeFilterSet + ignore_fields = ('prechange_data', 'postchange_data') @classmethod def setUpTestData(cls): diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index bb4f50c21..3eecadb06 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1733,6 +1733,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ServiceTemplate.objects.all() filterset = ServiceTemplateFilterSet + ignore_fields = ('ports',) @classmethod def setUpTestData(cls): @@ -1797,6 +1798,7 @@ class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Service.objects.all() filterset = ServiceFilterSet + ignore_fields = ('ports',) @classmethod def setUpTestData(cls): diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index 5930285a9..5349244f7 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -15,6 +15,7 @@ User = get_user_model() class UserTestCase(TestCase, BaseFilterSetTests): queryset = User.objects.all() filterset = filtersets.UserFilterSet + ignore_fields = ('password',) @classmethod def setUpTestData(cls): @@ -132,6 +133,7 @@ class GroupTestCase(TestCase, BaseFilterSetTests): class ObjectPermissionTestCase(TestCase, BaseFilterSetTests): queryset = ObjectPermission.objects.all() filterset = filtersets.ObjectPermissionFilterSet + ignore_fields = ('actions', 'constraints') @classmethod def setUpTestData(cls): @@ -226,6 +228,7 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests): class TokenTestCase(TestCase, BaseFilterSetTests): queryset = Token.objects.all() filterset = filtersets.TokenFilterSet + ignore_fields = ('allowed_ips',) @classmethod def setUpTestData(cls): diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index 00f3d9745..7b19f2408 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -1,15 +1,47 @@ -from datetime import date, datetime, timezone +from datetime import datetime, timezone +from itertools import chain +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.db.models import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel, OneToOneRel +from django.utils.module_loading import import_string +from taggit.managers import TaggableManager + +from core.models import ObjectType __all__ = ( 'BaseFilterSetTests', 'ChangeLoggedFilterSetTests', ) +IGNORE_MODELS = ( + ('core', 'AutoSyncRecord'), + ('core', 'ManagedFile'), + ('core', 'ObjectType'), + ('dcim', 'CablePath'), + ('extras', 'Branch'), + ('extras', 'CachedValue'), + ('extras', 'Dashboard'), + ('extras', 'ScriptModule'), + ('extras', 'StagedChange'), + ('extras', 'TaggedItem'), + ('users', 'UserConfig'), +) + +IGNORE_FIELDS = ( + 'comments', + 'custom_field_data', + 'level', # MPTT + 'lft', # MPTT + 'rght', # MPTT + 'tree_id', # MPTT +) + class BaseFilterSetTests: queryset = None filterset = None + ignore_fields = tuple() def test_id(self): """ @@ -19,6 +51,63 @@ class BaseFilterSetTests: self.assertGreater(self.queryset.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_missing_filters(self): + """ + Check for any model fields which do not have the required filter(s) defined. + """ + app_label = self.__class__.__module__.split('.')[0] + model = self.queryset.model + model_name = model.__name__ + + # Skip ignored models + if (app_label, model_name) in IGNORE_MODELS: + return + + # Import the FilterSet class & sanity check it + filterset = import_string(f'{app_label}.filtersets.{model_name}FilterSet') + self.assertEqual(model, filterset.Meta.model, "FilterSet model does not match!") + + filterset_fields = sorted(filterset.get_filters()) + + # Check for missing filters + for model_field in model._meta.get_fields(): + + # Skip private fields + if model_field.name.startswith('_'): + continue + + # Skip ignored fields + if model_field.name in chain(self.ignore_fields, IGNORE_FIELDS): + continue + + # One-to-one & one-to-many relationships + if issubclass(model_field.__class__, ForeignKey) or type(model_field) is OneToOneRel: + if model_field.related_model is ContentType: + # Relationships to ContentType (used as part of a GFK) do not need a filter + continue + elif model_field.related_model is ObjectType: + # Filters to ObjectType use 'app.model' rather than numeric PK, so we omit the _id suffix + filter_name = model_field.name + else: + filter_name = f'{model_field.name}_id' + self.assertIn(filter_name, filterset_fields, f'No filter found for {model_field.name}!') + + # TODO: Many-to-one & many-to-many relationships + elif type(model_field) in (ManyToOneRel, ManyToManyField, ManyToManyRel): + continue + + # TODO: Generic relationships + elif type(model_field) in (GenericForeignKey, GenericRelation): + continue + + # Tags + elif type(model_field) is TaggableManager: + self.assertIn('tag', filterset_fields, f'No filter found for {model_field.name}!') + + # All other fields + else: + self.assertIn(model_field.name, filterset_fields, f'No filter found for {model_field.name}!') + class ChangeLoggedFilterSetTests(BaseFilterSetTests): diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 5c020e1b2..3d9e17a23 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -522,6 +522,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VMInterface.objects.all() filterset = VMInterfaceFilterSet + ignore_fields = ('untagged_vlan',) @classmethod def setUpTestData(cls): diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index d4e80750d..4d099a065 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -848,8 +848,8 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'l2vpn': [l2vpns[0].slug, l2vpns[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) - def test_content_type(self): - params = {'assigned_object_type_id': ContentType.objects.get(model='vlan').pk} + def test_termination_type(self): + params = {'assigned_object_type': 'ipam.vlan'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_interface(self): From 16b422cbacb907649e19e21ff031294233e31552 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 5 Mar 2024 17:15:21 -0500 Subject: [PATCH 19/56] Add missing filters --- netbox/circuits/filtersets.py | 21 +++- netbox/core/filtersets.py | 8 +- netbox/dcim/filtersets.py | 147 ++++++++++++++++++++-------- netbox/extras/filtersets.py | 69 +++++++------ netbox/ipam/filtersets.py | 13 ++- netbox/users/filtersets.py | 11 ++- netbox/virtualization/filtersets.py | 4 +- netbox/vpn/filtersets.py | 4 +- 8 files changed, 182 insertions(+), 95 deletions(-) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 97be1cf57..13fa0a15c 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -158,6 +158,12 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte queryset=ProviderAccount.objects.all(), label=_('Provider account (ID)'), ) + provider_account = django_filters.ModelMultipleChoiceFilter( + field_name='provider_account__account', + queryset=Provider.objects.all(), + to_field_name='account', + label=_('Provider account (account)'), + ) provider_network_id = django_filters.ModelMultipleChoiceFilter( field_name='terminations__provider_network', queryset=ProviderNetwork.objects.all(), @@ -214,10 +220,18 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte to_field_name='slug', label=_('Site (slug)'), ) + termination_a_id = django_filters.ModelMultipleChoiceFilter( + queryset=CircuitTermination.objects.all(), + label=_('Termination A (ID)'), + ) + termination_z_id = django_filters.ModelMultipleChoiceFilter( + queryset=CircuitTermination.objects.all(), + label=_('Termination A (ID)'), + ) class Meta: model = Circuit - fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate'] + fields = ('id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate') def search(self, queryset, name, value): if not value.strip(): @@ -258,7 +272,10 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): class Meta: model = CircuitTermination - fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'cable_end'] + fields = ( + 'id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'mark_connected', + 'pp_info', 'cable_end', + ) def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index 902e240ee..c5d332b68 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -28,7 +28,7 @@ class DataSourceFilterSet(NetBoxModelFilterSet): class Meta: model = DataSource - fields = ('id', 'name', 'enabled', 'description') + fields = ('id', 'name', 'enabled', 'description', 'source_url', 'last_synced') def search(self, queryset, name, value): if not value.strip(): @@ -115,7 +115,7 @@ class JobFilterSet(BaseFilterSet): class Meta: model = Job - fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user') + fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user', 'job_id') def search(self, queryset, name, value): if not value.strip(): @@ -134,9 +134,7 @@ class ConfigRevisionFilterSet(BaseFilterSet): class Meta: model = ConfigRevision - fields = [ - 'id', - ] + fields = ('id', 'created', 'comment') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 082659b8f..c22e84d51 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -18,7 +18,7 @@ from tenancy.models import * from utilities.choices import ColorChoices from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, - TreeNodeMultipleChoiceFilter, + NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster from vpn.models import L2VPN @@ -178,12 +178,11 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe queryset=ASN.objects.all(), label=_('AS (ID)'), ) + time_zone = MultiValueCharFilter() class Meta: model = Site - fields = ( - 'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'description' - ) + fields = ('id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -447,10 +446,14 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='username', label=_('User (name)'), ) + unit = NumericArrayFilter( + field_name='units', + lookup_expr='contains' + ) class Meta: model = RackReservation - fields = ['id', 'created', 'description'] + fields = ('id', 'created', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -538,10 +541,22 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): class Meta: model = DeviceType - fields = [ + fields = ( 'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'description', - ] + + # Counters + 'console_port_template_count', + 'console_server_port_template_count', + 'power_port_template_count', + 'power_outlet_template_count', + 'interface_template_count', + 'front_port_template_count', + 'rear_port_template_count', + 'device_bay_template_count', + 'module_bay_template_count', + 'inventory_item_template_count', + ) def search(self, queryset, name, value): if not value.strip(): @@ -675,12 +690,15 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet): method='search', label=_('Search'), ) - devicetype_id = django_filters.ModelMultipleChoiceFilter( + device_type_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), field_name='device_type_id', label=_('Device type (ID)'), ) + # TODO: Remove in v4.1 + devicetype_id = device_type_id + def search(self, queryset, name, value): if not value.strip(): return queryset @@ -691,32 +709,35 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet): class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet): - moduletype_id = django_filters.ModelMultipleChoiceFilter( + module_type_id = django_filters.ModelMultipleChoiceFilter( queryset=ModuleType.objects.all(), field_name='module_type_id', label=_('Module type (ID)'), ) + # TODO: Remove in v4.1 + moduletype_id = module_type_id + class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ConsolePortTemplate - fields = ['id', 'name', 'type', 'description'] + fields = ('id', 'name', 'label', 'type', 'description') class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'name', 'type', 'description'] + fields = ('id', 'name', 'label', 'type', 'description') class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = PowerPortTemplate - fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description'] + fields = ('id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -724,10 +745,14 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType choices=PowerOutletFeedLegChoices, null_value=None ) + power_port_id = django_filters.ModelMultipleChoiceFilter( + queryset=PowerPortTemplate.objects.all(), + label=_('Power port (ID)'), + ) class Meta: model = PowerOutletTemplate - fields = ['id', 'name', 'type', 'feed_leg', 'description'] + fields = ('id', 'name', 'label', 'type', 'feed_leg', 'description') class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -751,7 +776,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo class Meta: model = InterfaceTemplate - fields = ['id', 'name', 'type', 'enabled', 'mgmt_only', 'description'] + fields = ('id', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description') class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -762,7 +787,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo class Meta: model = FrontPortTemplate - fields = ['id', 'name', 'type', 'color', 'description'] + fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_id', 'rear_port_position', 'description') class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -773,21 +798,21 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom class Meta: model = RearPortTemplate - fields = ['id', 'name', 'type', 'color', 'positions', 'description'] + fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description') class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: model = ModuleBayTemplate - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'label', 'position', 'description') class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: model = DeviceBayTemplate - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'label', 'description') class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): @@ -1068,10 +1093,22 @@ class DeviceFilterSet( class Meta: model = Device - fields = [ + fields = ( 'id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority', 'description', - ] + + # Counters + 'console_port_count', + 'console_server_port_count', + 'power_port_count', + 'power_outlet_count', + 'interface_count', + 'front_port_count', + 'rear_port_count', + 'device_bay_count', + 'module_bay_count', + 'inventory_item_count', + ) def search(self, queryset, name, value): if not value.strip(): @@ -1151,7 +1188,7 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, Prim class Meta: model = VirtualDeviceContext - fields = ['id', 'device', 'name', 'description'] + fields = ('id', 'device', 'name', 'identifier', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1217,7 +1254,7 @@ class ModuleFilterSet(NetBoxModelFilterSet): class Meta: model = Module - fields = ['id', 'status', 'asset_tag', 'description'] + fields = ('id', 'status', 'asset_tag', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1361,6 +1398,10 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet): class CabledObjectFilterSet(django_filters.FilterSet): + cable_id = django_filters.ModelMultipleChoiceFilter( + queryset=Cable.objects.all(), + label=_('Cable (ID)'), + ) cabled = django_filters.BooleanFilter( field_name='cable', lookup_expr='isnull', @@ -1402,7 +1443,7 @@ class ConsolePortFilterSet( class Meta: model = ConsolePort - fields = ['id', 'name', 'label', 'description', 'cable_end'] + fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end') class ConsoleServerPortFilterSet( @@ -1418,7 +1459,7 @@ class ConsoleServerPortFilterSet( class Meta: model = ConsoleServerPort - fields = ['id', 'name', 'label', 'description', 'cable_end'] + fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end') class PowerPortFilterSet( @@ -1434,7 +1475,9 @@ class PowerPortFilterSet( class Meta: model = PowerPort - fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end', + ) class PowerOutletFilterSet( @@ -1451,10 +1494,16 @@ class PowerOutletFilterSet( choices=PowerOutletFeedLegChoices, null_value=None ) + power_port_id = django_filters.ModelMultipleChoiceFilter( + queryset=PowerPort.objects.all(), + label=_('Power port (ID)'), + ) class Meta: model = PowerOutlet - fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'feed_leg', 'description', 'mark_connected', 'cable_end', + ) class CommonInterfaceFilterSet(django_filters.FilterSet): @@ -1586,10 +1635,11 @@ class InterfaceFilterSet( class Meta: model = Interface - fields = [ + fields = ( 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role', - 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end', - ] + 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', + 'cable_id', 'cable_end', 'wireless_link_id', + ) def filter_virtual_chassis_member(self, queryset, name, value): try: @@ -1621,7 +1671,10 @@ class FrontPortFilterSet( class Meta: model = FrontPort - fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'type', 'color', 'rear_port_id', 'rear_port_position', 'description', + 'mark_connected', 'cable_end', + ) class RearPortFilterSet( @@ -1636,21 +1689,33 @@ class RearPortFilterSet( class Meta: model = RearPort - fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end', + ) class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): class Meta: model = ModuleBay - fields = ['id', 'name', 'label', 'description'] + fields = ('id', 'name', 'label', 'position', 'description') class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): + installed_device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label=_('Installed device (ID)'), + ) + installed_device = django_filters.ModelMultipleChoiceFilter( + field_name='installed_device__name', + queryset=Device.objects.all(), + to_field_name='name', + label=_('Installed device (name)'), + ) class Meta: model = DeviceBay - fields = ['id', 'name', 'label', 'description'] + fields = ('id', 'name', 'label', 'description') class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): @@ -1686,7 +1751,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): class Meta: model = InventoryItem - fields = ['id', 'name', 'label', 'part_id', 'asset_tag', 'discovered'] + fields = ('id', 'name', 'label', 'part_id', 'asset_tag', 'description', 'discovered') def search(self, queryset, name, value): if not value.strip(): @@ -1770,7 +1835,7 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet): class Meta: model = VirtualChassis - fields = ['id', 'domain', 'name', 'description'] + fields = ('id', 'domain', 'name', 'description', 'member_count') def search(self, queryset, name, value): if not value.strip(): @@ -1953,12 +2018,12 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): return self.filter_by_termination_object(queryset, CircuitTermination, value) -class CableTerminationFilterSet(BaseFilterSet): +class CableTerminationFilterSet(ChangeLoggedModelFilterSet): termination_type = ContentTypeFilter() class Meta: model = CableTermination - fields = ['id', 'cable', 'cable_end', 'termination_type', 'termination_id'] + fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id') class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): @@ -2073,10 +2138,10 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi class Meta: model = PowerFeed - fields = [ - 'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end', - 'description', - ] + fields = ( + 'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', + 'available_power', 'mark_connected', 'cable_end', 'description', + ) def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index d88b8c9b3..1d833bd28 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -40,12 +40,14 @@ class ScriptFilterSet(BaseFilterSet): method='search', label=_('Search'), ) + module_id = django_filters.ModelMultipleChoiceFilter( + queryset=ScriptModule.objects.all(), + label=_('Script module (ID)'), + ) class Meta: model = Script - fields = [ - 'id', 'name', - ] + fields = ('id', 'name', 'is_executable') def search(self, queryset, name, value): if not value.strip(): @@ -69,10 +71,10 @@ class WebhookFilterSet(NetBoxModelFilterSet): class Meta: model = Webhook - fields = [ + fields = ( 'id', 'name', 'payload_url', 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path', 'description', - ] + ) def search(self, queryset, name, value): if not value.strip(): @@ -103,10 +105,10 @@ class EventRuleFilterSet(NetBoxModelFilterSet): class Meta: model = EventRule - fields = [ + fields = ( 'id', 'name', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'enabled', 'action_type', 'description', - ] + ) def search(self, queryset, name, value): if not value.strip(): @@ -118,7 +120,7 @@ class EventRuleFilterSet(NetBoxModelFilterSet): ) -class CustomFieldFilterSet(BaseFilterSet): +class CustomFieldFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), @@ -147,10 +149,11 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField - fields = [ - 'id', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', - 'weight', 'is_cloneable', 'description', - ] + fields = ( + 'id', 'name', 'label', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible', + 'ui_editable', 'weight', 'is_cloneable', 'description', 'validation_minimum', 'validation_maximum', + 'validation_regex', + ) def search(self, queryset, name, value): if not value.strip(): @@ -163,7 +166,7 @@ class CustomFieldFilterSet(BaseFilterSet): ) -class CustomFieldChoiceSetFilterSet(BaseFilterSet): +class CustomFieldChoiceSetFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), @@ -174,9 +177,9 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet): class Meta: model = CustomFieldChoiceSet - fields = [ + fields = ( 'id', 'name', 'description', 'base_choices', 'order_alphabetically', - ] + ) def search(self, queryset, name, value): if not value.strip(): @@ -191,7 +194,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet): return queryset.filter(extra_choices__overlap=value) -class CustomLinkFilterSet(BaseFilterSet): +class CustomLinkFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), @@ -205,9 +208,9 @@ class CustomLinkFilterSet(BaseFilterSet): class Meta: model = CustomLink - fields = [ - 'id', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', - ] + fields = ( + 'id', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', 'button_class', + ) def search(self, queryset, name, value): if not value.strip(): @@ -220,7 +223,7 @@ class CustomLinkFilterSet(BaseFilterSet): ) -class ExportTemplateFilterSet(BaseFilterSet): +class ExportTemplateFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), @@ -242,7 +245,10 @@ class ExportTemplateFilterSet(BaseFilterSet): class Meta: model = ExportTemplate - fields = ['id', 'name', 'description', 'data_synced'] + fields = ( + 'id', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment', 'auto_sync_enabled', + 'data_synced', + ) def search(self, queryset, name, value): if not value.strip(): @@ -253,7 +259,7 @@ class ExportTemplateFilterSet(BaseFilterSet): ) -class SavedFilterFilterSet(BaseFilterSet): +class SavedFilterFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), @@ -280,7 +286,7 @@ class SavedFilterFilterSet(BaseFilterSet): class Meta: model = SavedFilter - fields = ['id', 'name', 'slug', 'description', 'enabled', 'shared', 'weight'] + fields = ('id', 'name', 'slug', 'description', 'enabled', 'shared', 'weight') def search(self, queryset, name, value): if not value.strip(): @@ -324,17 +330,16 @@ class BookmarkFilterSet(BaseFilterSet): fields = ['id', 'object_id'] -class ImageAttachmentFilterSet(BaseFilterSet): +class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), ) - created = django_filters.DateTimeFilter() object_type = ContentTypeFilter() class Meta: model = ImageAttachment - fields = ['id', 'object_type_id', 'object_id', 'name'] + fields = ('id', 'object_type_id', 'object_id', 'name', 'image_width', 'image_height') def search(self, queryset, name, value): if not value.strip(): @@ -579,7 +584,7 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): class Meta: model = ConfigContext - fields = ['id', 'name', 'is_active', 'data_synced', 'description'] + fields = ('id', 'name', 'is_active', 'description', 'weight', 'auto_sync_enabled', 'data_synced') def search(self, queryset, name, value): if not value.strip(): @@ -591,7 +596,7 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): ) -class ConfigTemplateFilterSet(BaseFilterSet): +class ConfigTemplateFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), @@ -608,7 +613,7 @@ class ConfigTemplateFilterSet(BaseFilterSet): class Meta: model = ConfigTemplate - fields = ['id', 'name', 'description', 'data_synced'] + fields = ('id', 'name', 'description', 'auto_sync_enabled', 'data_synced') def search(self, queryset, name, value): if not value.strip(): @@ -656,10 +661,10 @@ class ObjectChangeFilterSet(BaseFilterSet): class Meta: model = ObjectChange - fields = [ + fields = ( 'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id', - 'object_repr', - ] + 'related_object_type', 'related_object_id', 'object_repr', + ) def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 404baf71b..177e0cef2 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -183,7 +183,7 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): class Meta: model = ASNRange - fields = ['id', 'name', 'start', 'end', 'description'] + fields = ('id', 'name', 'slug', 'start', 'end', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -234,7 +234,7 @@ class RoleFilterSet(OrganizationalModelFilterSet): class Meta: model = Role - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description', 'weight') class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): @@ -475,7 +475,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet): class Meta: model = IPRange - fields = ['id', 'mark_utilized', 'description'] + fields = ('id', 'mark_utilized', 'size', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -631,7 +631,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = IPAddress - fields = ['id', 'dns_name', 'description'] + fields = ('id', 'dns_name', 'description', 'assigned_object_type', 'assigned_object_id', 'nat_inside_id') def search(self, queryset, name, value): if not value.strip(): @@ -1008,7 +1008,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet): class Meta: model = ServiceTemplate - fields = ['id', 'name', 'protocol', 'description'] + fields = ('id', 'name', 'protocol', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1052,7 +1052,6 @@ class ServiceFilterSet(NetBoxModelFilterSet): to_field_name='address', label=_('IP address'), ) - port = NumericArrayFilter( field_name='ports', lookup_expr='contains' @@ -1060,7 +1059,7 @@ class ServiceFilterSet(NetBoxModelFilterSet): class Meta: model = Service - fields = ['id', 'name', 'protocol', 'description'] + fields = ('id', 'name', 'protocol', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 5dbca7738..2f0bb068a 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -22,7 +22,7 @@ class GroupFilterSet(BaseFilterSet): class Meta: model = Group - fields = ['id', 'name'] + fields = ('id', 'name', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -49,7 +49,10 @@ class UserFilterSet(BaseFilterSet): class Meta: model = get_user_model() - fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'is_superuser'] + fields = ( + 'id', 'username', 'first_name', 'last_name', 'email', 'date_joined', 'last_login', 'is_staff', 'is_active', + 'is_superuser', + ) def search(self, queryset, name, value): if not value.strip(): @@ -99,7 +102,7 @@ class TokenFilterSet(BaseFilterSet): class Meta: model = Token - fields = ['id', 'key', 'write_enabled', 'description'] + fields = ('id', 'key', 'write_enabled', 'description', 'last_used') def search(self, queryset, name, value): if not value.strip(): @@ -152,7 +155,7 @@ class ObjectPermissionFilterSet(BaseFilterSet): class Meta: model = ObjectPermission - fields = ['id', 'name', 'enabled', 'object_types', 'description'] + fields = ('id', 'name', 'enabled', 'object_types', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 78f6566d3..c36075c44 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -240,7 +240,7 @@ class VirtualMachineFilterSet( class Meta: model = VirtualMachine - fields = ['id', 'cluster', 'vcpus', 'memory', 'disk', 'description'] + fields = ('id', 'cluster', 'vcpus', 'memory', 'disk', 'description', 'interface_count', 'virtual_disk_count') def search(self, queryset, name, value): if not value.strip(): @@ -299,7 +299,7 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet): class Meta: model = VMInterface - fields = ['id', 'name', 'enabled', 'mtu', 'description'] + fields = ('id', 'name', 'enabled', 'mtu', 'mode', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 0647838a8..9a1c328a9 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -120,7 +120,7 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet): class Meta: model = TunnelTermination - fields = ['id'] + fields = ('id', 'termination_id') class IKEProposalFilterSet(NetBoxModelFilterSet): @@ -402,7 +402,7 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): class Meta: model = L2VPNTermination - fields = ('id', 'assigned_object_type_id') + fields = ('id', 'assigned_object_id') def search(self, queryset, name, value): if not value.strip(): From 5cb7af88d4e9150a0d3516c624f27d63b1553f22 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 6 Mar 2024 08:33:58 -0500 Subject: [PATCH 20/56] Fix remaining tests --- netbox/dcim/filtersets.py | 10 ++++++++++ netbox/dcim/tests/test_filtersets.py | 2 +- netbox/ipam/filtersets.py | 11 ++++++++--- netbox/ipam/tests/test_filtersets.py | 1 + netbox/users/tests/test_filtersets.py | 2 +- netbox/utilities/testing/filtersets.py | 6 +++++- 6 files changed, 26 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index c22e84d51..f0fc4ac60 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1004,6 +1004,11 @@ class DeviceFilterSet( queryset=Rack.objects.all(), label=_('Rack (ID)'), ) + parent_bay_id = django_filters.ModelMultipleChoiceFilter( + field_name='parent_bay', + queryset=DeviceBay.objects.all(), + label=_('Parent bay (ID)'), + ) cluster_id = django_filters.ModelMultipleChoiceFilter( queryset=Cluster.objects.all(), label=_('VM cluster (ID)'), @@ -1695,6 +1700,11 @@ class RearPortFilterSet( class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): + installed_module_id = django_filters.ModelMultipleChoiceFilter( + field_name='installed_module', + queryset=ModuleBay.objects.all(), + label=_('Installed module (ID)'), + ) class Meta: model = ModuleBay diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 8ec1da713..e9bee9322 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1884,7 +1884,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Device.objects.all() filterset = DeviceFilterSet - ignore_fields = ('primary_ip4', 'primary_ip6', 'oob_ip', 'local_context_data') + ignore_fields = ('local_context_data', 'oob_ip', 'primary_ip4', 'primary_ip6', 'vc_master_for') @classmethod def setUpTestData(cls): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 177e0cef2..c95863be8 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -849,7 +849,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): region = django_filters.NumberFilter( method='filter_scope' ) - sitegroup = django_filters.NumberFilter( + site_group = django_filters.NumberFilter( method='filter_scope' ) site = django_filters.NumberFilter( @@ -861,13 +861,17 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): rack = django_filters.NumberFilter( method='filter_scope' ) - clustergroup = django_filters.NumberFilter( + cluster_group = django_filters.NumberFilter( method='filter_scope' ) cluster = django_filters.NumberFilter( method='filter_scope' ) + # TODO: Remove in v4.1 + sitegroup = site_group + clustergroup = cluster_group + class Meta: model = VLANGroup fields = ['id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id'] @@ -882,8 +886,9 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): return queryset.filter(qs_filter) def filter_scope(self, queryset, name, value): + model_name = name.replace('_', '') return queryset.filter( - scope_type=ContentType.objects.get(model=name), + scope_type=ContentType.objects.get(model=model_name), scope_id=value ) diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 3eecadb06..f69e2d2f4 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -922,6 +922,7 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests): class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = IPAddress.objects.all() filterset = IPAddressFilterSet + ignore_fields = ('fhrpgroup',) @classmethod def setUpTestData(cls): diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index 5349244f7..7f41746fd 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -15,7 +15,7 @@ User = get_user_model() class UserTestCase(TestCase, BaseFilterSetTests): queryset = User.objects.all() filterset = filtersets.UserFilterSet - ignore_fields = ('password',) + ignore_fields = ('config', 'dashboard', 'password') @classmethod def setUpTestData(cls): diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index 7b19f2408..a5a5bf999 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -106,7 +106,11 @@ class BaseFilterSetTests: # All other fields else: - self.assertIn(model_field.name, filterset_fields, f'No filter found for {model_field.name}!') + self.assertIn( + model_field.name, + filterset_fields, + f'No filter found for {model_field.name} ({type(model_field)})!' + ) class ChangeLoggedFilterSetTests(BaseFilterSetTests): From 0a0dae3d355cf46bd36621cd4221ec5538a7d150 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 6 Mar 2024 15:09:48 -0500 Subject: [PATCH 21/56] Inspect many-to-many fields --- netbox/utilities/testing/filtersets.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index a5a5bf999..4d8025672 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -80,6 +80,10 @@ class BaseFilterSetTests: if model_field.name in chain(self.ignore_fields, IGNORE_FIELDS): continue + # Skip reverse ForeignKey relationships + if type(model_field) is ManyToOneRel: + continue + # One-to-one & one-to-many relationships if issubclass(model_field.__class__, ForeignKey) or type(model_field) is OneToOneRel: if model_field.related_model is ContentType: @@ -90,10 +94,14 @@ class BaseFilterSetTests: filter_name = model_field.name else: filter_name = f'{model_field.name}_id' - self.assertIn(filter_name, filterset_fields, f'No filter found for {model_field.name}!') + self.assertIn(filter_name, filterset_fields, f'No filter found for {filter_name}!') - # TODO: Many-to-one & many-to-many relationships - elif type(model_field) in (ManyToOneRel, ManyToManyField, ManyToManyRel): + # TODO: Many-to-many relationships + elif type(model_field) is ManyToManyField: + related_model = model_field.related_model._meta.model_name + filter_name = f'{related_model}_id' + self.assertIn(filter_name, filterset_fields, f'M2M: No filter found for {filter_name}!') + elif type(model_field) is ManyToManyRel: continue # TODO: Generic relationships From 6085e0bb0bdefc3c7bc7e586607bee4699f2ea03 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 7 Mar 2024 14:59:41 -0500 Subject: [PATCH 22/56] Test for missing ManyToManyField filters --- netbox/dcim/filtersets.py | 12 +++++++++- netbox/dcim/tests/test_filtersets.py | 2 +- netbox/extras/filtersets.py | 8 +++++-- netbox/extras/tests/test_filtersets.py | 7 +++--- netbox/ipam/filtersets.py | 8 +++++-- netbox/ipam/tests/test_filtersets.py | 15 +++++++++--- netbox/users/filtersets.py | 7 ++++++ netbox/users/tests/test_filtersets.py | 3 ++- netbox/utilities/testing/filtersets.py | 32 ++++++++++++++++++++------ netbox/vpn/filtersets.py | 16 +++++++++---- netbox/vpn/tests/test_filtersets.py | 22 ++++++++++++------ 11 files changed, 101 insertions(+), 31 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f0fc4ac60..0e22f613f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -23,6 +23,7 @@ from utilities.filters import ( from virtualization.models import Cluster from vpn.models import L2VPN from wireless.choices import WirelessRoleChoices, WirelessChannelChoices +from wireless.models import WirelessLAN, WirelessLink from .choices import * from .constants import * from .models import * @@ -1637,13 +1638,22 @@ class InterfaceFilterSet( to_field_name='name', label='Virtual Device Context', ) + wireless_lan_id = django_filters.ModelMultipleChoiceFilter( + field_name='wireless_lans', + queryset=WirelessLAN.objects.all(), + label='Wireless LAN', + ) + wireless_link_id = django_filters.ModelMultipleChoiceFilter( + queryset=WirelessLink.objects.all(), + label='Wireless link', + ) class Meta: model = Interface fields = ( 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', - 'cable_id', 'cable_end', 'wireless_link_id', + 'cable_id', 'cable_end', ) def filter_virtual_chassis_member(self, queryset, name, value): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index e9bee9322..d844cfd6b 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -3235,7 +3235,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = Interface.objects.all() filterset = InterfaceFilterSet - ignore_fields = ('untagged_vlan',) + ignore_fields = ('untagged_vlan', 'vdcs') @classmethod def setUpTestData(cls): diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 1d833bd28..0be2fde28 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -491,12 +491,12 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): queryset=DeviceType.objects.all(), label=_('Device type'), ) - role_id = django_filters.ModelMultipleChoiceFilter( + device_role_id = django_filters.ModelMultipleChoiceFilter( field_name='roles', queryset=DeviceRole.objects.all(), label=_('Role'), ) - role = django_filters.ModelMultipleChoiceFilter( + device_role = django_filters.ModelMultipleChoiceFilter( field_name='roles__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', @@ -582,6 +582,10 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): label=_('Data file (ID)'), ) + # TODO: Remove in v4.1 + role = device_role + role_id = device_role_id + class Meta: model = ConfigContext fields = ('id', 'name', 'is_active', 'description', 'weight', 'auto_sync_enabled', 'data_synced') diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index ccccaa793..d7d9e6ca2 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1043,11 +1043,11 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_role(self): + def test_device_role(self): device_roles = DeviceRole.objects.all()[:2] - params = {'role_id': [device_roles[0].pk, device_roles[1].pk]} + params = {'device_role_id': [device_roles[0].pk, device_roles[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'role': [device_roles[0].slug, device_roles[1].slug]} + params = {'device_role': [device_roles[0].slug, device_roles[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_platform(self): @@ -1128,6 +1128,7 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): class TagTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Tag.objects.all() filterset = TagFilterSet + ignore_fields = ('object_types',) @classmethod def setUpTestData(cls): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index c95863be8..84d507e44 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -1046,12 +1046,12 @@ class ServiceFilterSet(NetBoxModelFilterSet): to_field_name='name', label=_('Virtual machine (name)'), ) - ipaddress_id = django_filters.ModelMultipleChoiceFilter( + ip_address_id = django_filters.ModelMultipleChoiceFilter( field_name='ipaddresses', queryset=IPAddress.objects.all(), label=_('IP address (ID)'), ) - ipaddress = django_filters.ModelMultipleChoiceFilter( + ip_address = django_filters.ModelMultipleChoiceFilter( field_name='ipaddresses__address', queryset=IPAddress.objects.all(), to_field_name='address', @@ -1062,6 +1062,10 @@ class ServiceFilterSet(NetBoxModelFilterSet): lookup_expr='contains' ) + # TODO: Remove in v4.1 + ipaddress = ip_address + ipaddress_id = ip_address_id + class Meta: model = Service fields = ('id', 'name', 'protocol', 'description') diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index f69e2d2f4..0b3c92b3e 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -181,6 +181,15 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VRF.objects.all() filterset = VRFFilterSet + @staticmethod + def get_m2m_filter_name(field): + # Override filter names for import & export RouteTargets + if field.name == 'import_targets': + return 'import_target' + if field.name == 'export_targets': + return 'export_target' + return super().get_m2m_filter_name(field) + @classmethod def setUpTestData(cls): @@ -1886,9 +1895,9 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'virtual_machine': [vms[0].name, vms[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_ipaddress(self): + def test_ip_address(self): ips = IPAddress.objects.all()[:2] - params = {'ipaddress_id': [ips[0].pk, ips[1].pk]} + params = {'ip_address_id': [ips[0].pk, ips[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'ipaddress': [str(ips[0].address), str(ips[1].address)]} + params = {'ip_address': [str(ips[0].address), str(ips[1].address)]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 2f0bb068a..5ad8b6476 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext as _ from netbox.filtersets import BaseFilterSet from users.models import Group, ObjectPermission, Token +from utilities.filters import ContentTypeFilter, MultiValueNumberFilter __all__ = ( 'GroupFilterSet', @@ -118,6 +119,12 @@ class ObjectPermissionFilterSet(BaseFilterSet): method='search', label=_('Search'), ) + object_type_id = MultiValueNumberFilter( + field_name='object_types__id' + ) + object_type = ContentTypeFilter( + field_name='object_types' + ) can_view = django_filters.BooleanFilter( method='_check_action' ) diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index 7f41746fd..d7a352793 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -15,7 +15,7 @@ User = get_user_model() class UserTestCase(TestCase, BaseFilterSetTests): queryset = User.objects.all() filterset = filtersets.UserFilterSet - ignore_fields = ('config', 'dashboard', 'password') + ignore_fields = ('config', 'dashboard', 'password', 'user_permissions') @classmethod def setUpTestData(cls): @@ -110,6 +110,7 @@ class UserTestCase(TestCase, BaseFilterSetTests): class GroupTestCase(TestCase, BaseFilterSetTests): queryset = Group.objects.all() filterset = filtersets.GroupFilterSet + ignore_fields = ('permissions',) @classmethod def setUpTestData(cls): diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index 4d8025672..898669cde 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -43,6 +43,15 @@ class BaseFilterSetTests: filterset = None ignore_fields = tuple() + @staticmethod + def get_m2m_filter_name(field): + """ + Given a ManyToManyField, determine the correct name for its corresponding Filter. Individual test + cases may override this method to prescribe deviations for specific fields. + """ + related_model_name = field.related_model._meta.verbose_name + return related_model_name.lower().replace(' ', '_') + def test_id(self): """ Test filtering for two PKs from a set of >2 objects. @@ -94,13 +103,22 @@ class BaseFilterSetTests: filter_name = model_field.name else: filter_name = f'{model_field.name}_id' - self.assertIn(filter_name, filterset_fields, f'No filter found for {filter_name}!') + self.assertIn( + filter_name, + filterset_fields, + f'No filter defined for {filter_name} ({model_field.name})!' + ) + + elif type(model_field) is ManyToManyField: + filter_name = self.get_m2m_filter_name(model_field) + filter_name = f'{filter_name}_id' + self.assertIn( + filter_name, + filterset_fields, + f'No filter defined for {filter_name} ({model_field.name})!' + ) # TODO: Many-to-many relationships - elif type(model_field) is ManyToManyField: - related_model = model_field.related_model._meta.model_name - filter_name = f'{related_model}_id' - self.assertIn(filter_name, filterset_fields, f'M2M: No filter found for {filter_name}!') elif type(model_field) is ManyToManyRel: continue @@ -110,14 +128,14 @@ class BaseFilterSetTests: # Tags elif type(model_field) is TaggableManager: - self.assertIn('tag', filterset_fields, f'No filter found for {model_field.name}!') + self.assertIn('tag', filterset_fields, f'No filter defined for {model_field.name}!') # All other fields else: self.assertIn( model_field.name, filterset_fields, - f'No filter found for {model_field.name} ({type(model_field)})!' + f'No defined found for {model_field.name} ({type(model_field)})!' ) diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 9a1c328a9..03eb0ee67 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -158,13 +158,17 @@ class IKEPolicyFilterSet(NetBoxModelFilterSet): mode = django_filters.MultipleChoiceFilter( choices=IKEModeChoices ) - proposal_id = MultiValueNumberFilter( + ike_proposal_id = MultiValueNumberFilter( field_name='proposals__id' ) - proposal = MultiValueCharFilter( + ike_proposal = MultiValueCharFilter( field_name='proposals__name' ) + # TODO: Remove in v4.1 + proposal = ike_proposal + proposal_id = ike_proposal_id + class Meta: model = IKEPolicy fields = ['id', 'name', 'preshared_key', 'description'] @@ -205,13 +209,17 @@ class IPSecPolicyFilterSet(NetBoxModelFilterSet): pfs_group = django_filters.MultipleChoiceFilter( choices=DHGroupChoices ) - proposal_id = MultiValueNumberFilter( + ipsec_proposal_id = MultiValueNumberFilter( field_name='proposals__id' ) - proposal = MultiValueCharFilter( + ipsec_proposal = MultiValueCharFilter( field_name='proposals__name' ) + # TODO: Remove in v4.1 + proposal = ipsec_proposal + proposal_id = ipsec_proposal_id + class Meta: model = IPSecPolicy fields = ['id', 'name', 'description'] diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index 4d099a065..26fd79854 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -1,4 +1,3 @@ -from django.contrib.contenttypes.models import ContentType from django.test import TestCase from dcim.choices import InterfaceTypeChoices @@ -446,11 +445,11 @@ class IKEPolicyTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mode': [IKEModeChoices.MAIN]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_proposal(self): + def test_ike_proposal(self): proposals = IKEProposal.objects.all()[:2] - params = {'proposal_id': [proposals[0].pk, proposals[1].pk]} + params = {'ike_proposal_id': [proposals[0].pk, proposals[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'proposal': [proposals[0].name, proposals[1].name]} + params = {'ike_proposal': [proposals[0].name, proposals[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -584,11 +583,11 @@ class IPSecPolicyTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'pfs_group': [DHGroupChoices.GROUP_1, DHGroupChoices.GROUP_2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_proposal(self): + def test_ipsec_proposal(self): proposals = IPSecProposal.objects.all()[:2] - params = {'proposal_id': [proposals[0].pk, proposals[1].pk]} + params = {'ipsec_proposal_id': [proposals[0].pk, proposals[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'proposal': [proposals[0].name, proposals[1].name]} + params = {'ipsec_proposal': [proposals[0].name, proposals[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -710,6 +709,15 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = L2VPN.objects.all() filterset = L2VPNFilterSet + @staticmethod + def get_m2m_filter_name(field): + # Override filter names for import & export RouteTargets + if field.name == 'import_targets': + return 'import_target' + if field.name == 'export_targets': + return 'export_target' + return super().get_m2m_filter_name(field) + @classmethod def setUpTestData(cls): From b36a70d23664839eec0e4ad8428e9c138e978b56 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 7 Mar 2024 16:27:58 -0500 Subject: [PATCH 23/56] Add missing filters for reverse many-to-many relationships --- netbox/dcim/filtersets.py | 5 ++ netbox/dcim/tests/test_filtersets.py | 39 +++++++---- netbox/extras/tests/test_filtersets.py | 88 +++++++++++++++++++++++- netbox/ipam/filtersets.py | 39 +++++++++++ netbox/ipam/tests/test_filtersets.py | 87 ++++++++++++++++++++--- netbox/users/filtersets.py | 15 ++++ netbox/users/tests/test_filtersets.py | 45 ++++++++++++ netbox/utilities/testing/filtersets.py | 6 +- netbox/vpn/filtersets.py | 22 ++++++ netbox/vpn/tests/test_filtersets.py | 36 +++++++++- netbox/wireless/filtersets.py | 5 ++ netbox/wireless/tests/test_filtersets.py | 16 +++++ 12 files changed, 373 insertions(+), 30 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0e22f613f..d8e74b773 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1184,6 +1184,11 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, Prim queryset=Device.objects.all(), label='Device model', ) + interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interfaces', + queryset=Interface.objects.all(), + label='Interface (ID)', + ) status = django_filters.MultipleChoiceFilter( choices=VirtualDeviceContextStatusChoices ) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index d844cfd6b..824f616e8 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -5409,15 +5409,22 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): VirtualDeviceContext.objects.bulk_create(vdcs) interfaces = ( - Interface(device=devices[0], name='Interface 1', type='virtual'), - Interface(device=devices[0], name='Interface 2', type='virtual'), + Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[1], name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[1], name='Interface 4', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[2], name='Interface 5', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[2], name='Interface 6', type=InterfaceTypeChoices.TYPE_VIRTUAL), ) Interface.objects.bulk_create(interfaces) - interfaces[0].vdcs.set([vdcs[0]]) interfaces[1].vdcs.set([vdcs[1]]) + interfaces[2].vdcs.set([vdcs[2]]) + interfaces[3].vdcs.set([vdcs[3]]) + interfaces[4].vdcs.set([vdcs[4]]) + interfaces[5].vdcs.set([vdcs[5]]) - addresses = ( + ip_addresses = ( IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'), IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'), IPAddress(assigned_object=None, address='10.1.1.3/24'), @@ -5425,13 +5432,12 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'), IPAddress(assigned_object=None, address='2001:db8::3/64'), ) - IPAddress.objects.bulk_create(addresses) - - vdcs[0].primary_ip4 = addresses[0] - vdcs[0].primary_ip6 = addresses[3] + IPAddress.objects.bulk_create(ip_addresses) + vdcs[0].primary_ip4 = ip_addresses[0] + vdcs[0].primary_ip6 = ip_addresses[3] vdcs[0].save() - vdcs[1].primary_ip4 = addresses[1] - vdcs[1].primary_ip6 = addresses[4] + vdcs[1].primary_ip4 = ip_addresses[1] + vdcs[1].primary_ip6 = ip_addresses[4] vdcs[1].save() def test_q(self): @@ -5439,8 +5445,11 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_device(self): - params = {'device': ['Device 1', 'Device 2']} + devices = Device.objects.filter(name__in=['Device 1', 'Device 2']) + params = {'device': [devices[0].name, devices[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_status(self): params = {'status': ['active']} @@ -5450,10 +5459,10 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_device_id(self): - devices = Device.objects.filter(name__in=['Device 1', 'Device 2']) - params = {'device_id': [devices[0].pk, devices[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_interface(self): + interfaces = Interface.objects.filter(name__in=['Interface 1', 'Interface 3']) + params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_has_primary_ip(self): params = {'has_primary_ip': True} diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index d7d9e6ca2..b68c02efc 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1128,7 +1128,93 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): class TagTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Tag.objects.all() filterset = TagFilterSet - ignore_fields = ('object_types',) + ignore_fields = ( + 'object_types', + + # Reverse relationships (to tagged models) we can ignore + 'aggregate', + 'asn', + 'asnrange', + 'cable', + 'circuit', + 'circuittermination', + 'circuittype', + 'cluster', + 'clustergroup', + 'clustertype', + 'configtemplate', + 'consoleport', + 'consoleserverport', + 'contact', + 'contactassignment', + 'contactgroup', + 'contactrole', + 'datasource', + 'device', + 'devicebay', + 'devicerole', + 'devicetype', + 'dummymodel', # From dummy_plugin + 'eventrule', + 'fhrpgroup', + 'frontport', + 'ikepolicy', + 'ikeproposal', + 'interface', + 'inventoryitem', + 'inventoryitemrole', + 'ipaddress', + 'iprange', + 'ipsecpolicy', + 'ipsecprofile', + 'ipsecproposal', + 'journalentry', + 'l2vpn', + 'l2vpntermination', + 'location', + 'manufacturer', + 'module', + 'modulebay', + 'moduletype', + 'platform', + 'powerfeed', + 'poweroutlet', + 'powerpanel', + 'powerport', + 'prefix', + 'provider', + 'provideraccount', + 'providernetwork', + 'rack', + 'rackreservation', + 'rackrole', + 'rearport', + 'region', + 'rir', + 'role', + 'routetarget', + 'service', + 'servicetemplate', + 'site', + 'sitegroup', + 'tenant', + 'tenantgroup', + 'tunnel', + 'tunnelgroup', + 'tunneltermination', + 'virtualchassis', + 'virtualdevicecontext', + 'virtualdisk', + 'virtualmachine', + 'vlan', + 'vlangroup', + 'vminterface', + 'vrf', + 'webhook', + 'wirelesslan', + 'wirelesslangroup', + 'wirelesslink', + ) @classmethod def setUpTestData(cls): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 84d507e44..951f475e2 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -8,6 +8,7 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from netaddr.core import AddrFormatError +from circuits.models import Provider from dcim.models import Device, Interface, Region, Site, SiteGroup from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet @@ -101,6 +102,28 @@ class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='rd', label=_('Export VRF (RD)'), ) + importing_l2vpn_id = django_filters.ModelMultipleChoiceFilter( + field_name='importing_l2vpns', + queryset=L2VPN.objects.all(), + label=_('Importing L2VPN'), + ) + importing_l2vpn = django_filters.ModelMultipleChoiceFilter( + field_name='importing_l2vpns__identifier', + queryset=L2VPN.objects.all(), + to_field_name='identifier', + label=_('Importing L2VPN (identifier)'), + ) + exporting_l2vpn_id = django_filters.ModelMultipleChoiceFilter( + field_name='exporting_l2vpns', + queryset=L2VPN.objects.all(), + label=_('Exporting L2VPN'), + ) + exporting_l2vpn = django_filters.ModelMultipleChoiceFilter( + field_name='exporting_l2vpns__identifier', + queryset=L2VPN.objects.all(), + to_field_name='identifier', + label=_('Exporting L2VPN (identifier)'), + ) def search(self, queryset, name, value): if not value.strip(): @@ -214,6 +237,17 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): to_field_name='slug', label=_('Site (slug)'), ) + provider_id = django_filters.ModelMultipleChoiceFilter( + field_name='providers', + queryset=Provider.objects.all(), + label=_('Provider (ID)'), + ) + provider = django_filters.ModelMultipleChoiceFilter( + field_name='providers__slug', + queryset=Provider.objects.all(), + to_field_name='slug', + label=_('Provider (slug)'), + ) class Meta: model = ASN @@ -628,6 +662,11 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): role = django_filters.MultipleChoiceFilter( choices=IPAddressRoleChoices ) + service_id = django_filters.ModelMultipleChoiceFilter( + field_name='services', + queryset=Service.objects.all(), + label=_('Service (ID)'), + ) class Meta: model = IPAddress diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 0b3c92b3e..52ef460d5 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -2,6 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from netaddr import IPNetwork +from circuits.models import Provider from dcim.choices import InterfaceTypeChoices from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup from ipam.choices import * @@ -10,6 +11,8 @@ from ipam.models import * from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from vpn.choices import L2VPNTypeChoices +from vpn.models import L2VPN class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -110,13 +113,6 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): ] RIR.objects.bulk_create(rirs) - sites = [ - Site(name='Site 1', slug='site-1'), - Site(name='Site 2', slug='site-2'), - Site(name='Site 3', slug='site-3') - ] - Site.objects.bulk_create(sites) - tenants = [ Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), @@ -136,6 +132,12 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): ) ASN.objects.bulk_create(asns) + sites = [ + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3') + ] + Site.objects.bulk_create(sites) asns[0].sites.set([sites[0]]) asns[1].sites.set([sites[1]]) asns[2].sites.set([sites[2]]) @@ -143,6 +145,16 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): asns[4].sites.set([sites[1]]) asns[5].sites.set([sites[2]]) + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), + ) + Provider.objects.bulk_create(providers) + providers[0].asns.add(asns[0]) + providers[1].asns.add(asns[1]) + providers[2].asns.add(asns[2]) + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -176,6 +188,11 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_provider(self): + providers = Provider.objects.all()[:2] + params = {'provider_id': [providers[0].pk, providers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class VRFTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VRF.objects.all() @@ -188,7 +205,7 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests): return 'import_target' if field.name == 'export_targets': return 'export_target' - return super().get_m2m_filter_name(field) + return ChangeLoggedFilterSetTests.get_m2m_filter_name(field) @classmethod def setUpTestData(cls): @@ -286,6 +303,19 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RouteTarget.objects.all() filterset = RouteTargetFilterSet + @staticmethod + def get_m2m_filter_name(field): + # Override filter names for import & export VRFs and L2VPNs + if field.name == 'importing_vrfs': + return 'importing_vrf' + if field.name == 'exporting_vrfs': + return 'exporting_vrf' + if field.name == 'importing_l2vpns': + return 'importing_l2vpn' + if field.name == 'exporting_l2vpns': + return 'exporting_l2vpn' + return ChangeLoggedFilterSetTests.get_m2m_filter_name(field) + @classmethod def setUpTestData(cls): @@ -331,6 +361,17 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests): vrfs[1].import_targets.add(route_targets[4], route_targets[5]) vrfs[1].export_targets.add(route_targets[6], route_targets[7]) + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=100), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=200), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=300), + ) + L2VPN.objects.bulk_create(l2vpns) + l2vpns[0].import_targets.add(route_targets[0], route_targets[1]) + l2vpns[0].export_targets.add(route_targets[2], route_targets[3]) + l2vpns[1].import_targets.add(route_targets[4], route_targets[5]) + l2vpns[1].export_targets.add(route_targets[6], route_targets[7]) + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -353,6 +394,20 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'exporting_vrf': [vrfs[0].rd, vrfs[1].rd]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_importing_l2vpn(self): + l2vpns = L2VPN.objects.all()[:2] + params = {'importing_l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'importing_l2vpn': [l2vpns[0].identifier, l2vpns[1].identifier]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_exporting_l2vpn(self): + l2vpns = L2VPN.objects.all()[:2] + params = {'exporting_l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'exporting_l2vpn': [l2vpns[0].identifier, l2vpns[1].identifier]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_tenant(self): tenants = Tenant.objects.all()[:2] params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} @@ -1102,6 +1157,16 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): ) IPAddress.objects.bulk_create(ipaddresses) + services = ( + Service(name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]), + Service(name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]), + Service(name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]), + ) + Service.objects.bulk_create(services) + services[0].ipaddresses.add(ipaddresses[0]) + services[1].ipaddresses.add(ipaddresses[1]) + services[2].ipaddresses.add(ipaddresses[2]) + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -1241,6 +1306,11 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_service(self): + services = Service.objects.all()[:2] + params = {'service_id': [services[0].pk, services[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FHRPGroup.objects.all() @@ -1485,6 +1555,7 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VLAN.objects.all() filterset = VLANFilterSet + ignore_fields = ('interfaces_as_tagged', 'vminterfaces_as_tagged') @classmethod def setUpTestData(cls): diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 5ad8b6476..8a770ef34 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -20,6 +20,16 @@ class GroupFilterSet(BaseFilterSet): method='search', label=_('Search'), ) + user_id = django_filters.ModelMultipleChoiceFilter( + field_name='user', + queryset=get_user_model().objects.all(), + label=_('User (ID)'), + ) + permission_id = django_filters.ModelMultipleChoiceFilter( + field_name='object_permissions', + queryset=ObjectPermission.objects.all(), + label=_('Permission (ID)'), + ) class Meta: model = Group @@ -47,6 +57,11 @@ class UserFilterSet(BaseFilterSet): to_field_name='name', label=_('Group (name)'), ) + permission_id = django_filters.ModelMultipleChoiceFilter( + field_name='object_permissions', + queryset=ObjectPermission.objects.all(), + label=_('Permission (ID)'), + ) class Meta: model = get_user_model() diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index d7a352793..2cef6954a 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -67,6 +67,16 @@ class UserTestCase(TestCase, BaseFilterSetTests): users[1].groups.set([groups[1]]) users[2].groups.set([groups[2]]) + object_permissions = ( + ObjectPermission(name='Permission 1', actions=['add']), + ObjectPermission(name='Permission 2', actions=['change']), + ObjectPermission(name='Permission 3', actions=['delete']), + ) + ObjectPermission.objects.bulk_create(object_permissions) + object_permissions[0].users.add(users[0]) + object_permissions[1].users.add(users[1]) + object_permissions[2].users.add(users[2]) + def test_q(self): params = {'q': 'user1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -106,6 +116,11 @@ class UserTestCase(TestCase, BaseFilterSetTests): params = {'group': [groups[0].name, groups[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_permission(self): + object_permissions = ObjectPermission.objects.all()[:2] + params = {'permission_id': [object_permissions[0].pk, object_permissions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class GroupTestCase(TestCase, BaseFilterSetTests): queryset = Group.objects.all() @@ -122,6 +137,26 @@ class GroupTestCase(TestCase, BaseFilterSetTests): ) Group.objects.bulk_create(groups) + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + ) + User.objects.bulk_create(users) + users[0].groups.set([groups[0]]) + users[1].groups.set([groups[1]]) + users[2].groups.set([groups[2]]) + + object_permissions = ( + ObjectPermission(name='Permission 1', actions=['add']), + ObjectPermission(name='Permission 2', actions=['change']), + ObjectPermission(name='Permission 3', actions=['delete']), + ) + ObjectPermission.objects.bulk_create(object_permissions) + object_permissions[0].groups.add(groups[0]) + object_permissions[1].groups.add(groups[1]) + object_permissions[2].groups.add(groups[2]) + def test_q(self): params = {'q': 'group 1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -130,6 +165,16 @@ class GroupTestCase(TestCase, BaseFilterSetTests): params = {'name': ['Group 1', 'Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_user(self): + users = User.objects.all()[:2] + params = {'user_id': [users[0].pk, users[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_permission(self): + object_permissions = ObjectPermission.objects.all()[:2] + params = {'permission_id': [object_permissions[0].pk, object_permissions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ObjectPermissionTestCase(TestCase, BaseFilterSetTests): queryset = ObjectPermission.objects.all() diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index 898669cde..2dfd0ed61 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -109,7 +109,7 @@ class BaseFilterSetTests: f'No filter defined for {filter_name} ({model_field.name})!' ) - elif type(model_field) is ManyToManyField: + elif type(model_field) in (ManyToManyField, ManyToManyRel): filter_name = self.get_m2m_filter_name(model_field) filter_name = f'{filter_name}_id' self.assertIn( @@ -118,10 +118,6 @@ class BaseFilterSetTests: f'No filter defined for {filter_name} ({model_field.name})!' ) - # TODO: Many-to-many relationships - elif type(model_field) is ManyToManyRel: - continue - # TODO: Generic relationships elif type(model_field) in (GenericForeignKey, GenericRelation): continue diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 03eb0ee67..327ce8b27 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -124,6 +124,17 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet): class IKEProposalFilterSet(NetBoxModelFilterSet): + ike_policy_id = django_filters.ModelMultipleChoiceFilter( + field_name='ike_policies', + queryset=IKEPolicy.objects.all(), + label=_('IKE policy (ID)'), + ) + ike_policy = django_filters.ModelMultipleChoiceFilter( + field_name='ike_policies__name', + queryset=IKEPolicy.objects.all(), + to_field_name='name', + label=_('IKE policy (name)'), + ) authentication_method = django_filters.MultipleChoiceFilter( choices=AuthenticationMethodChoices ) @@ -184,6 +195,17 @@ class IKEPolicyFilterSet(NetBoxModelFilterSet): class IPSecProposalFilterSet(NetBoxModelFilterSet): + ipsec_policy_id = django_filters.ModelMultipleChoiceFilter( + field_name='ipsec_policies', + queryset=IPSecPolicy.objects.all(), + label=_('IPSec policy (ID)'), + ) + ipsec_policy = django_filters.ModelMultipleChoiceFilter( + field_name='ipsec_policies__name', + queryset=IPSecPolicy.objects.all(), + to_field_name='name', + label=_('IPSec policy (name)'), + ) encryption_algorithm = django_filters.MultipleChoiceFilter( choices=EncryptionAlgorithmChoices ) diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index 26fd79854..f16db4cb8 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -330,6 +330,16 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests): ) IKEProposal.objects.bulk_create(ike_proposals) + ike_policies = ( + IKEPolicy(name='IKE Policy 1'), + IKEPolicy(name='IKE Policy 2'), + IKEPolicy(name='IKE Policy 3'), + ) + IKEPolicy.objects.bulk_create(ike_policies) + ike_policies[0].proposals.add(ike_proposals[0]) + ike_policies[1].proposals.add(ike_proposals[1]) + ike_policies[2].proposals.add(ike_proposals[2]) + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -342,6 +352,13 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_ike_policy(self): + ike_policies = IKEPolicy.objects.all()[:2] + params = {'ike_policy_id': [ike_policies[0].pk, ike_policies[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'ike_policy': [ike_policies[0].name, ike_policies[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_authentication_method(self): params = {'authentication_method': [ AuthenticationMethodChoices.PRESHARED_KEYS, AuthenticationMethodChoices.CERTIFICATES @@ -487,6 +504,16 @@ class IPSecProposalTestCase(TestCase, ChangeLoggedFilterSetTests): ) IPSecProposal.objects.bulk_create(ipsec_proposals) + ipsec_policies = ( + IPSecPolicy(name='IPSec Policy 1'), + IPSecPolicy(name='IPSec Policy 2'), + IPSecPolicy(name='IPSec Policy 3'), + ) + IPSecPolicy.objects.bulk_create(ipsec_policies) + ipsec_policies[0].proposals.add(ipsec_proposals[0]) + ipsec_policies[1].proposals.add(ipsec_proposals[1]) + ipsec_policies[2].proposals.add(ipsec_proposals[2]) + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -499,6 +526,13 @@ class IPSecProposalTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_ipsec_policy(self): + ipsec_policies = IPSecPolicy.objects.all()[:2] + params = {'ipsec_policy_id': [ipsec_policies[0].pk, ipsec_policies[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'ipsec_policy': [ipsec_policies[0].name, ipsec_policies[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_encryption_algorithm(self): params = {'encryption_algorithm': [ EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC @@ -716,7 +750,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): return 'import_target' if field.name == 'export_targets': return 'export_target' - return super().get_m2m_filter_name(field) + return ChangeLoggedFilterSetTests.get_m2m_filter_name(field) @classmethod def setUpTestData(cls): diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 50b1f78b1..6c70cf0f2 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -2,6 +2,7 @@ import django_filters from django.db.models import Q from dcim.choices import LinkStatusChoices +from dcim.models import Interface from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet @@ -60,6 +61,10 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): vlan_id = django_filters.ModelMultipleChoiceFilter( queryset=VLAN.objects.all() ) + interface_id = django_filters.ModelMultipleChoiceFilter( + queryset=Interface.objects.all(), + field_name='interfaces' + ) auth_type = django_filters.MultipleChoiceFilter( choices=WirelessAuthTypeChoices ) diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index 78e50edb7..72264a158 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/netbox/wireless/tests/test_filtersets.py @@ -153,6 +153,17 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): ) WirelessLAN.objects.bulk_create(wireless_lans) + device = create_test_device('Device 1') + interfaces = ( + Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_80211N), + Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_80211N), + Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_80211N), + ) + Interface.objects.bulk_create(interfaces) + interfaces[0].wireless_lans.add(wireless_lans[0]) + interfaces[1].wireless_lans.add(wireless_lans[1]) + interfaces[2].wireless_lans.add(wireless_lans[2]) + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -200,6 +211,11 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'tenant': [tenants[0].slug, tenants[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_interface(self): + interfaces = Interface.objects.all()[:2] + params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = WirelessLink.objects.all() From a136030094dbff6baa45accfa815b10b505a79d2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 7 Mar 2024 17:09:26 -0500 Subject: [PATCH 24/56] Validate filter class for foreign key fields --- netbox/dcim/filtersets.py | 11 +++++-- netbox/ipam/filtersets.py | 7 +++- netbox/utilities/testing/filtersets.py | 44 +++++++++++++++++++++----- netbox/wireless/filtersets.py | 8 +++-- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index d8e74b773..51e478e65 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -785,10 +785,13 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo choices=PortTypeChoices, null_value=None ) + rear_port_id = django_filters.ModelMultipleChoiceFilter( + queryset=RearPort.objects.all() + ) class Meta: model = FrontPortTemplate - fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_id', 'rear_port_position', 'description') + fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description') class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -1688,12 +1691,14 @@ class FrontPortFilterSet( choices=PortTypeChoices, null_value=None ) + rear_port_id = django_filters.ModelMultipleChoiceFilter( + queryset=RearPort.objects.all() + ) class Meta: model = FrontPort fields = ( - 'id', 'name', 'label', 'type', 'color', 'rear_port_id', 'rear_port_position', 'description', - 'mark_connected', 'cable_end', + 'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end', ) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 951f475e2..23da04566 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -667,10 +667,15 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): queryset=Service.objects.all(), label=_('Service (ID)'), ) + nat_inside_id = django_filters.ModelMultipleChoiceFilter( + field_name='nat_inside', + queryset=IPAddress.objects.all(), + label=_('NAT inside IP address (ID)'), + ) class Meta: model = IPAddress - fields = ('id', 'dns_name', 'description', 'assigned_object_type', 'assigned_object_id', 'nat_inside_id') + fields = ('id', 'dns_name', 'description', 'assigned_object_type', 'assigned_object_id') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index 2dfd0ed61..005630c9c 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -1,11 +1,14 @@ +import django_filters from datetime import datetime, timezone from itertools import chain +from mptt.models import MPTTModel from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.db.models import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel, OneToOneRel from django.utils.module_loading import import_string from taggit.managers import TaggableManager +from utilities.filters import TreeNodeMultipleChoiceFilter from core.models import ObjectType @@ -52,6 +55,21 @@ class BaseFilterSetTests: related_model_name = field.related_model._meta.verbose_name return related_model_name.lower().replace(' ', '_') + @staticmethod + def get_filter_class_for_field(field): + + # ForeignKey & OneToOneField + if issubclass(field.__class__, ForeignKey) or type(field) is OneToOneRel: + + # ForeignKey to an MPTT-enabled model + if issubclass(field.related_model, MPTTModel) and field.model is not field.related_model: + return TreeNodeMultipleChoiceFilter + + return django_filters.ModelMultipleChoiceFilter + + # Unable to determine the correct filter class + return None + def test_id(self): """ Test filtering for two PKs from a set of >2 objects. @@ -76,7 +94,7 @@ class BaseFilterSetTests: filterset = import_string(f'{app_label}.filtersets.{model_name}FilterSet') self.assertEqual(model, filterset.Meta.model, "FilterSet model does not match!") - filterset_fields = sorted(filterset.get_filters()) + filters = filterset.get_filters() # Check for missing filters for model_field in model._meta.get_fields(): @@ -95,26 +113,36 @@ class BaseFilterSetTests: # One-to-one & one-to-many relationships if issubclass(model_field.__class__, ForeignKey) or type(model_field) is OneToOneRel: + + # Relationships to ContentType (used as part of a GFK) do not need a filter if model_field.related_model is ContentType: - # Relationships to ContentType (used as part of a GFK) do not need a filter continue - elif model_field.related_model is ObjectType: - # Filters to ObjectType use 'app.model' rather than numeric PK, so we omit the _id suffix + + # Filters to ObjectType use 'app.model' rather than numeric PK, so we omit the _id suffix + if model_field.related_model is ObjectType: filter_name = model_field.name else: filter_name = f'{model_field.name}_id' + self.assertIn( filter_name, - filterset_fields, + filters, f'No filter defined for {filter_name} ({model_field.name})!' ) + if filter_class := self.get_filter_class_for_field(model_field): + self.assertIs( + type(filters[filter_name]), + filter_class, + f"Invalid filter class for {filter_name}!" + ) + # Many-to-many relationships (forward & backward) elif type(model_field) in (ManyToManyField, ManyToManyRel): filter_name = self.get_m2m_filter_name(model_field) filter_name = f'{filter_name}_id' self.assertIn( filter_name, - filterset_fields, + filters, f'No filter defined for {filter_name} ({model_field.name})!' ) @@ -124,13 +152,13 @@ class BaseFilterSetTests: # Tags elif type(model_field) is TaggableManager: - self.assertIn('tag', filterset_fields, f'No filter defined for {model_field.name}!') + self.assertIn('tag', filters, f'No filter defined for {model_field.name}!') # All other fields else: self.assertIn( model_field.name, - filterset_fields, + filters, f'No defined found for {model_field.name} ({type(model_field)})!' ) diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 6c70cf0f2..8415f191d 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -87,8 +87,12 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class WirelessLinkFilterSet(NetBoxModelFilterSet, TenancyFilterSet): - interface_a_id = MultiValueNumberFilter() - interface_b_id = MultiValueNumberFilter() + interface_a_id = django_filters.ModelMultipleChoiceFilter( + queryset=Interface.objects.all() + ) + interface_b_id = django_filters.ModelMultipleChoiceFilter( + queryset=Interface.objects.all() + ) status = django_filters.MultipleChoiceFilter( choices=LinkStatusChoices ) From 313e63622b019b34757db6369ea684c8e28113b8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 Mar 2024 15:35:40 -0400 Subject: [PATCH 25/56] Extend logic for validating filter class --- netbox/extras/filtersets.py | 30 ++++--- netbox/ipam/tests/test_filtersets.py | 6 +- netbox/users/filtersets.py | 8 +- netbox/utilities/testing/filtersets.py | 112 +++++++++++++------------ netbox/vpn/filtersets.py | 22 +++-- netbox/vpn/tests/test_filtersets.py | 3 +- 6 files changed, 99 insertions(+), 82 deletions(-) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 0be2fde28..1ab6679e2 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -91,8 +91,9 @@ class EventRuleFilterSet(NetBoxModelFilterSet): method='search', label=_('Search'), ) - object_type_id = MultiValueNumberFilter( - field_name='object_types__id' + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='object_types' ) object_type = ContentTypeFilter( field_name='object_types' @@ -128,14 +129,16 @@ class CustomFieldFilterSet(ChangeLoggedModelFilterSet): type = django_filters.MultipleChoiceFilter( choices=CustomFieldTypeChoices ) - object_type_id = MultiValueNumberFilter( - field_name='object_types__id' + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='object_types' ) object_type = ContentTypeFilter( field_name='object_types' ) - related_object_type_id = MultiValueNumberFilter( - field_name='related_object_type__id' + related_object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='related_object_type' ) related_object_type = ContentTypeFilter() choice_set_id = django_filters.ModelMultipleChoiceFilter( @@ -199,8 +202,9 @@ class CustomLinkFilterSet(ChangeLoggedModelFilterSet): method='search', label=_('Search'), ) - object_type_id = MultiValueNumberFilter( - field_name='object_types__id' + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='object_types' ) object_type = ContentTypeFilter( field_name='object_types' @@ -228,8 +232,9 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet): method='search', label=_('Search'), ) - object_type_id = MultiValueNumberFilter( - field_name='object_types__id' + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='object_types' ) object_type = ContentTypeFilter( field_name='object_types' @@ -264,8 +269,9 @@ class SavedFilterFilterSet(ChangeLoggedModelFilterSet): method='search', label=_('Search'), ) - object_type_id = MultiValueNumberFilter( - field_name='object_types__id' + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='object_types' ) object_type = ContentTypeFilter( field_name='object_types' diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 52ef460d5..3a46423a5 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -198,8 +198,7 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VRF.objects.all() filterset = VRFFilterSet - @staticmethod - def get_m2m_filter_name(field): + def get_m2m_filter_name(self, field): # Override filter names for import & export RouteTargets if field.name == 'import_targets': return 'import_target' @@ -303,8 +302,7 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RouteTarget.objects.all() filterset = RouteTargetFilterSet - @staticmethod - def get_m2m_filter_name(field): + def get_m2m_filter_name(self, field): # Override filter names for import & export VRFs and L2VPNs if field.name == 'importing_vrfs': return 'importing_vrf' diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 8a770ef34..6e86528dd 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -3,9 +3,10 @@ from django.contrib.auth import get_user_model from django.db.models import Q from django.utils.translation import gettext as _ +from core.models import ObjectType from netbox.filtersets import BaseFilterSet from users.models import Group, ObjectPermission, Token -from utilities.filters import ContentTypeFilter, MultiValueNumberFilter +from utilities.filters import ContentTypeFilter __all__ = ( 'GroupFilterSet', @@ -134,8 +135,9 @@ class ObjectPermissionFilterSet(BaseFilterSet): method='search', label=_('Search'), ) - object_type_id = MultiValueNumberFilter( - field_name='object_types__id' + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='object_types' ) object_type = ContentTypeFilter( field_name='object_types' diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index 005630c9c..2cfcb3209 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -8,7 +8,9 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel, OneToOneRel from django.utils.module_loading import import_string from taggit.managers import TaggableManager -from utilities.filters import TreeNodeMultipleChoiceFilter + +from extras.filters import TagFilter +from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from core.models import ObjectType @@ -46,8 +48,7 @@ class BaseFilterSetTests: filterset = None ignore_fields = tuple() - @staticmethod - def get_m2m_filter_name(field): + def get_m2m_filter_name(self, field): """ Given a ManyToManyField, determine the correct name for its corresponding Filter. Individual test cases may override this method to prescribe deviations for specific fields. @@ -55,20 +56,50 @@ class BaseFilterSetTests: related_model_name = field.related_model._meta.verbose_name return related_model_name.lower().replace(' ', '_') - @staticmethod - def get_filter_class_for_field(field): - + def get_filters_for_model_field(self, field): + """ + Given a model field, return an iterable of (name, class) for each filter that should be defined on + the model's FilterSet class. If the appropriate filter class cannot be determined, it will be None. + """ # ForeignKey & OneToOneField if issubclass(field.__class__, ForeignKey) or type(field) is OneToOneRel: + # Relationships to ContentType (used as part of a GFK) do not need a filter + if field.related_model is ContentType: + return [(None, None)] + + # ForeignKeys to ObjectType need two filters: 'app.model' & PK + if field.related_model is ObjectType: + return [ + (field.name, ContentTypeFilter), + (f'{field.name}_id', django_filters.ModelMultipleChoiceFilter), + ] + # ForeignKey to an MPTT-enabled model if issubclass(field.related_model, MPTTModel) and field.model is not field.related_model: - return TreeNodeMultipleChoiceFilter + return [(f'{field.name}_id', TreeNodeMultipleChoiceFilter)] - return django_filters.ModelMultipleChoiceFilter + return [(f'{field.name}_id', django_filters.ModelMultipleChoiceFilter)] + + # Many-to-many relationships (forward & backward) + elif type(field) in (ManyToManyField, ManyToManyRel): + filter_name = self.get_m2m_filter_name(field) + + # ManyToManyFields to ObjectType need two filters: 'app.model' & PK + if field.related_model is ObjectType: + return [ + (filter_name, ContentTypeFilter), + (f'{filter_name}_id', django_filters.ModelMultipleChoiceFilter), + ] + + return [(f'{filter_name}_id', django_filters.ModelMultipleChoiceFilter)] + + # Tag manager + if type(field) is TaggableManager: + return [('tag', TagFilter)] # Unable to determine the correct filter class - return None + return [(field.name, None)] def test_id(self): """ @@ -111,57 +142,32 @@ class BaseFilterSetTests: if type(model_field) is ManyToOneRel: continue - # One-to-one & one-to-many relationships - if issubclass(model_field.__class__, ForeignKey) or type(model_field) is OneToOneRel: - - # Relationships to ContentType (used as part of a GFK) do not need a filter - if model_field.related_model is ContentType: - continue - - # Filters to ObjectType use 'app.model' rather than numeric PK, so we omit the _id suffix - if model_field.related_model is ObjectType: - filter_name = model_field.name - else: - filter_name = f'{model_field.name}_id' - - self.assertIn( - filter_name, - filters, - f'No filter defined for {filter_name} ({model_field.name})!' - ) - if filter_class := self.get_filter_class_for_field(model_field): - self.assertIs( - type(filters[filter_name]), - filter_class, - f"Invalid filter class for {filter_name}!" - ) - - # Many-to-many relationships (forward & backward) - elif type(model_field) in (ManyToManyField, ManyToManyRel): - filter_name = self.get_m2m_filter_name(model_field) - filter_name = f'{filter_name}_id' - self.assertIn( - filter_name, - filters, - f'No filter defined for {filter_name} ({model_field.name})!' - ) - # TODO: Generic relationships - elif type(model_field) in (GenericForeignKey, GenericRelation): + if type(model_field) in (GenericForeignKey, GenericRelation): continue - # Tags - elif type(model_field) is TaggableManager: - self.assertIn('tag', filters, f'No filter defined for {model_field.name}!') + for filter_name, filter_class in self.get_filters_for_model_field(model_field): - # All other fields - else: + if filter_name is None: + # Field is exempt + continue + + # Check that the filter is defined self.assertIn( - model_field.name, - filters, - f'No defined found for {model_field.name} ({type(model_field)})!' + filter_name, + filters.keys(), + f'No filter defined for {filter_name} ({model_field.name})!' ) + # Check that the filter class is correct + filter = filters[filter_name] + if filter_class is not None: + self.assertIs( + type(filter), + filter_class, + f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!" + ) + class ChangeLoggedFilterSetTests(BaseFilterSetTests): diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 327ce8b27..3c23cb478 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -169,11 +169,14 @@ class IKEPolicyFilterSet(NetBoxModelFilterSet): mode = django_filters.MultipleChoiceFilter( choices=IKEModeChoices ) - ike_proposal_id = MultiValueNumberFilter( - field_name='proposals__id' + ike_proposal_id = django_filters.ModelMultipleChoiceFilter( + field_name='proposals', + queryset=IKEProposal.objects.all() ) - ike_proposal = MultiValueCharFilter( - field_name='proposals__name' + ike_proposal = django_filters.ModelMultipleChoiceFilter( + field_name='proposals__name', + queryset=IKEProposal.objects.all(), + to_field_name='name' ) # TODO: Remove in v4.1 @@ -231,11 +234,14 @@ class IPSecPolicyFilterSet(NetBoxModelFilterSet): pfs_group = django_filters.MultipleChoiceFilter( choices=DHGroupChoices ) - ipsec_proposal_id = MultiValueNumberFilter( - field_name='proposals__id' + ipsec_proposal_id = django_filters.ModelMultipleChoiceFilter( + field_name='proposals', + queryset=IPSecProposal.objects.all() ) - ipsec_proposal = MultiValueCharFilter( - field_name='proposals__name' + ipsec_proposal = django_filters.ModelMultipleChoiceFilter( + field_name='proposals__name', + queryset=IPSecProposal.objects.all(), + to_field_name='name' ) # TODO: Remove in v4.1 diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index f16db4cb8..d2b893766 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -743,8 +743,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = L2VPN.objects.all() filterset = L2VPNFilterSet - @staticmethod - def get_m2m_filter_name(field): + def get_m2m_filter_name(self, field): # Override filter names for import & export RouteTargets if field.name == 'import_targets': return 'import_target' From f8744a665918fdd3a8764d4b7d36f5e3256bbc06 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 Mar 2024 15:44:47 -0400 Subject: [PATCH 26/56] Clean up exemption logic --- netbox/dcim/tests/test_filtersets.py | 2 +- netbox/utilities/testing/filtersets.py | 24 +++---------------- .../virtualization/tests/test_filtersets.py | 2 +- 3 files changed, 5 insertions(+), 23 deletions(-) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 824f616e8..1e46d66ac 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -3235,7 +3235,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = Interface.objects.all() filterset = InterfaceFilterSet - ignore_fields = ('untagged_vlan', 'vdcs') + ignore_fields = ('tagged_vlans', 'untagged_vlan', 'vdcs') @classmethod def setUpTestData(cls): diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index 2cfcb3209..e58123f03 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -19,21 +19,7 @@ __all__ = ( 'ChangeLoggedFilterSetTests', ) -IGNORE_MODELS = ( - ('core', 'AutoSyncRecord'), - ('core', 'ManagedFile'), - ('core', 'ObjectType'), - ('dcim', 'CablePath'), - ('extras', 'Branch'), - ('extras', 'CachedValue'), - ('extras', 'Dashboard'), - ('extras', 'ScriptModule'), - ('extras', 'StagedChange'), - ('extras', 'TaggedItem'), - ('users', 'UserConfig'), -) - -IGNORE_FIELDS = ( +EXEMPT_MODEL_FIELDS = ( 'comments', 'custom_field_data', 'level', # MPTT @@ -117,10 +103,6 @@ class BaseFilterSetTests: model = self.queryset.model model_name = model.__name__ - # Skip ignored models - if (app_label, model_name) in IGNORE_MODELS: - return - # Import the FilterSet class & sanity check it filterset = import_string(f'{app_label}.filtersets.{model_name}FilterSet') self.assertEqual(model, filterset.Meta.model, "FilterSet model does not match!") @@ -135,14 +117,14 @@ class BaseFilterSetTests: continue # Skip ignored fields - if model_field.name in chain(self.ignore_fields, IGNORE_FIELDS): + if model_field.name in chain(self.ignore_fields, EXEMPT_MODEL_FIELDS): continue # Skip reverse ForeignKey relationships if type(model_field) is ManyToOneRel: continue - # TODO: Generic relationships + # Skip generic relationships if type(model_field) in (GenericForeignKey, GenericRelation): continue diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 3d9e17a23..ff55aba10 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -522,7 +522,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VMInterface.objects.all() filterset = VMInterfaceFilterSet - ignore_fields = ('untagged_vlan',) + ignore_fields = ('tagged_vlans', 'untagged_vlan',) @classmethod def setUpTestData(cls): From 51b2bcf264723f1752438b0448049ba50f9feb87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markku=20Leini=C3=B6?= Date: Sun, 10 Mar 2024 13:41:27 +0200 Subject: [PATCH 27/56] Closes #14206: Add FC SFP types --- netbox/dcim/choices.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 2ba24e0aa..b00784265 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -889,7 +889,10 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_8GFC_SFP_PLUS = '8gfc-sfpp' TYPE_16GFC_SFP_PLUS = '16gfc-sfpp' TYPE_32GFC_SFP28 = '32gfc-sfp28' + TYPE_32GFC_SFP_PLUS = '32gfc-sfpp' TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp' + TYPE_64GFC_SFP_DD = '64gfc-sfpdd' + TYPE_64GFC_SFP_PLUS = '64gfc-sfpp' TYPE_128GFC_QSFP28 = '128gfc-qsfp28' # InfiniBand @@ -1058,7 +1061,10 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'), (TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'), (TYPE_32GFC_SFP28, 'SFP28 (32GFC)'), + (TYPE_32GFC_SFP_PLUS, 'SFP+ (32GFC)'), (TYPE_64GFC_QSFP_PLUS, 'QSFP+ (64GFC)'), + (TYPE_64GFC_SFP_DD, 'SFP-DD (64GFC)'), + (TYPE_64GFC_SFP_PLUS, 'SFP+ (64GFC)'), (TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'), ) ), From eca2a7758485bc0e62a6a63706ab50890364bf53 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Mar 2024 10:51:29 -0400 Subject: [PATCH 28/56] Closes #14459: Update coverage report --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed8c65b7d..11125ae4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,4 +84,4 @@ jobs: run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel - name: Show coverage report - run: coverage report --skip-covered --omit *migrations* + run: coverage report --skip-covered --omit '*/migrations/*,*/tests/*' From 44f7ab09701b39d6a526f09c86945c5764a313c0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Mar 2024 10:57:14 -0400 Subject: [PATCH 29/56] Add NetBox Enterprise deployment type --- .github/ISSUE_TEMPLATE/bug_report.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index b0b8c02ad..1776f6cf4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -17,8 +17,9 @@ body: How are you running NetBox? (For issues with the Docker image, please go to the [netbox-docker](https://github.com/netbox-community/netbox-docker) repo.) options: - - Self-hosted - NetBox Cloud + - NetBox Enterprise + - Self-hosted validations: required: true - type: input From 52bda9c0e688952d8770985aff3d0a33be062d4d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Mar 2024 11:20:23 -0400 Subject: [PATCH 30/56] Closes #15401: Rename PostgreSQL tables & indexes for L2VPN models (#15405) * Closes #15401: Rename PostgreSQL tables & indexes for L2VPN models * Account for alternate index name --- netbox/vpn/migrations/0005_rename_indexes.py | 44 ++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 netbox/vpn/migrations/0005_rename_indexes.py diff --git a/netbox/vpn/migrations/0005_rename_indexes.py b/netbox/vpn/migrations/0005_rename_indexes.py new file mode 100644 index 000000000..805b380cc --- /dev/null +++ b/netbox/vpn/migrations/0005_rename_indexes.py @@ -0,0 +1,44 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0004_alter_ikepolicy_mode'), + ] + + operations = [ + + # Rename vpn_l2vpn constraints + migrations.RunSQL("ALTER TABLE vpn_l2vpn RENAME CONSTRAINT ipam_l2vpn_tenant_id_bb2564a6_fk_tenancy_tenant_id TO vpn_l2vpn_tenant_id_57ec8f92_fk_tenancy_tenant_id"), + + # Rename ipam_l2vpn_* sequences + migrations.RunSQL("ALTER TABLE ipam_l2vpn_export_targets_id_seq RENAME TO vpn_l2vpn_export_targets_id_seq"), + migrations.RunSQL("ALTER TABLE ipam_l2vpn_id_seq RENAME TO vpn_l2vpn_id_seq"), + migrations.RunSQL("ALTER TABLE ipam_l2vpn_import_targets_id_seq RENAME TO vpn_l2vpn_import_targets_id_seq"), + + # Rename ipam_l2vpn_* indexes + migrations.RunSQL("ALTER INDEX ipam_l2vpn_pkey RENAME TO vpn_l2vpn_pkey"), + migrations.RunSQL("ALTER INDEX ipam_l2vpn_name_5e1c080f_like RENAME TO vpn_l2vpn_name_8824eda5_like"), + migrations.RunSQL("ALTER INDEX ipam_l2vpn_name_key RENAME TO vpn_l2vpn_name_key"), + migrations.RunSQL("ALTER INDEX ipam_l2vpn_slug_24008406_like RENAME TO vpn_l2vpn_slug_76b5a174_like"), + migrations.RunSQL("ALTER INDEX ipam_l2vpn_tenant_id_bb2564a6 RENAME TO vpn_l2vpn_tenant_id_57ec8f92"), + # The unique index for L2VPN.slug may have one of two names, depending on how it was created, + # so we check for both. + migrations.RunSQL("ALTER INDEX IF EXISTS ipam_l2vpn_slug_24008406_uniq RENAME TO vpn_l2vpn_slug_76b5a174_uniq"), + migrations.RunSQL("ALTER INDEX IF EXISTS ipam_l2vpn_slug_key RENAME TO vpn_l2vpn_slug_key"), + + # Rename vpn_l2vpntermination constraints + migrations.RunSQL("ALTER TABLE vpn_l2vpntermination RENAME CONSTRAINT ipam_l2vpntermination_assigned_object_id_check TO vpn_l2vpntermination_assigned_object_id_check"), + migrations.RunSQL("ALTER TABLE vpn_l2vpntermination RENAME CONSTRAINT ipam_l2vpnterminatio_assigned_object_type_3923c124_fk_django_co TO vpn_l2vpntermination_assigned_object_type_id_f063b865_fk_django_co"), + migrations.RunSQL("ALTER TABLE vpn_l2vpntermination RENAME CONSTRAINT ipam_l2vpntermination_l2vpn_id_9e570aa1_fk_ipam_l2vpn_id TO vpn_l2vpntermination_l2vpn_id_f5367bbe_fk_vpn_l2vpn_id"), + + # Rename ipam_l2vpn_termination_* sequences + migrations.RunSQL("ALTER TABLE ipam_l2vpntermination_id_seq RENAME TO vpn_l2vpntermination_id_seq"), + + # Rename ipam_l2vpn_* indexes + migrations.RunSQL("ALTER INDEX ipam_l2vpntermination_pkey RENAME TO vpn_l2vpntermination_pkey"), + migrations.RunSQL("ALTER INDEX ipam_l2vpntermination_assigned_object_type_id_3923c124 RENAME TO vpn_l2vpntermination_assigned_object_type_id_f063b865"), + migrations.RunSQL("ALTER INDEX ipam_l2vpntermination_l2vpn_id_9e570aa1 RENAME TO vpn_l2vpntermination_l2vpn_id_f5367bbe"), + + ] From bea32aef71d69a188acdbfa095e18e1c62d40a70 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Mar 2024 12:08:11 -0400 Subject: [PATCH 31/56] Declare FilterSet fields as a tuple --- netbox/circuits/filtersets.py | 8 +++---- netbox/dcim/filtersets.py | 34 ++++++++++++++--------------- netbox/extras/filtersets.py | 8 +++---- netbox/ipam/filtersets.py | 20 ++++++++--------- netbox/tenancy/filtersets.py | 12 +++++----- netbox/virtualization/filtersets.py | 8 +++---- netbox/vpn/filtersets.py | 16 +++++++------- netbox/wireless/filtersets.py | 6 ++--- 8 files changed, 56 insertions(+), 56 deletions(-) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 13fa0a15c..cbf1fb82d 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -67,7 +67,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): class Meta: model = Provider - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -95,7 +95,7 @@ class ProviderAccountFilterSet(NetBoxModelFilterSet): class Meta: model = ProviderAccount - fields = ['id', 'name', 'account', 'description'] + fields = ('id', 'name', 'account', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -122,7 +122,7 @@ class ProviderNetworkFilterSet(NetBoxModelFilterSet): class Meta: model = ProviderNetwork - fields = ['id', 'name', 'service_id', 'description'] + fields = ('id', 'name', 'service_id', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -139,7 +139,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet): class Meta: model = CircuitType - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ('id', 'name', 'slug', 'color', 'description') class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 51e478e65..c4cf7b55a 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -106,7 +106,7 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): class Meta: model = Region - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): @@ -136,7 +136,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): class Meta: model = SiteGroup - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): @@ -270,7 +270,7 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM class Meta: model = Location - fields = ['id', 'name', 'slug', 'status', 'description'] + fields = ('id', 'name', 'slug', 'status', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -285,7 +285,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = RackRole - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ('id', 'name', 'slug', 'color', 'description') class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): @@ -364,10 +364,10 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe class Meta: model = Rack - fields = [ + fields = ( 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', - ] + ) def search(self, queryset, name, value): if not value.strip(): @@ -471,7 +471,7 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet) class Meta: model = Manufacturer - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class DeviceTypeFilterSet(NetBoxModelFilterSet): @@ -651,7 +651,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet): class Meta: model = ModuleType - fields = ['id', 'model', 'part_number', 'weight', 'weight_unit', 'description'] + fields = ('id', 'model', 'part_number', 'weight', 'weight_unit', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -849,7 +849,7 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo class Meta: model = InventoryItemTemplate - fields = ['id', 'name', 'label', 'part_id', 'description'] + fields = ('id', 'name', 'label', 'part_id', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -870,7 +870,7 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = DeviceRole - fields = ['id', 'name', 'slug', 'color', 'vm_role', 'description'] + fields = ('id', 'name', 'slug', 'color', 'vm_role', 'description') class PlatformFilterSet(OrganizationalModelFilterSet): @@ -896,7 +896,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet): class Meta: model = Platform - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') @extend_schema_field(OpenApiTypes.STR) def get_for_device_type(self, queryset, name, value): @@ -1800,7 +1800,7 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = InventoryItemRole - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ('id', 'name', 'slug', 'color', 'description') class VirtualChassisFilterSet(NetBoxModelFilterSet): @@ -1970,7 +1970,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): class Meta: model = Cable - fields = ['id', 'label', 'length', 'length_unit', 'description'] + fields = ('id', 'label', 'length', 'length_unit', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -2102,7 +2102,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): class Meta: model = PowerPanel - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -2230,18 +2230,18 @@ class ConsoleConnectionFilterSet(ConnectionFilterSet): class Meta: model = ConsolePort - fields = ['name'] + fields = ('name',) class PowerConnectionFilterSet(ConnectionFilterSet): class Meta: model = PowerPort - fields = ['name'] + fields = ('name',) class InterfaceConnectionFilterSet(ConnectionFilterSet): class Meta: model = Interface - fields = [] + fields = tuple() diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 1ab6679e2..4674335c9 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -333,7 +333,7 @@ class BookmarkFilterSet(BaseFilterSet): class Meta: model = Bookmark - fields = ['id', 'object_id'] + fields = ('id', 'object_id') class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet): @@ -375,7 +375,7 @@ class JournalEntryFilterSet(NetBoxModelFilterSet): class Meta: model = JournalEntry - fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created', 'kind'] + fields = ('id', 'assigned_object_type_id', 'assigned_object_id', 'created', 'kind') def search(self, queryset, name, value): if not value.strip(): @@ -400,7 +400,7 @@ class TagFilterSet(ChangeLoggedModelFilterSet): class Meta: model = Tag - fields = ['id', 'name', 'slug', 'color', 'description', 'object_types'] + fields = ('id', 'name', 'slug', 'color', 'description', 'object_types') def search(self, queryset, name, value): if not value.strip(): @@ -697,7 +697,7 @@ class ObjectTypeFilterSet(django_filters.FilterSet): class Meta: model = ObjectType - fields = ['id', 'app_label', 'model'] + fields = ('id', 'app_label', 'model') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 23da04566..d58f5bfc9 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -76,7 +76,7 @@ class VRFFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = VRF - fields = ['id', 'name', 'rd', 'enforce_unique', 'description'] + fields = ('id', 'name', 'rd', 'enforce_unique', 'description') class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet): @@ -135,14 +135,14 @@ class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = RouteTarget - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'description') class RIRFilterSet(OrganizationalModelFilterSet): class Meta: model = RIR - fields = ['id', 'name', 'slug', 'is_private', 'description'] + fields = ('id', 'name', 'slug', 'is_private', 'description') class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): @@ -167,7 +167,7 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = Aggregate - fields = ['id', 'date_added', 'description'] + fields = ('id', 'date_added', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -251,7 +251,7 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): class Meta: model = ASN - fields = ['id', 'asn', 'description'] + fields = ('id', 'asn', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -393,7 +393,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = Prefix - fields = ['id', 'is_pool', 'mark_utilized', 'description'] + fields = ('id', 'is_pool', 'mark_utilized', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -802,7 +802,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet): class Meta: model = FHRPGroup - fields = ['id', 'group_id', 'name', 'auth_key', 'description'] + fields = ('id', 'group_id', 'name', 'auth_key', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -863,7 +863,7 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): class Meta: model = FHRPGroupAssignment - fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority'] + fields = ('id', 'group_id', 'interface_type', 'interface_id', 'priority') def filter_device(self, queryset, name, value): devices = Device.objects.filter(**{f'{name}__in': value}) @@ -918,7 +918,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): class Meta: model = VLANGroup - fields = ['id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id'] + fields = ('id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id') def search(self, queryset, name, value): if not value.strip(): @@ -1024,7 +1024,7 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = VLAN - fields = ['id', 'vid', 'name', 'description'] + fields = ('id', 'vid', 'name', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 7af3dc082..a7c52d3fb 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -50,14 +50,14 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): class Meta: model = ContactGroup - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class ContactRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = ContactRole - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class ContactFilterSet(NetBoxModelFilterSet): @@ -77,7 +77,7 @@ class ContactFilterSet(NetBoxModelFilterSet): class Meta: model = Contact - fields = ['id', 'name', 'title', 'phone', 'email', 'address', 'link', 'description'] + fields = ('id', 'name', 'title', 'phone', 'email', 'address', 'link', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -131,7 +131,7 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet): class Meta: model = ContactAssignment - fields = ['id', 'object_type_id', 'object_id', 'priority', 'tag'] + fields = ('id', 'object_type_id', 'object_id', 'priority', 'tag') def search(self, queryset, name, value): if not value.strip(): @@ -192,7 +192,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): class Meta: model = TenantGroup - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): @@ -212,7 +212,7 @@ class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): class Meta: model = Tenant - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index c36075c44..55fadd1af 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -27,14 +27,14 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet): class Meta: model = ClusterType - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): class Meta: model = ClusterGroup - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): @@ -101,7 +101,7 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte class Meta: model = Cluster - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -325,7 +325,7 @@ class VirtualDiskFilterSet(NetBoxModelFilterSet): class Meta: model = VirtualDisk - fields = ['id', 'name', 'size', 'description'] + fields = ('id', 'name', 'size', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 3c23cb478..970f68795 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -29,7 +29,7 @@ class TunnelGroupFilterSet(OrganizationalModelFilterSet): class Meta: model = TunnelGroup - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet): @@ -62,7 +62,7 @@ class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = Tunnel - fields = ['id', 'name', 'tunnel_id', 'description'] + fields = ('id', 'name', 'tunnel_id', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -150,7 +150,7 @@ class IKEProposalFilterSet(NetBoxModelFilterSet): class Meta: model = IKEProposal - fields = ['id', 'name', 'sa_lifetime', 'description'] + fields = ('id', 'name', 'sa_lifetime', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -185,7 +185,7 @@ class IKEPolicyFilterSet(NetBoxModelFilterSet): class Meta: model = IKEPolicy - fields = ['id', 'name', 'preshared_key', 'description'] + fields = ('id', 'name', 'preshared_key', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -218,7 +218,7 @@ class IPSecProposalFilterSet(NetBoxModelFilterSet): class Meta: model = IPSecProposal - fields = ['id', 'name', 'sa_lifetime_seconds', 'sa_lifetime_data', 'description'] + fields = ('id', 'name', 'sa_lifetime_seconds', 'sa_lifetime_data', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -250,7 +250,7 @@ class IPSecPolicyFilterSet(NetBoxModelFilterSet): class Meta: model = IPSecPolicy - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -289,7 +289,7 @@ class IPSecProfileFilterSet(NetBoxModelFilterSet): class Meta: model = IPSecProfile - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -331,7 +331,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = L2VPN - fields = ['id', 'identifier', 'name', 'slug', 'type', 'description'] + fields = ('id', 'identifier', 'name', 'slug', 'type', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 8415f191d..da66df144 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -40,7 +40,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): class Meta: model = WirelessLANGroup - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): @@ -74,7 +74,7 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = WirelessLAN - fields = ['id', 'ssid', 'auth_psk', 'description'] + fields = ('id', 'ssid', 'auth_psk', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -105,7 +105,7 @@ class WirelessLinkFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = WirelessLink - fields = ['id', 'ssid', 'auth_psk', 'description'] + fields = ('id', 'ssid', 'auth_psk', 'description') def search(self, queryset, name, value): if not value.strip(): From 572efeb987b6b22ac0aaddeb481f60f4258a03d4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Mar 2024 12:14:13 -0400 Subject: [PATCH 32/56] Ensure all filter labels are translated --- netbox/dcim/filtersets.py | 18 +++++++++--------- netbox/dcim/forms/filtersets.py | 2 +- netbox/ipam/forms/filtersets.py | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index c4cf7b55a..aa8a68296 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1180,24 +1180,24 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, Prim device_id = django_filters.ModelMultipleChoiceFilter( field_name='device', queryset=Device.objects.all(), - label='VDC (ID)', + label=_('VDC (ID)') ) device = django_filters.ModelMultipleChoiceFilter( field_name='device', queryset=Device.objects.all(), - label='Device model', + label=_('Device model') ) interface_id = django_filters.ModelMultipleChoiceFilter( field_name='interfaces', queryset=Interface.objects.all(), - label='Interface (ID)', + label=_('Interface (ID)') ) status = django_filters.MultipleChoiceFilter( choices=VirtualDeviceContextStatusChoices ) has_primary_ip = django_filters.BooleanFilter( method='_has_primary_ip', - label='Has a primary IP', + label=_('Has a primary IP') ) class Meta: @@ -1632,28 +1632,28 @@ class InterfaceFilterSet( vdc_id = django_filters.ModelMultipleChoiceFilter( field_name='vdcs', queryset=VirtualDeviceContext.objects.all(), - label='Virtual Device Context', + label=_('Virtual Device Context') ) vdc_identifier = django_filters.ModelMultipleChoiceFilter( field_name='vdcs__identifier', queryset=VirtualDeviceContext.objects.all(), to_field_name='identifier', - label='Virtual Device Context (Identifier)', + label=_('Virtual Device Context (Identifier)') ) vdc = django_filters.ModelMultipleChoiceFilter( field_name='vdcs__name', queryset=VirtualDeviceContext.objects.all(), to_field_name='name', - label='Virtual Device Context', + label=_('Virtual Device Context') ) wireless_lan_id = django_filters.ModelMultipleChoiceFilter( field_name='wireless_lans', queryset=WirelessLAN.objects.all(), - label='Wireless LAN', + label=_('Wireless LAN') ) wireless_link_id = django_filters.ModelMultipleChoiceFilter( queryset=WirelessLink.objects.all(), - label='Wireless link', + label=_('Wireless link') ) class Meta: diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 89793528d..e35055851 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -754,7 +754,7 @@ class DeviceFilterForm( ) has_oob_ip = forms.NullBooleanField( required=False, - label='Has an OOB IP', + label=_('Has an OOB IP'), widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 909de886f..cf2e4d46e 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -304,7 +304,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): 'placeholder': 'Prefix', } ), - label='Parent Prefix' + label=_('Parent Prefix') ) family = forms.ChoiceField( required=False, From 8fe3f5e3fd62e88f7c473c95f4036c97a663f0e1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Mar 2024 10:38:21 -0400 Subject: [PATCH 33/56] Closes #14366: Enable custom links on ConfigContexts and ConfigTemplates --- netbox/extras/models/configs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 425c1386a..910803e16 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -11,7 +11,7 @@ from extras.querysets import ConfigContextQuerySet from netbox.config import get_config from netbox.registry import registry from netbox.models import ChangeLoggedModel -from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin +from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin from utilities.jinja2 import ConfigTemplateLoader from utilities.utils import deepmerge @@ -26,7 +26,7 @@ __all__ = ( # Config contexts # -class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel): +class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel): """ A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B @@ -210,7 +210,7 @@ class ConfigContextModel(models.Model): # Config templates # -class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): +class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): name = models.CharField( verbose_name=_('name'), max_length=100 From 7357f953eba60b1ebd54ef498c0dc390f2389cc9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Mar 2024 16:13:06 -0400 Subject: [PATCH 34/56] Closes #15413: Enable caching of object attributes in search index --- netbox/netbox/search/__init__.py | 27 ++++++++++++++++++++++++++- netbox/netbox/tables/tables.py | 8 +++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/netbox/netbox/search/__init__.py b/netbox/netbox/search/__init__.py index 590188f21..76898be13 100644 --- a/netbox/netbox/search/__init__.py +++ b/netbox/netbox/search/__init__.py @@ -1,6 +1,9 @@ from collections import namedtuple +from decimal import Decimal +from django.core.exceptions import FieldDoesNotExist from django.db import models +from netaddr import IPAddress, IPNetwork from ipam.fields import IPAddressField, IPNetworkField from netbox.registry import registry @@ -56,6 +59,24 @@ class SearchIndex: return FieldTypes.INTEGER return FieldTypes.STRING + @staticmethod + def get_attr_type(instance, field_name): + """ + Return the data type of the specified object attribute. + """ + value = getattr(instance, field_name) + if type(value) is str: + return FieldTypes.STRING + if type(value) is int: + return FieldTypes.INTEGER + if type(value) in (float, Decimal): + return FieldTypes.FLOAT + if type(value) is IPNetwork: + return FieldTypes.CIDR + if type(value) is IPAddress: + return FieldTypes.INET + return FieldTypes.STRING + @staticmethod def get_field_value(instance, field_name): """ @@ -82,7 +103,11 @@ class SearchIndex: # Capture built-in fields for name, weight in cls.fields: - type_ = cls.get_field_type(instance, name) + try: + type_ = cls.get_field_type(instance, name) + except FieldDoesNotExist: + # Not a concrete field; handle as an object attribute + type_ = cls.get_attr_type(instance, name) value = cls.get_field_value(instance, name) if type_ and value: values.append( diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index afef74752..31502f6c5 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -263,9 +263,11 @@ class SearchTable(tables.Table): super().__init__(data, **kwargs) def render_field(self, value, record): - if hasattr(record.object, value): - return title(record.object._meta.get_field(value).verbose_name) - return value + try: + model_field = record.object._meta.get_field(value) + return title(model_field.verbose_name) + except FieldDoesNotExist: + return value def render_value(self, value): if not self.highlight: From 7350950e8874bff4f3657596891216cde01010af Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Mar 2024 15:11:28 -0400 Subject: [PATCH 35/56] Fixes #15347: Fix querying virtual machine contacts via GraphQL --- netbox/extras/graphql/mixins.py | 1 + netbox/virtualization/graphql/types.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/extras/graphql/mixins.py b/netbox/extras/graphql/mixins.py index 7045575fb..68fba5ee6 100644 --- a/netbox/extras/graphql/mixins.py +++ b/netbox/extras/graphql/mixins.py @@ -7,6 +7,7 @@ from extras.models import ObjectChange __all__ = ( 'ChangelogMixin', 'ConfigContextMixin', + 'ContactsMixin', 'CustomFieldsMixin', 'ImageAttachmentsMixin', 'JournalEntriesMixin', diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 9b97e1dc9..30429be69 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -1,5 +1,5 @@ from dcim.graphql.types import ComponentObjectType -from extras.graphql.mixins import ConfigContextMixin +from extras.graphql.mixins import ConfigContextMixin, ContactsMixin from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType from virtualization import filtersets, models @@ -38,7 +38,7 @@ class ClusterTypeType(OrganizationalObjectType): filterset_class = filtersets.ClusterTypeFilterSet -class VirtualMachineType(ConfigContextMixin, NetBoxObjectType): +class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType): class Meta: model = models.VirtualMachine From 7ac21690e5c04a9b163f507a70c2a6929a2a0a4d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Mar 2024 15:05:04 -0400 Subject: [PATCH 36/56] Fixes #15356: Fix assignment of front & rear images to device types via REST API --- netbox/dcim/api/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ab3177de5..053b3e9ea 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -326,8 +326,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer): airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True) weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) - front_image = serializers.URLField(allow_null=True, required=False) - rear_image = serializers.URLField(allow_null=True, required=False) + front_image = serializers.ImageField(required=False, allow_null=True) + rear_image = serializers.ImageField(required=False, allow_null=True) # Counter fields console_port_template_count = serializers.IntegerField(read_only=True) From 8bdbb49a276b569b6ea6444dbecda32e3205a3be Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Mar 2024 15:18:25 -0400 Subject: [PATCH 37/56] Fixes #15322: Add description field to YAML export for device & module types --- netbox/dcim/models/devices.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index f9e8ba213..2a1269a8d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -229,15 +229,16 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): 'manufacturer': self.manufacturer.name, 'model': self.model, 'slug': self.slug, + 'description': self.description, 'default_platform': self.default_platform.name if self.default_platform else None, 'part_number': self.part_number, 'u_height': float(self.u_height), 'is_full_depth': self.is_full_depth, 'subdevice_role': self.subdevice_role, 'airflow': self.airflow, - 'comments': self.comments, 'weight': float(self.weight) if self.weight is not None else None, 'weight_unit': self.weight_unit, + 'comments': self.comments, } # Component templates @@ -415,9 +416,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): 'manufacturer': self.manufacturer.name, 'model': self.model, 'part_number': self.part_number, - 'comments': self.comments, + 'description': self.description, 'weight': float(self.weight) if self.weight is not None else None, 'weight_unit': self.weight_unit, + 'comments': self.comments, } # Component templates From 9062d99bfa57d80c3a1dfc452180b7a46a773546 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Mar 2024 16:32:55 -0400 Subject: [PATCH 38/56] Closes #14454: Include member devices for virtual chassis in REST API --- netbox/dcim/api/serializers_/virtualchassis.py | 3 ++- netbox/dcim/api/views.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/api/serializers_/virtualchassis.py b/netbox/dcim/api/serializers_/virtualchassis.py index 570abfc7d..5a5917119 100644 --- a/netbox/dcim/api/serializers_/virtualchassis.py +++ b/netbox/dcim/api/serializers_/virtualchassis.py @@ -12,6 +12,7 @@ __all__ = ( class VirtualChassisSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') master = NestedDeviceSerializer(required=False, allow_null=True, default=None) + members = NestedDeviceSerializer(many=True, read_only=True) # Counter fields member_count = serializers.IntegerField(read_only=True) @@ -20,6 +21,6 @@ class VirtualChassisSerializer(NetBoxModelSerializer): model = VirtualChassis fields = [ 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', 'member_count', + 'created', 'last_updated', 'member_count', 'members', ] brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count') diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 668af28da..d6ddd466b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -511,7 +511,10 @@ class CableTerminationViewSet(NetBoxModelViewSet): # class VirtualChassisViewSet(NetBoxModelViewSet): - queryset = VirtualChassis.objects.all() + queryset = VirtualChassis.objects.prefetch_related( + # Prefetch related object for the display of unnamed devices + 'master__virtual_chassis', + ) serializer_class = serializers.VirtualChassisSerializer filterset_class = filtersets.VirtualChassisFilterSet From df7905d257e4a3f798a50a662930f05933be6d5e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Mar 2024 19:15:35 -0400 Subject: [PATCH 39/56] Changelog for #13722, #14206, #14366, #14832, #15322, #15347, #15356 --- docs/release-notes/version-3.7.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 17a3bf9cc..db6e697f0 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -4,17 +4,24 @@ ### Enhancements +* [#14206](https://github.com/netbox-community/netbox/issues/14206) - Add additional FibreChannel SFP+ interface types +* [#14366](https://github.com/netbox-community/netbox/issues/14366) - Enable custom links for config contexts & templates * [#15291](https://github.com/netbox-community/netbox/issues/15291) - Add tunnel termination buttons to VM interfaces table * [#15297](https://github.com/netbox-community/netbox/issues/15297) - Linkify platform column in device & virtual machine tables ### Bug Fixes +* [#13722](https://github.com/netbox-community/netbox/issues/13722) - Fix range expansion for comma-separated numerical values +* [#14832](https://github.com/netbox-community/netbox/issues/14832) - Enable querying IP addresses for an FHRP group via GraphQL * [#15220](https://github.com/netbox-community/netbox/issues/15220) - Fix validation check when bulk editing the mask length of IP addresses * [#15232](https://github.com/netbox-community/netbox/issues/15232) - Permit user with sufficient permissions to assign an inventory item to a device type * [#15241](https://github.com/netbox-community/netbox/issues/15241) - Restore missing `display` field on VirtualDisk serialization in REST API * [#15243](https://github.com/netbox-community/netbox/issues/15243) - Correct representation of installed module when listing module bays using REST API brief mode * [#15316](https://github.com/netbox-community/netbox/issues/15316) - Fix selection of 3DES encryption for IKE & IPSec proposals +* [#15322](https://github.com/netbox-community/netbox/issues/15322) - Add description field to YAML export for device & module types * [#15336](https://github.com/netbox-community/netbox/issues/15336) - Correct label for recurring scheduled jobs +* [#15347](https://github.com/netbox-community/netbox/issues/15347) - Fix querying virtual machine contacts via GraphQL +* [#15356](https://github.com/netbox-community/netbox/issues/15356) - Fix assignment of front & rear images to device types via REST API --- From 06bdfdc9e899ac28f1b61df2a647df1436c41596 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Mar 2024 19:23:51 -0400 Subject: [PATCH 40/56] Release v3.7.4 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- base_requirements.txt | 2 +- contrib/generated_schema.json | 3 +++ docs/release-notes/version-3.7.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 10 +++++----- 7 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 1776f6cf4..612d01d89 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -26,7 +26,7 @@ body: attributes: label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v3.7.3 + placeholder: v3.7.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index bc99999c0..8eb47180d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.7.3 + placeholder: v3.7.4 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index de885eeb2..642450cf8 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -101,7 +101,7 @@ markdown-include mkdocs-material # Introspection for embedded code -# https://github.com/mkdocstrings/mkdocstrings/blob/master/CHANGELOG.md +# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md mkdocstrings[python-legacy] # Library for manipulating IP prefixes and addresses diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json index 5e8507798..1164f2e48 100644 --- a/contrib/generated_schema.json +++ b/contrib/generated_schema.json @@ -384,7 +384,10 @@ "8gfc-sfpp", "16gfc-sfpp", "32gfc-sfp28", + "32gfc-sfpp", "64gfc-qsfpp", + "64gfc-sfpdd", + "64gfc-sfpp", "128gfc-qsfp28", "infiniband-sdr", "infiniband-ddr", diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index db6e697f0..fd61218e5 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -1,6 +1,6 @@ # NetBox v3.7 -## v3.7.4 (FUTURE) +## v3.7.4 (2024-03-13) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 52b085b33..007164a6d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig # Environment setup # -VERSION = '3.7.4-dev' +VERSION = '3.7.4' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 5218657f2..39922c9b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ bleach==6.1.0 -Django==4.2.10 +Django==4.2.11 django-cors-headers==4.3.1 django-debug-toolbar==4.3.0 -django-filter==23.5 +django-filter==24.1 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.14.0 django-pglocks==1.0.4 @@ -15,14 +15,14 @@ django-tables2==2.7.0 django-timezone-field==6.1.0 djangorestframework==3.14.0 drf-spectacular==0.27.1 -drf-spectacular-sidecar==2024.2.1 +drf-spectacular-sidecar==2024.3.4 feedparser==6.0.11 graphene-django==3.0.0 gunicorn==21.2.0 Jinja2==3.1.3 Markdown==3.5.2 -mkdocs-material==9.5.10 -mkdocstrings[python-legacy]==0.24.0 +mkdocs-material==9.5.13 +mkdocstrings[python-legacy]==0.24.1 netaddr==1.2.1 Pillow==10.2.0 psycopg[binary,pool]==3.1.18 From 4adb44f60d91fec19c800ea0e03800e2eb338f06 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Mar 2024 19:37:28 -0400 Subject: [PATCH 41/56] PRVB --- docs/release-notes/version-3.7.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index fd61218e5..9724c4488 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -1,5 +1,9 @@ # NetBox v3.7 +## v3.7.5 (FUTURE) + +--- + ## v3.7.4 (2024-03-13) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 007164a6d..e662561ee 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig # Environment setup # -VERSION = '3.7.4' +VERSION = '3.7.5-dev' # Hostname HOSTNAME = platform.node() From 19f577ccaf13685aaac2a493c878f8d5549c94c4 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 18 Mar 2024 09:09:50 -0500 Subject: [PATCH 42/56] Closes: #13918 - Add facility field (#15456) * Fixes: #13918 - Add facilities field to Location model. * Stupidly forgot to `git add` * Fix errant reference to site. * Misc cleanup --------- Co-authored-by: Jeremy Stretch --- docs/models/dcim/location.md | 4 ++++ netbox/dcim/api/serializers_/sites.py | 4 ++-- netbox/dcim/filtersets.py | 3 ++- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/model_forms.py | 4 ++-- .../dcim/migrations/0186_location_facility.py | 18 ++++++++++++++++++ netbox/dcim/models/sites.py | 8 +++++++- netbox/dcim/search.py | 3 ++- netbox/dcim/tables/sites.py | 8 +++++--- netbox/dcim/tests/test_filtersets.py | 10 +++++++--- netbox/dcim/tests/test_views.py | 1 + netbox/templates/dcim/location.html | 4 ++++ 12 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 netbox/dcim/migrations/0186_location_facility.py diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md index 96ab13039..cf957ca5b 100644 --- a/docs/models/dcim/location.md +++ b/docs/models/dcim/location.md @@ -26,3 +26,7 @@ The location's operational status. !!! tip Additional statuses may be defined by setting `Location.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Facility + +Data center or facility designation for identifying the location. diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index 6fb3811ba..8063278a7 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -92,7 +92,7 @@ class LocationSerializer(NestedGroupModelSerializer): class Meta: model = Location fields = [ - 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility', 'description', + 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index aa8a68296..2ff9f49ae 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -270,13 +270,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM class Meta: model = Location - fields = ('id', 'name', 'slug', 'status', 'description') + fields = ('id', 'name', 'slug', 'status', 'facility', 'description') def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( Q(name__icontains=value) | + Q(facility__icontains=value) | Q(description__icontains=value) ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 47974096f..d49973082 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -157,7 +157,7 @@ class LocationImportForm(NetBoxModelImportForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags') + fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description', 'tags') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 6c33ea8d6..92740ec45 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -179,14 +179,14 @@ class LocationForm(TenancyForm, NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Location'), ('site', 'parent', 'name', 'slug', 'status', 'description', 'tags')), + (_('Location'), ('site', 'parent', 'name', 'slug', 'status', 'facility', 'description', 'tags')), (_('Tenancy'), ('tenant_group', 'tenant')), ) class Meta: model = Location fields = ( - 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'tags', + 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'facility', 'tags', ) diff --git a/netbox/dcim/migrations/0186_location_facility.py b/netbox/dcim/migrations/0186_location_facility.py new file mode 100644 index 000000000..759ee813b --- /dev/null +++ b/netbox/dcim/migrations/0186_location_facility.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.4 on 2024-03-17 02:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0185_gfk_indexes'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='facility', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index d2797bf95..c1da807ad 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -275,6 +275,12 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel): blank=True, null=True ) + facility = models.CharField( + verbose_name=_('facility'), + max_length=50, + blank=True, + help_text=_('Local facility ID or description') + ) # Generic relations vlan_groups = GenericRelation( @@ -284,7 +290,7 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel): related_query_name='location' ) - clone_fields = ('site', 'parent', 'status', 'tenant', 'description') + clone_fields = ('site', 'parent', 'status', 'tenant', 'facility', 'description') prerequisite_models = ( 'dcim.Site', ) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index 18cf75a9a..b349bcac0 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -132,10 +132,11 @@ class LocationIndex(SearchIndex): model = models.Location fields = ( ('name', 100), + ('facility', 100), ('slug', 110), ('description', 500), ) - display_attrs = ('site', 'status', 'tenant', 'description') + display_attrs = ('site', 'status', 'tenant', 'facility', 'description') @register_search diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index a0a71ab30..e179ec43a 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -152,7 +152,9 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Location fields = ( - 'pk', 'id', 'name', 'site', 'status', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description', - 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count', + 'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description' ) - default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 1e46d66ac..fffa82a10 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -359,9 +359,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): location.save() locations = ( - Location(name='Location 1A', slug='location-1a', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='foobar1'), - Location(name='Location 2A', slug='location-2a', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='foobar2'), - Location(name='Location 3A', slug='location-3a', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='foobar3'), + Location(name='Location 1A', slug='location-1a', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, facility='Facility 1', description='foobar1'), + Location(name='Location 2A', slug='location-2a', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, facility='Facility 2', description='foobar2'), + Location(name='Location 3A', slug='location-3a', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, facility='Facility 3', description='foobar3'), ) for location in locations: location.save() @@ -390,6 +390,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_facility(self): + params = {'facility': ['Facility 1', 'Facility 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index e9e5a557b..e3437cefc 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -213,6 +213,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'slug': 'location-x', 'site': site.pk, 'status': LocationStatusChoices.STATUS_PLANNED, + 'facility': 'Facility X', 'tenant': tenant.pk, 'description': 'A new location', 'tags': [t.pk for t in tags], diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index db387b164..9f2b766ea 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -54,6 +54,10 @@ {{ object.tenant|linkify|placeholder }} + + {% trans "Facility" %} + {{ object.facility|placeholder }} +
{% include 'inc/panels/tags.html' %} From 93c9f8cc041001dd7dc894e842e4063922730a30 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 18 Mar 2024 11:26:53 -0700 Subject: [PATCH 43/56] 15193 use psycopg compiled --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 07add11a2..72b086912 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,7 @@ mkdocstrings[python-legacy]==0.24.1 netaddr==1.2.1 nh3==0.2.15 Pillow==10.2.0 -psycopg[binary,pool]==3.1.18 +psycopg[c,pool]==3.1.18 PyYAML==6.0.1 requests==2.31.0 social-auth-app-django==5.4.0 From f585c36d86bb4fe218c064e924edaa7013300e5a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Mar 2024 15:44:35 -0400 Subject: [PATCH 44/56] Introduce InlineFields for rendering fields side-by-side --- netbox/dcim/forms/model_forms.py | 17 ++++ netbox/dcim/views.py | 1 - netbox/templates/dcim/rack_edit.html | 90 ------------------- netbox/templates/htmx/form.html | 17 +--- netbox/utilities/forms/rendering.py | 10 +++ .../form_helpers/render_fieldset.html | 26 ++++++ netbox/utilities/templatetags/form_helpers.py | 24 +++++ 7 files changed, 79 insertions(+), 106 deletions(-) delete mode 100644 netbox/templates/dcim/rack_edit.html create mode 100644 netbox/utilities/forms/rendering.py create mode 100644 netbox/utilities/templates/form_helpers/render_fieldset.html diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 92740ec45..44c3bb40a 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -16,6 +16,7 @@ from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField, ) +from utilities.forms.rendering import InlineFields from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK from virtualization.models import Cluster from wireless.models import WirelessLAN, WirelessLANGroup @@ -227,6 +228,22 @@ class RackForm(TenancyForm, NetBoxModelForm): ) comments = CommentField() + fieldsets = ( + (_('Rack'), ('site', 'location', 'name', 'status', 'role', 'description', 'tags')), + (_('Inventory Control'), ('facility_id', 'serial', 'asset_tag')), + (_('Tenancy'), ('tenant_group', 'tenant')), + (_('Dimensions'), ( + 'type', + 'width', + 'starting_unit', + 'u_height', + InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), + 'mounting_depth', + 'desc_units', + )), + ) + class Meta: model = Rack fields = [ diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 93e5f04dc..b447ae579 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -727,7 +727,6 @@ class RackNonRackedView(generic.ObjectChildrenView): class RackEditView(generic.ObjectEditView): queryset = Rack.objects.all() form = forms.RackForm - template_name = 'dcim/rack_edit.html' @register_model_view(Rack, 'delete') diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html deleted file mode 100644 index 21bc8303d..000000000 --- a/netbox/templates/dcim/rack_edit.html +++ /dev/null @@ -1,90 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} -{% load i18n %} - -{% block form %} -
-
-
{% trans "Rack" %}
-
- {% render_field form.site %} - {% render_field form.location %} - {% render_field form.name %} - {% render_field form.status %} - {% render_field form.role %} - {% render_field form.description %} - {% render_field form.tags %} -
- -
-
-
{% trans "Inventory Control" %}
-
- {% render_field form.facility_id %} - {% render_field form.serial %} - {% render_field form.asset_tag %} -
- -
-
-
{% trans "Tenancy" %}
-
- {% render_field form.tenant_group %} - {% render_field form.tenant %} -
- -
-
-
{% trans "Dimensions" %}
-
- {% render_field form.type %} - {% render_field form.width %} - {% render_field form.starting_unit %} - {% render_field form.u_height %} -
- -
- {{ form.outer_width }} -
{% trans "Width" %}
-
-
- {{ form.outer_depth }} -
{% trans "Depth" %}
-
-
- {{ form.outer_unit }} -
{% trans "Unit" %}
-
-
-
- -
- {{ form.weight }} -
{% trans "Weight" %}
-
-
- {{ form.max_weight }} -
{% trans "Maximum Weight" %}
-
-
- {{ form.weight_unit }} -
{% trans "Unit" %}
-
-
- {% render_field form.mounting_depth %} - {% render_field form.desc_units %} -
- - {% if form.custom_fields %} -
-
-
{% trans "Custom Fields" %}
-
- {% render_custom_fields form %} -
- {% endif %} - -
- {% render_field form.comments %} -
-{% endblock %} diff --git a/netbox/templates/htmx/form.html b/netbox/templates/htmx/form.html index 3aafc2a21..0bfcb00ca 100644 --- a/netbox/templates/htmx/form.html +++ b/netbox/templates/htmx/form.html @@ -9,21 +9,8 @@ {% endfor %} {# Render grouped fields according to Form #} - {% for group, fields in form.fieldsets %} -
- {% if group %} -
-
{{ group }}
-
- {% endif %} - {% for name in fields %} - {% with field=form|getfield:name %} - {% if field and not field.field.widget.is_hidden %} - {% render_field field %} - {% endif %} - {% endwith %} - {% endfor %} -
+ {% for group, items in form.fieldsets %} + {% render_fieldset form items heading=group %} {% endfor %} {% if form.custom_fields %} diff --git a/netbox/utilities/forms/rendering.py b/netbox/utilities/forms/rendering.py new file mode 100644 index 000000000..498b1a2ce --- /dev/null +++ b/netbox/utilities/forms/rendering.py @@ -0,0 +1,10 @@ +__all__ = ( + 'InlineFields', +) + + +class InlineFields: + + def __init__(self, *field_names, label=None): + self.field_names = field_names + self.label = label diff --git a/netbox/utilities/templates/form_helpers/render_fieldset.html b/netbox/utilities/templates/form_helpers/render_fieldset.html new file mode 100644 index 000000000..718a8f6a0 --- /dev/null +++ b/netbox/utilities/templates/form_helpers/render_fieldset.html @@ -0,0 +1,26 @@ +{% load i18n %} +{% load form_helpers %} +
+ {% if heading %} +
+
{{ heading }}
+
+ {% endif %} + {% for layout, title, items in rows %} + {% if layout == 'field' %} + {# Single form field #} + {% render_field items.0 %} + {% elif layout == 'inline' %} + {# Multiple form fields on the same line #} +
+ + {% for field in items %} +
+ {{ field }} +
{% trans field.label %}
+
+ {% endfor %} +
+ {% endif %} + {% endfor %} +
diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index f4fd8b819..3f60627b4 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -1,5 +1,7 @@ from django import template +from utilities.forms.rendering import InlineFields + __all__ = ( 'getfield', 'render_custom_fields', @@ -45,6 +47,28 @@ def widget_type(field): # Inclusion tags # +@register.inclusion_tag('form_helpers/render_fieldset.html') +def render_fieldset(form, fieldset, heading=None): + """ + Render a group set of fields. + """ + rows = [] + for item in fieldset: + if type(item) is InlineFields: + rows.append( + ('inline', item.label, [form[name] for name in item.field_names]) + ) + else: + rows.append( + ('field', None, [form[item]]) + ) + + return { + 'heading': heading, + 'rows': rows, + } + + @register.inclusion_tag('form_helpers/render_field.html') def render_field(field, bulk_nullable=False, label=None): """ From 4c7b6fcec05f83eaf7fba65dc82dbe26527a5eaa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Mar 2024 17:02:26 -0400 Subject: [PATCH 45/56] Enable tabbed group fields in fieldsets --- netbox/dcim/forms/model_forms.py | 24 ++-- netbox/dcim/views.py | 2 - netbox/templates/dcim/inventoryitem_edit.html | 107 ------------------ netbox/utilities/forms/rendering.py | 37 +++++- .../form_helpers/render_fieldset.html | 26 +++++ netbox/utilities/templatetags/form_helpers.py | 17 ++- 6 files changed, 92 insertions(+), 121 deletions(-) delete mode 100644 netbox/templates/dcim/inventoryitem_edit.html diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 44c3bb40a..06f28b4e6 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -16,7 +16,7 @@ from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField, ) -from utilities.forms.rendering import InlineFields +from utilities.forms.rendering import InlineFields, TabbedFieldGroups from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK from virtualization.models import Cluster from wireless.models import WirelessLAN, WirelessLANGroup @@ -237,8 +237,8 @@ class RackForm(TenancyForm, NetBoxModelForm): 'width', 'starting_unit', 'u_height', - InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), - InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), + InlineFields(_('Outer Dimensions'), 'outer_width', 'outer_depth', 'outer_unit'), + InlineFields(_('Weight'), 'weight', 'max_weight', 'weight_unit'), 'mounting_depth', 'desc_units', )), @@ -1414,6 +1414,17 @@ class InventoryItemForm(DeviceComponentForm): fieldsets = ( (_('Inventory Item'), ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')), (_('Hardware'), ('manufacturer', 'part_id', 'serial', 'asset_tag')), + (_('Component Assignment'), ( + TabbedFieldGroups( + (_('Interface'), 'interface'), + (_('Console Port'), 'consoleport'), + (_('Console Server Port'), 'consoleserverport'), + (_('Front Port'), 'frontport'), + (_('Rear Port'), 'rearport'), + (_('Power Port'), 'powerport'), + (_('Power Outlet'), 'poweroutlet'), + ), + )) ) class Meta: @@ -1429,22 +1440,17 @@ class InventoryItemForm(DeviceComponentForm): component_type = initial.get('component_type') component_id = initial.get('component_id') - # Used for picking the default active tab for component selection - self.no_component = True - if instance: - # When editing set the initial value for component selectin + # When editing set the initial value for component selection for component_model in ContentType.objects.filter(MODULAR_COMPONENT_MODELS): if type(instance.component) is component_model.model_class(): initial[component_model.model] = instance.component - self.no_component = False break elif component_type and component_id: # When adding the InventoryItem from a component page if content_type := ContentType.objects.filter(MODULAR_COMPONENT_MODELS).filter(pk=component_type).first(): if component := content_type.model_class().objects.filter(pk=component_id).first(): initial[content_type.model] = component - self.no_component = False kwargs['initial'] = initial diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b447ae579..49bbe9be1 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2924,14 +2924,12 @@ class InventoryItemView(generic.ObjectView): class InventoryItemEditView(generic.ObjectEditView): queryset = InventoryItem.objects.all() form = forms.InventoryItemForm - template_name = 'dcim/inventoryitem_edit.html' class InventoryItemCreateView(generic.ComponentCreateView): queryset = InventoryItem.objects.all() form = forms.InventoryItemCreateForm model_form = forms.InventoryItemForm - template_name = 'dcim/inventoryitem_edit.html' @register_model_view(InventoryItem, 'delete') diff --git a/netbox/templates/dcim/inventoryitem_edit.html b/netbox/templates/dcim/inventoryitem_edit.html deleted file mode 100644 index 1dc46ddce..000000000 --- a/netbox/templates/dcim/inventoryitem_edit.html +++ /dev/null @@ -1,107 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load static %} -{% load form_helpers %} -{% load helpers %} -{% load i18n %} - -{% block form %} -
-
-
{% trans "Inventory Item" %}
-
- {% render_field form.device %} - {% render_field form.parent %} - {% render_field form.name %} - {% render_field form.label %} - {% render_field form.role %} - {% render_field form.description %} - {% render_field form.tags %} -
- -
-
-
{% trans "Hardware" %}
-
- {% render_field form.manufacturer %} - {% render_field form.part_id %} - {% render_field form.serial %} - {% render_field form.asset_tag %} -
- -
-
-
{% trans "Component Assignment" %}
-
-
- -
-
-
- {% render_field form.consoleport %} -
-
- {% render_field form.consoleserverport %} -
-
- {% render_field form.frontport %} -
-
- {% render_field form.interface %} -
-
- {% render_field form.poweroutlet %} -
-
- {% render_field form.powerport %} -
-
- {% render_field form.rearport %} -
-
-
- - {% if form.custom_fields %} -
-
-
{% trans "Custom Fields" %}
-
- {% render_custom_fields form %} -
- {% endif %} -{% endblock %} diff --git a/netbox/utilities/forms/rendering.py b/netbox/utilities/forms/rendering.py index 498b1a2ce..ad87930a9 100644 --- a/netbox/utilities/forms/rendering.py +++ b/netbox/utilities/forms/rendering.py @@ -1,10 +1,43 @@ +import random +import string +from functools import cached_property + __all__ = ( + 'FieldGroup', 'InlineFields', + 'TabbedFieldGroups', ) -class InlineFields: +class FieldGroup: - def __init__(self, *field_names, label=None): + def __init__(self, label, *field_names): self.field_names = field_names self.label = label + + +class InlineFields(FieldGroup): + pass + + +class TabbedFieldGroups: + + def __init__(self, *groups): + self.groups = [ + FieldGroup(*group) for group in groups + ] + + # Initialize a random ID for the group (for tab selection) + self.id = ''.join( + random.choice(string.ascii_lowercase + string.digits) for _ in range(8) + ) + + @cached_property + def tabs(self): + return [ + { + 'id': f'{self.id}_{i}', + 'title': group.label, + 'fields': group.field_names, + } for i, group in enumerate(self.groups, start=1) + ] diff --git a/netbox/utilities/templates/form_helpers/render_fieldset.html b/netbox/utilities/templates/form_helpers/render_fieldset.html index 718a8f6a0..ee1f50293 100644 --- a/netbox/utilities/templates/form_helpers/render_fieldset.html +++ b/netbox/utilities/templates/form_helpers/render_fieldset.html @@ -7,9 +7,11 @@
{% endif %} {% for layout, title, items in rows %} + {% if layout == 'field' %} {# Single form field #} {% render_field items.0 %} + {% elif layout == 'inline' %} {# Multiple form fields on the same line #}
@@ -21,6 +23,30 @@
{% endfor %} + + {% elif layout == 'tabs' %} + {# Tabbed groups of fields #} +
+ +
+
+ {% for tab in items %} +
+ {% for field in tab.fields %} + {% render_field field %} + {% endfor %} +
+ {% endfor %} +
+ {% endif %} {% endfor %} diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index 3f60627b4..47bbaafe8 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -1,6 +1,6 @@ from django import template -from utilities.forms.rendering import InlineFields +from utilities.forms.rendering import InlineFields, TabbedFieldGroups __all__ = ( 'getfield', @@ -58,6 +58,21 @@ def render_fieldset(form, fieldset, heading=None): rows.append( ('inline', item.label, [form[name] for name in item.field_names]) ) + elif type(item) is TabbedFieldGroups: + tabs = [ + { + 'id': tab['id'], + 'title': tab['title'], + 'active': bool(form.initial.get(tab['fields'][0], False)), + 'fields': [form[name] for name in tab['fields']] + } for tab in item.tabs + ] + # If none of the tabs has been marked as active, activate the first one + if not any(tab['active'] for tab in tabs): + tabs[0]['active'] = True + rows.append( + ('tabs', None, tabs) + ) else: rows.append( ('field', None, [form[item]]) From 33b9ebb2019f9f084b350d3a33ee9770612d4509 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Mar 2024 09:43:01 -0400 Subject: [PATCH 46/56] Ignore fields which are not included on the form (dynamic rendering) --- netbox/utilities/templatetags/form_helpers.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index 47bbaafe8..c55a6b98b 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -54,17 +54,24 @@ def render_fieldset(form, fieldset, heading=None): """ rows = [] for item in fieldset: + + # Multiple fields side-by-side if type(item) is InlineFields: + fields = [ + form[name] for name in item.field_names if name in form.fields + ] rows.append( - ('inline', item.label, [form[name] for name in item.field_names]) + ('inline', item.label, fields) ) + + # Tabbed groups of fields elif type(item) is TabbedFieldGroups: tabs = [ { 'id': tab['id'], 'title': tab['title'], 'active': bool(form.initial.get(tab['fields'][0], False)), - 'fields': [form[name] for name in tab['fields']] + 'fields': [form[name] for name in tab['fields'] if name in form.fields] } for tab in item.tabs ] # If none of the tabs has been marked as active, activate the first one @@ -73,7 +80,9 @@ def render_fieldset(form, fieldset, heading=None): rows.append( ('tabs', None, tabs) ) - else: + + # A single form field + elif item in form.fields: rows.append( ('field', None, [form[item]]) ) From 8f03a19b5fdcffc534b6be38a734e1736ad7717c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Mar 2024 10:15:34 -0400 Subject: [PATCH 47/56] Introduce ObjectAttribute for displaying read-only instance attributes on forms --- netbox/extras/forms/model_forms.py | 4 +++ netbox/extras/views.py | 1 - netbox/ipam/forms/model_forms.py | 5 +++ netbox/ipam/views.py | 1 - .../extras/imageattachment_edit.html | 19 ---------- .../ipam/fhrpgroupassignment_edit.html | 19 ---------- .../tenancy/contactassignment_edit.html | 35 ------------------- netbox/tenancy/forms/model_forms.py | 5 +++ netbox/tenancy/views.py | 1 - netbox/utilities/forms/rendering.py | 8 ++++- .../form_helpers/render_fieldset.html | 11 ++++++ netbox/utilities/templatetags/form_helpers.py | 9 ++++- 12 files changed, 40 insertions(+), 78 deletions(-) delete mode 100644 netbox/templates/extras/imageattachment_edit.html delete mode 100644 netbox/templates/ipam/fhrpgroupassignment_edit.html delete mode 100644 netbox/templates/tenancy/contactassignment_edit.html diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 09d2d9535..4e62b3ab7 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -17,6 +17,7 @@ from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, ) +from utilities.forms.rendering import ObjectAttribute from utilities.forms.widgets import ChoicesWidget, HTMXSelect from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -526,6 +527,9 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm): class ImageAttachmentForm(forms.ModelForm): + fieldsets = ( + (None, (ObjectAttribute('parent'), 'name', 'image')), + ) class Meta: model = ImageAttachment diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 1fa2a30aa..cb3fdd39c 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -759,7 +759,6 @@ class ImageAttachmentListView(generic.ObjectListView): class ImageAttachmentEditView(generic.ObjectEditView): queryset = ImageAttachment.objects.all() form = forms.ImageAttachmentForm - template_name = 'extras/imageattachment_edit.html' def alter_object(self, instance, request, args, kwargs): if not instance.pk: diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 47087139a..07f782f7f 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -16,6 +16,7 @@ from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, SlugField, ) +from utilities.forms.rendering import ObjectAttribute from utilities.forms.widgets import DatePicker from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface @@ -502,6 +503,10 @@ class FHRPGroupAssignmentForm(forms.ModelForm): queryset=FHRPGroup.objects.all() ) + fieldsets = ( + (None, (ObjectAttribute('interface'), 'group', 'priority')), + ) + class Meta: model = FHRPGroupAssignment fields = ('group', 'priority') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 9c4a9a102..79716f082 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1059,7 +1059,6 @@ class FHRPGroupBulkDeleteView(generic.BulkDeleteView): class FHRPGroupAssignmentEditView(generic.ObjectEditView): queryset = FHRPGroupAssignment.objects.all() form = forms.FHRPGroupAssignmentForm - template_name = 'ipam/fhrpgroupassignment_edit.html' def alter_object(self, instance, request, args, kwargs): if not instance.pk: diff --git a/netbox/templates/extras/imageattachment_edit.html b/netbox/templates/extras/imageattachment_edit.html deleted file mode 100644 index 75b2ce48b..000000000 --- a/netbox/templates/extras/imageattachment_edit.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load helpers %} -{% load form_helpers %} - -{% block form %} -
-
- -
-
- {{ object.parent|linkify }} -
-
-
- {% render_form form %} -
-{% endblock form %} diff --git a/netbox/templates/ipam/fhrpgroupassignment_edit.html b/netbox/templates/ipam/fhrpgroupassignment_edit.html deleted file mode 100644 index bbc1505f2..000000000 --- a/netbox/templates/ipam/fhrpgroupassignment_edit.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} -{% load i18n %} - -{% block form %} -
-
-
{% trans "FHRP Group Assignment" %}
-
-
- -
- -
-
- {% render_field form.group %} - {% render_field form.priority %} -
-{% endblock %} diff --git a/netbox/templates/tenancy/contactassignment_edit.html b/netbox/templates/tenancy/contactassignment_edit.html deleted file mode 100644 index 342debcbb..000000000 --- a/netbox/templates/tenancy/contactassignment_edit.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load helpers %} -{% load form_helpers %} -{% load i18n %} - -{% block form %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
-
-
{% trans "Contact Assignment" %}
-
-
- -
- -
-
- {% render_field form.group %} - {% render_field form.contact %} - {% render_field form.role %} - {% render_field form.priority %} - {% render_field form.tags %} -
- -
-
-
{% trans "Custom Fields" %}
-
- {% render_custom_fields form %} -
-{% endblock %} diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index 140d9cf9a..7dcb4e433 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelForm from tenancy.models import * from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField +from utilities.forms.rendering import ObjectAttribute __all__ = ( 'ContactAssignmentForm', @@ -140,6 +141,10 @@ class ContactAssignmentForm(NetBoxModelForm): queryset=ContactRole.objects.all() ) + fieldsets = ( + (None, (ObjectAttribute('object'), 'group', 'contact', 'role', 'priority', 'tags')), + ) + class Meta: model = ContactAssignment fields = ( diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 4c4d263df..d30793a16 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -369,7 +369,6 @@ class ContactAssignmentListView(generic.ObjectListView): class ContactAssignmentEditView(generic.ObjectEditView): queryset = ContactAssignment.objects.all() form = forms.ContactAssignmentForm - template_name = 'tenancy/contactassignment_edit.html' def alter_object(self, instance, request, args, kwargs): if not instance.pk: diff --git a/netbox/utilities/forms/rendering.py b/netbox/utilities/forms/rendering.py index ad87930a9..d60f3f061 100644 --- a/netbox/utilities/forms/rendering.py +++ b/netbox/utilities/forms/rendering.py @@ -3,8 +3,8 @@ import string from functools import cached_property __all__ = ( - 'FieldGroup', 'InlineFields', + 'ObjectAttribute', 'TabbedFieldGroups', ) @@ -41,3 +41,9 @@ class TabbedFieldGroups: 'fields': group.field_names, } for i, group in enumerate(self.groups, start=1) ] + + +class ObjectAttribute: + + def __init__(self, name): + self.name = name diff --git a/netbox/utilities/templates/form_helpers/render_fieldset.html b/netbox/utilities/templates/form_helpers/render_fieldset.html index ee1f50293..d4c7981f7 100644 --- a/netbox/utilities/templates/form_helpers/render_fieldset.html +++ b/netbox/utilities/templates/form_helpers/render_fieldset.html @@ -12,6 +12,17 @@ {# Single form field #} {% render_field items.0 %} + {% elif layout == 'attribute' %} + {# A static attribute of the form's instance #} +
+ +
+
+ {{ items.0|linkify }} +
+
+
+ {% elif layout == 'inline' %} {# Multiple form fields on the same line #}
diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index c55a6b98b..e336ac21b 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -1,6 +1,6 @@ from django import template -from utilities.forms.rendering import InlineFields, TabbedFieldGroups +from utilities.forms.rendering import InlineFields, ObjectAttribute, TabbedFieldGroups __all__ = ( 'getfield', @@ -81,6 +81,13 @@ def render_fieldset(form, fieldset, heading=None): ('tabs', None, tabs) ) + elif type(item) is ObjectAttribute: + value = getattr(form.instance, item.name) + label = value._meta.verbose_name if hasattr(value, '_meta') else item.name + rows.append( + ('attribute', label.title(), [value]) + ) + # A single form field elif item in form.fields: rows.append( From 2aaa552067cfa7f92391709f971d682190edc45c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Mar 2024 10:59:00 -0400 Subject: [PATCH 48/56] Replace custom form templates with TabbedFieldGroups --- netbox/circuits/forms/model_forms.py | 16 ++++ netbox/circuits/views.py | 1 - netbox/ipam/forms/model_forms.py | 46 ++++++++- netbox/ipam/views.py | 3 - .../circuits/circuittermination_edit.html | 58 ------------ netbox/templates/ipam/ipaddress_edit.html | 93 ------------------- netbox/templates/ipam/service_create.html | 79 ---------------- netbox/templates/ipam/service_edit.html | 66 ------------- .../templates/vpn/l2vpntermination_edit.html | 56 ----------- .../form_helpers/render_fieldset.html | 24 ++--- netbox/vpn/forms/model_forms.py | 13 +++ netbox/vpn/views.py | 1 - 12 files changed, 87 insertions(+), 369 deletions(-) delete mode 100644 netbox/templates/circuits/circuittermination_edit.html delete mode 100644 netbox/templates/ipam/ipaddress_edit.html delete mode 100644 netbox/templates/ipam/service_create.html delete mode 100644 netbox/templates/ipam/service_edit.html delete mode 100644 netbox/templates/vpn/l2vpntermination_edit.html diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 0809cb2f4..9e29f6477 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -7,6 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField +from utilities.forms.rendering import TabbedFieldGroups from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( @@ -146,6 +147,21 @@ class CircuitTerminationForm(NetBoxModelForm): selector=True ) + fieldsets = ( + (_('Circuit Termination'), ( + 'circuit', + 'term_side', + 'description', + 'tags', + TabbedFieldGroups( + (_('Site'), 'site'), + (_('Provider Network'), 'provider_network'), + ), + 'mark_connected', + )), + (_('Termination Details'), ('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info')), + ) + class Meta: model = CircuitTermination fields = [ diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 64dd82682..0c01d6eb9 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -412,7 +412,6 @@ class CircuitContactsView(ObjectContactsView): class CircuitTerminationEditView(generic.ObjectEditView): queryset = CircuitTermination.objects.all() form = forms.CircuitTerminationForm - template_name = 'circuits/circuittermination_edit.html' @register_model_view(CircuitTermination, 'delete') diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 07f782f7f..85f9591a8 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -16,7 +16,7 @@ from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, SlugField, ) -from utilities.forms.rendering import ObjectAttribute +from utilities.forms.rendering import InlineFields, ObjectAttribute, TabbedFieldGroups from utilities.forms.widgets import DatePicker from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface @@ -308,6 +308,20 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): ) comments = CommentField() + fieldsets = ( + (_('IP Address'), ('address', 'status', 'role', 'vrf', 'dns_name', 'description', 'tags')), + (_('Tenancy'), ('tenant_group', 'tenant')), + (_('Assignment'), ( + TabbedFieldGroups( + (_('Device'), 'interface'), + (_('Virtual Machine'), 'vminterface'), + (_('FHRP Group'), 'fhrpgroup'), + ), + 'primary_for_parent', + )), + (_('NAT IP (Inside)'), ('nat_inside',)), + ) + class Meta: model = IPAddress fields = [ @@ -709,6 +723,20 @@ class ServiceForm(NetBoxModelForm): ) comments = CommentField() + fieldsets = ( + (_('Service'), ( + TabbedFieldGroups( + (_('Device'), 'device'), + (_('Virtual Machine'), 'virtual_machine'), + ), + 'name', + InlineFields(_('Port(s)'), 'protocol', 'ports'), + 'ipaddresses', + 'description', + 'tags', + )), + ) + class Meta: model = Service fields = [ @@ -723,6 +751,22 @@ class ServiceCreateForm(ServiceForm): required=False ) + fieldsets = ( + (_('Service'), ( + TabbedFieldGroups( + (_('Device'), 'device'), + (_('Virtual Machine'), 'virtual_machine'), + ), + TabbedFieldGroups( + (_('From Template'), 'service_template'), + (_('Custom'), 'name', 'protocol', 'ports'), + ), + 'ipaddresses', + 'description', + 'tags', + )), + ) + class Meta(ServiceForm.Meta): fields = [ 'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description', diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 79716f082..6870d1e9e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -781,7 +781,6 @@ class IPAddressView(generic.ObjectView): class IPAddressEditView(generic.ObjectEditView): queryset = IPAddress.objects.all() form = forms.IPAddressForm - template_name = 'ipam/ipaddress_edit.html' def alter_object(self, obj, request, url_args, url_kwargs): @@ -1235,14 +1234,12 @@ class ServiceView(generic.ObjectView): class ServiceCreateView(generic.ObjectEditView): queryset = Service.objects.all() form = forms.ServiceCreateForm - template_name = 'ipam/service_create.html' @register_model_view(Service, 'edit') class ServiceEditView(generic.ObjectEditView): queryset = Service.objects.all() form = forms.ServiceForm - template_name = 'ipam/service_edit.html' @register_model_view(Service, 'delete') diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html deleted file mode 100644 index 18198cb72..000000000 --- a/netbox/templates/circuits/circuittermination_edit.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load static %} -{% load form_helpers %} -{% load i18n %} - -{% block form %} -
-
-
{% trans "Circuit Termination" %}
-
- {% render_field form.circuit %} - {% render_field form.term_side %} - {% render_field form.tags %} - {% render_field form.mark_connected %} - {% with providernetwork_tab_active=form.initial.provider_network %} -
-
- -
-
-
-
- {% render_field form.site %} -
-
- {% render_field form.provider_network %} -
-
- {% endwith %} -
- -
-
-
{% trans "Termination Details" %}
-
- {% render_field form.port_speed %} - {% render_field form.upstream_speed %} - {% render_field form.xconnect_id %} - {% render_field form.pp_info %} - {% render_field form.description %} -
- - {% if form.custom_fields %} -
-
-
{% trans "Custom Fields" %}
-
- {% render_custom_fields form %} -
- {% endif %} -{% endblock %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html deleted file mode 100644 index d9157f5ef..000000000 --- a/netbox/templates/ipam/ipaddress_edit.html +++ /dev/null @@ -1,93 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load static %} -{% load form_helpers %} -{% load helpers %} -{% load i18n %} - -{% block tabs %} - {% include 'ipam/inc/ipaddress_edit_header.html' with active_tab='add' %} -{% endblock tabs %} - -{% block form %} -
-
-
{% trans "IP Address" %}
-
- {% render_field form.address %} - {% render_field form.status %} - {% render_field form.role %} - {% render_field form.vrf %} - {% render_field form.dns_name %} - {% render_field form.description %} - {% render_field form.tags %} -
- -
-
-
{% trans "Tenancy" %}
-
- {% render_field form.tenant_group %} - {% render_field form.tenant %} -
- -
-
-
{% trans "Interface Assignment" %}
-
-
-
- -
-
-
-
- {% render_field form.interface %} -
-
- {% render_field form.vminterface %} -
-
- {% render_field form.fhrpgroup %} -
- {% render_field form.primary_for_parent %} -
-
- -
-
-
{% trans "NAT IP (Inside" %})
-
-
- {% render_field form.nat_inside %} -
-
- -
- {% render_field form.comments %} -
- - {% if form.custom_fields %} -
-
-
{% trans "Custom Fields" %}
-
- {% render_custom_fields form %} -
- {% endif %} -{% endblock %} diff --git a/netbox/templates/ipam/service_create.html b/netbox/templates/ipam/service_create.html deleted file mode 100644 index d145999c0..000000000 --- a/netbox/templates/ipam/service_create.html +++ /dev/null @@ -1,79 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} -{% load i18n %} - -{% block form %} -
-
-
{% trans "Service" %}
-
- - {# Device/VM selection #} -
-
- -
-
-
-
- {% render_field form.device %} -
-
- {% render_field form.virtual_machine %} -
-
- - {# Template or custom #} -
-
- -
-
-
-
- {% render_field form.service_template %} -
-
- {% render_field form.name %} - {% render_field form.protocol %} - {% render_field form.ports %} -
-
- {% render_field form.ipaddresses %} - {% render_field form.description %} - {% render_field form.tags %} -
- -
- {% render_field form.comments %} -
- - {% if form.custom_fields %} -
-
{% trans "Custom Fields" %}
-
- {% render_custom_fields form %} - {% endif %} -{% endblock %} diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html deleted file mode 100644 index 33eda76e1..000000000 --- a/netbox/templates/ipam/service_edit.html +++ /dev/null @@ -1,66 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} -{% load i18n %} - -{% block form %} -
-
-
{% trans "Service" %}
-
- -
-
- -
-
-
-
- {% render_field form.device %} -
-
- {% render_field form.virtual_machine %} -
-
- {% render_field form.name %} -
- -
- {{ form.protocol }} -
-
- {{ form.ports }} -
-
-
-
-
- {{ form.ports.help_text }} -
-
- {% render_field form.ipaddresses %} - {% render_field form.description %} - {% render_field form.tags %} -
- -
- {% render_field form.comments %} -
- - {% if form.custom_fields %} -
-
{% trans "Custom Fields" %}
-
- {% render_custom_fields form %} - {% endif %} -{% endblock %} diff --git a/netbox/templates/vpn/l2vpntermination_edit.html b/netbox/templates/vpn/l2vpntermination_edit.html deleted file mode 100644 index 14b30c78d..000000000 --- a/netbox/templates/vpn/l2vpntermination_edit.html +++ /dev/null @@ -1,56 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load helpers %} -{% load form_helpers %} -{% load i18n %} - -{% block form %} -
-
-
{% trans "L2VPN Termination" %}
-
- {% render_field form.l2vpn %} -
-
- -
-
-
-
-
- {% render_field form.vlan %} -
-
- {% render_field form.interface %} -
-
- {% render_field form.vminterface %} -
- {% render_field form.tags %} -
-
-
- {% if form.custom_fields %} -
-
-
{% trans "Custom Fields" %}
-
- {% render_custom_fields form %} -
-{% endif %} -{% endblock %} diff --git a/netbox/utilities/templates/form_helpers/render_fieldset.html b/netbox/utilities/templates/form_helpers/render_fieldset.html index d4c7981f7..d978997af 100644 --- a/netbox/utilities/templates/form_helpers/render_fieldset.html +++ b/netbox/utilities/templates/form_helpers/render_fieldset.html @@ -26,7 +26,7 @@ {% elif layout == 'inline' %} {# Multiple form fields on the same line #}
- + {% for field in items %}
{{ field }} @@ -37,16 +37,18 @@ {% elif layout == 'tabs' %} {# Tabbed groups of fields #} -
- +
+
+ +
{% for tab in items %} diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 9e5e17a09..efb8a7eda 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -7,6 +7,7 @@ from ipam.models import IPAddress, RouteTarget, VLAN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField +from utilities.forms.rendering import TabbedFieldGroups from utilities.forms.utils import add_blank_choice, get_field_value from utilities.forms.widgets import HTMXSelect from virtualization.models import VirtualMachine, VMInterface @@ -444,6 +445,18 @@ class L2VPNTerminationForm(NetBoxModelForm): label=_('Interface') ) + fieldsets = ( + (None, ( + 'l2vpn', + TabbedFieldGroups( + (_('VLAN'), 'vlan'), + (_('Device'), 'interface'), + (_('Virtual Machine'), 'vminterface'), + ), + 'tags', + )), + ) + class Meta: model = L2VPNTermination fields = ('l2vpn', 'tags') diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index 9bf424af9..af1f653c8 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -479,7 +479,6 @@ class L2VPNTerminationView(generic.ObjectView): class L2VPNTerminationEditView(generic.ObjectEditView): queryset = L2VPNTermination.objects.all() form = forms.L2VPNTerminationForm - template_name = 'vpn/l2vpntermination_edit.html' @register_model_view(L2VPNTermination, 'delete') From 3b28e8e61596b9f9d87e78f0ee788365bc88c3c0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Mar 2024 12:59:42 -0400 Subject: [PATCH 49/56] Refactor form rendering components & add docstrings --- netbox/circuits/forms/model_forms.py | 4 +- netbox/dcim/forms/model_forms.py | 8 ++-- netbox/ipam/forms/model_forms.py | 12 +++--- netbox/templates/htmx/form.html | 4 +- netbox/utilities/forms/rendering.py | 41 ++++++++++++------- netbox/utilities/templatetags/form_helpers.py | 17 +++++--- netbox/vpn/forms/model_forms.py | 4 +- 7 files changed, 54 insertions(+), 36 deletions(-) diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 9e29f6477..d73da3a02 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -7,7 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField -from utilities.forms.rendering import TabbedFieldGroups +from utilities.forms.rendering import TabbedGroups from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( @@ -153,7 +153,7 @@ class CircuitTerminationForm(NetBoxModelForm): 'term_side', 'description', 'tags', - TabbedFieldGroups( + TabbedGroups( (_('Site'), 'site'), (_('Provider Network'), 'provider_network'), ), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 06f28b4e6..e0c25dbba 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -16,7 +16,7 @@ from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField, ) -from utilities.forms.rendering import InlineFields, TabbedFieldGroups +from utilities.forms.rendering import InlineFields, TabbedGroups from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK from virtualization.models import Cluster from wireless.models import WirelessLAN, WirelessLANGroup @@ -237,8 +237,8 @@ class RackForm(TenancyForm, NetBoxModelForm): 'width', 'starting_unit', 'u_height', - InlineFields(_('Outer Dimensions'), 'outer_width', 'outer_depth', 'outer_unit'), - InlineFields(_('Weight'), 'weight', 'max_weight', 'weight_unit'), + InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), 'mounting_depth', 'desc_units', )), @@ -1415,7 +1415,7 @@ class InventoryItemForm(DeviceComponentForm): (_('Inventory Item'), ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')), (_('Hardware'), ('manufacturer', 'part_id', 'serial', 'asset_tag')), (_('Component Assignment'), ( - TabbedFieldGroups( + TabbedGroups( (_('Interface'), 'interface'), (_('Console Port'), 'consoleport'), (_('Console Server Port'), 'consoleserverport'), diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 85f9591a8..0aba37fb9 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -16,7 +16,7 @@ from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, SlugField, ) -from utilities.forms.rendering import InlineFields, ObjectAttribute, TabbedFieldGroups +from utilities.forms.rendering import InlineFields, ObjectAttribute, TabbedGroups from utilities.forms.widgets import DatePicker from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface @@ -312,7 +312,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): (_('IP Address'), ('address', 'status', 'role', 'vrf', 'dns_name', 'description', 'tags')), (_('Tenancy'), ('tenant_group', 'tenant')), (_('Assignment'), ( - TabbedFieldGroups( + TabbedGroups( (_('Device'), 'interface'), (_('Virtual Machine'), 'vminterface'), (_('FHRP Group'), 'fhrpgroup'), @@ -725,12 +725,12 @@ class ServiceForm(NetBoxModelForm): fieldsets = ( (_('Service'), ( - TabbedFieldGroups( + TabbedGroups( (_('Device'), 'device'), (_('Virtual Machine'), 'virtual_machine'), ), 'name', - InlineFields(_('Port(s)'), 'protocol', 'ports'), + InlineFields('protocol', 'ports', label=_('Port(s)')), 'ipaddresses', 'description', 'tags', @@ -753,11 +753,11 @@ class ServiceCreateForm(ServiceForm): fieldsets = ( (_('Service'), ( - TabbedFieldGroups( + TabbedGroups( (_('Device'), 'device'), (_('Virtual Machine'), 'virtual_machine'), ), - TabbedFieldGroups( + TabbedGroups( (_('From Template'), 'service_template'), (_('Custom'), 'name', 'protocol', 'ports'), ), diff --git a/netbox/templates/htmx/form.html b/netbox/templates/htmx/form.html index 0bfcb00ca..f9eecc2b9 100644 --- a/netbox/templates/htmx/form.html +++ b/netbox/templates/htmx/form.html @@ -9,8 +9,8 @@ {% endfor %} {# Render grouped fields according to Form #} - {% for group, items in form.fieldsets %} - {% render_fieldset form items heading=group %} + {% for fieldset in form.fieldsets %} + {% render_fieldset form fieldset %} {% endfor %} {% if form.custom_fields %} diff --git a/netbox/utilities/forms/rendering.py b/netbox/utilities/forms/rendering.py index d60f3f061..ea73c38ff 100644 --- a/netbox/utilities/forms/rendering.py +++ b/netbox/utilities/forms/rendering.py @@ -3,28 +3,39 @@ import string from functools import cached_property __all__ = ( + 'FieldSet', 'InlineFields', 'ObjectAttribute', - 'TabbedFieldGroups', + 'TabbedGroups', ) -class FieldGroup: +class FieldSet: + """ + A generic grouping of fields, with an optional name. Each field will be rendered + on its own row under the heading (name). + """ + def __init__(self, *fields, name=None): + self.fields = fields + self.name = name - def __init__(self, label, *field_names): - self.field_names = field_names + +class InlineFields: + """ + A set of fields rendered inline (side-by-side) with a shared label; typically nested within a FieldSet. + """ + def __init__(self, *fields, label=None): + self.fields = fields self.label = label -class InlineFields(FieldGroup): - pass - - -class TabbedFieldGroups: - +class TabbedGroups: + """ + Two or more groups of fields (FieldSets) arranged under tabs among which the user can navigate. + """ def __init__(self, *groups): self.groups = [ - FieldGroup(*group) for group in groups + FieldSet(*group, name=name) for name, *group in groups ] # Initialize a random ID for the group (for tab selection) @@ -37,13 +48,15 @@ class TabbedFieldGroups: return [ { 'id': f'{self.id}_{i}', - 'title': group.label, - 'fields': group.field_names, + 'title': group.name, + 'fields': group.fields, } for i, group in enumerate(self.groups, start=1) ] class ObjectAttribute: - + """ + Renders the value for a specific attribute on the form's instance. + """ def __init__(self, name): self.name = name diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index e336ac21b..48a1a5aa8 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -1,6 +1,6 @@ from django import template -from utilities.forms.rendering import InlineFields, ObjectAttribute, TabbedFieldGroups +from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups __all__ = ( 'getfield', @@ -48,24 +48,29 @@ def widget_type(field): # @register.inclusion_tag('form_helpers/render_fieldset.html') -def render_fieldset(form, fieldset, heading=None): +def render_fieldset(form, fieldset): """ Render a group set of fields. """ + # Handle legacy tuple-based fieldset definitions, e.g. (_('Label'), ('field1, 'field2', 'field3')) + if type(fieldset) is not FieldSet: + name, fields = fieldset + fieldset = FieldSet(*fields, name=name) + rows = [] - for item in fieldset: + for item in fieldset.fields: # Multiple fields side-by-side if type(item) is InlineFields: fields = [ - form[name] for name in item.field_names if name in form.fields + form[name] for name in item.fields if name in form.fields ] rows.append( ('inline', item.label, fields) ) # Tabbed groups of fields - elif type(item) is TabbedFieldGroups: + elif type(item) is TabbedGroups: tabs = [ { 'id': tab['id'], @@ -95,7 +100,7 @@ def render_fieldset(form, fieldset, heading=None): ) return { - 'heading': heading, + 'heading': fieldset.name, 'rows': rows, } diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index efb8a7eda..9674ee2f9 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -7,7 +7,7 @@ from ipam.models import IPAddress, RouteTarget, VLAN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField -from utilities.forms.rendering import TabbedFieldGroups +from utilities.forms.rendering import TabbedGroups from utilities.forms.utils import add_blank_choice, get_field_value from utilities.forms.widgets import HTMXSelect from virtualization.models import VirtualMachine, VMInterface @@ -448,7 +448,7 @@ class L2VPNTerminationForm(NetBoxModelForm): fieldsets = ( (None, ( 'l2vpn', - TabbedFieldGroups( + TabbedGroups( (_('VLAN'), 'vlan'), (_('Device'), 'interface'), (_('Virtual Machine'), 'vminterface'), From 72d3c17b482d0802aa33b8af6ea833d5af01caa7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Mar 2024 15:08:28 -0400 Subject: [PATCH 50/56] Use FieldSet instances for all forms --- netbox/circuits/forms/bulk_edit.py | 15 +- netbox/circuits/forms/filtersets.py | 33 +-- netbox/circuits/forms/model_forms.py | 33 +-- netbox/core/forms/bulk_edit.py | 3 +- netbox/core/forms/filtersets.py | 23 +- netbox/core/forms/model_forms.py | 37 ++- netbox/dcim/forms/bulk_edit.py | 113 ++++---- netbox/dcim/forms/filtersets.py | 258 +++++++++--------- netbox/dcim/forms/model_forms.py | 207 +++++++------- netbox/dcim/forms/object_create.py | 7 +- netbox/extras/forms/filtersets.py | 69 ++--- netbox/extras/forms/model_forms.py | 71 ++--- netbox/ipam/forms/bulk_edit.py | 43 +-- netbox/ipam/forms/filtersets.py | 93 ++++--- netbox/ipam/forms/model_forms.py | 114 ++++---- netbox/netbox/forms/base.py | 2 +- netbox/templates/generic/bulk_edit.html | 10 +- netbox/templates/inc/filter_list.html | 8 +- netbox/tenancy/forms/bulk_edit.py | 11 +- netbox/tenancy/forms/filtersets.py | 9 +- netbox/tenancy/forms/model_forms.py | 23 +- netbox/users/forms/bulk_edit.py | 7 +- netbox/users/forms/filtersets.py | 19 +- netbox/users/forms/model_forms.py | 38 ++- netbox/utilities/forms/rendering.py | 9 +- netbox/utilities/templatetags/form_helpers.py | 6 + netbox/virtualization/forms/bulk_edit.py | 23 +- netbox/virtualization/forms/filtersets.py | 42 +-- netbox/virtualization/forms/model_forms.py | 37 ++- netbox/vpn/forms/bulk_edit.py | 27 +- netbox/vpn/forms/filtersets.py | 48 ++-- netbox/vpn/forms/model_forms.py | 70 ++--- netbox/wireless/forms/bulk_edit.py | 11 +- netbox/wireless/forms/filtersets.py | 17 +- netbox/wireless/forms/model_forms.py | 21 +- 35 files changed, 800 insertions(+), 757 deletions(-) diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 5c416bff9..3ac311c56 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -8,6 +8,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import add_blank_choice from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( @@ -34,7 +35,7 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): model = Provider fieldsets = ( - (None, ('asns', 'description')), + FieldSet('asns', 'description'), ) nullable_fields = ( 'asns', 'description', 'comments', @@ -56,7 +57,7 @@ class ProviderAccountBulkEditForm(NetBoxModelBulkEditForm): model = ProviderAccount fieldsets = ( - (None, ('provider', 'description')), + FieldSet('provider', 'description'), ) nullable_fields = ( 'description', 'comments', @@ -83,7 +84,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): model = ProviderNetwork fieldsets = ( - (None, ('provider', 'service_id', 'description')), + FieldSet('provider', 'service_id', 'description'), ) nullable_fields = ( 'service_id', 'description', 'comments', @@ -103,7 +104,7 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): model = CircuitType fieldsets = ( - (None, ('color', 'description')), + FieldSet('color', 'description'), ) nullable_fields = ('color', 'description') @@ -164,9 +165,9 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): model = Circuit fieldsets = ( - (_('Circuit'), ('provider', 'type', 'status', 'description')), - (_('Service Parameters'), ('provider_account', 'install_date', 'termination_date', 'commit_rate')), - (_('Tenancy'), ('tenant',)), + FieldSet('provider', 'type', 'status', 'description', name=_('Circuit')), + FieldSet('provider_account', 'install_date', 'termination_date', 'commit_rate', name=_('Service Parameters')), + FieldSet('tenant', name=_('Tenancy')), ) nullable_fields = ( 'tenant', 'commit_rate', 'description', 'comments', diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 1e1abd068..01445ff6f 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -8,6 +8,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( @@ -22,10 +23,10 @@ __all__ = ( class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Provider fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('ASN'), ('asn',)), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('asn', name=_('ASN')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -61,8 +62,8 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ProviderAccountFilterForm(NetBoxModelFilterSetForm): model = ProviderAccount fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('provider_id', 'account')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('provider_id', 'account', name=_('Attributes')), ) provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), @@ -79,8 +80,8 @@ class ProviderAccountFilterForm(NetBoxModelFilterSetForm): class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): model = ProviderNetwork fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('provider_id', 'service_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('provider_id', 'service_id', name=_('Attributes')), ) provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), @@ -98,8 +99,8 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): class CircuitTypeFilterForm(NetBoxModelFilterSetForm): model = CircuitType fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('color',)), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('color', name=_('Attributes')), ) tag = TagFilterField(model) @@ -112,12 +113,12 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm): class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Circuit fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Provider'), ('provider_id', 'provider_account_id', 'provider_network_id')), - (_('Attributes'), ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')), + FieldSet('type_id', 'status', 'install_date', 'termination_date', 'commit_rate', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id') type_id = DynamicModelMultipleChoiceField( diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index d73da3a02..ee5e47ce7 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -7,7 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField -from utilities.forms.rendering import TabbedGroups +from utilities.forms.rendering import FieldSet, TabbedGroups from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( @@ -30,7 +30,7 @@ class ProviderForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Provider'), ('name', 'slug', 'asns', 'description', 'tags')), + FieldSet('name', 'slug', 'asns', 'description', 'tags'), ) class Meta: @@ -62,7 +62,7 @@ class ProviderNetworkForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Provider Network'), ('provider', 'name', 'service_id', 'description', 'tags')), + FieldSet('provider', 'name', 'service_id', 'description', 'tags'), ) class Meta: @@ -76,9 +76,7 @@ class CircuitTypeForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Circuit Type'), ( - 'name', 'slug', 'color', 'description', 'tags', - )), + FieldSet('name', 'slug', 'color', 'description', 'tags'), ) class Meta: @@ -108,9 +106,9 @@ class CircuitForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Circuit'), ('provider', 'provider_account', 'cid', 'type', 'status', 'description', 'tags')), - (_('Service Parameters'), ('install_date', 'termination_date', 'commit_rate')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('provider', 'provider_account', 'cid', 'type', 'status', 'description', 'tags', name=_('Circuit')), + FieldSet('install_date', 'termination_date', 'commit_rate', name=_('Service Parameters')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -148,18 +146,15 @@ class CircuitTerminationForm(NetBoxModelForm): ) fieldsets = ( - (_('Circuit Termination'), ( - 'circuit', - 'term_side', - 'description', - 'tags', + FieldSet( + 'circuit', 'term_side', 'description', 'tags', TabbedGroups( - (_('Site'), 'site'), - (_('Provider Network'), 'provider_network'), + FieldSet('site', name=_('Site')), + FieldSet('provider_network', name=_('Provider Network')), ), - 'mark_connected', - )), - (_('Termination Details'), ('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info')), + 'mark_connected', name=_('Circuit Termination') + ), + FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')), ) class Meta: diff --git a/netbox/core/forms/bulk_edit.py b/netbox/core/forms/bulk_edit.py index bc2ef8fc9..c1f1fca4d 100644 --- a/netbox/core/forms/bulk_edit.py +++ b/netbox/core/forms/bulk_edit.py @@ -5,6 +5,7 @@ from core.models import * from netbox.forms import NetBoxModelBulkEditForm from netbox.utils import get_data_backend_choices from utilities.forms.fields import CommentField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect __all__ = ( @@ -41,7 +42,7 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm): model = DataSource fieldsets = ( - (None, ('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules')), + FieldSet('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules'), ) nullable_fields = ( 'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules', diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index bd74c0f14..60a3acc44 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -9,7 +9,8 @@ from netbox.forms.mixins import SavedFiltersMixin from netbox.utils import get_data_backend_choices from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField -from utilities.forms.widgets import APISelectMultiple, DateTimePicker +from utilities.forms.rendering import FieldSet +from utilities.forms.widgets import DateTimePicker __all__ = ( 'ConfigRevisionFilterForm', @@ -22,8 +23,8 @@ __all__ = ( class DataSourceFilterForm(NetBoxModelFilterSetForm): model = DataSource fieldsets = ( - (None, ('q', 'filter_id')), - (_('Data Source'), ('type', 'status')), + FieldSet('q', 'filter_id'), + FieldSet('type', 'status', name=_('Data Source')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -47,8 +48,8 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm): class DataFileFilterForm(NetBoxModelFilterSetForm): model = DataFile fieldsets = ( - (None, ('q', 'filter_id')), - (_('File'), ('source_id',)), + FieldSet('q', 'filter_id'), + FieldSet('source_id', name=_('File')), ) source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -59,12 +60,12 @@ class DataFileFilterForm(NetBoxModelFilterSetForm): class JobFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Attributes'), ('object_type', 'status')), - (_('Creation'), ( + FieldSet('q', 'filter_id'), + FieldSet('object_type', 'status', name=_('Attributes')), + FieldSet( 'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before', - 'started__after', 'completed__before', 'completed__after', 'user', - )), + 'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation') + ), ) object_type = ContentTypeChoiceField( label=_('Object Type'), @@ -125,5 +126,5 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), + FieldSet('q', 'filter_id'), ) diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index e0c71fe48..cbca0737a 100644 --- a/netbox/core/forms/model_forms.py +++ b/netbox/core/forms/model_forms.py @@ -13,6 +13,7 @@ from netbox.registry import registry from netbox.utils import get_data_backend_choices from utilities.forms import get_field_value from utilities.forms.fields import CommentField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import HTMXSelect __all__ = ( @@ -49,11 +50,11 @@ class DataSourceForm(NetBoxModelForm): @property def fieldsets(self): fieldsets = [ - (_('Source'), ('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules')), + FieldSet('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules', name=_('Source')), ] if self.backend_fields: fieldsets.append( - (_('Backend Parameters'), self.backend_fields) + FieldSet(*self.backend_fields, name=_('Backend Parameters')) ) return fieldsets @@ -91,8 +92,8 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm): ) fieldsets = ( - (_('File Upload'), ('upload_file',)), - (_('Data Source'), ('data_source', 'data_file', 'auto_sync_enabled')), + FieldSet('upload_file', name=_('File Upload')), + FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), ) class Meta: @@ -144,18 +145,24 @@ class ConfigRevisionForm(forms.ModelForm, metaclass=ConfigFormMetaclass): """ fieldsets = ( - (_('Rack Elevations'), ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')), - (_('Power'), ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')), - (_('IPAM'), ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')), - (_('Security'), ('ALLOWED_URL_SCHEMES',)), - (_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')), - (_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')), - (_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')), - (_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)), - (_('Miscellaneous'), ( + FieldSet( + 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', name=_('Rack Elevations') + ), + FieldSet( + 'POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION', + name=_('Power') + ), + FieldSet('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4', name=_('IPAM')), + FieldSet('ALLOWED_URL_SCHEMES', name=_('Security')), + FieldSet('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM', name=_('Banners')), + FieldSet('PAGINATE_COUNT', 'MAX_PAGE_SIZE', name=_('Pagination')), + FieldSet('CUSTOM_VALIDATORS', 'PROTECTION_RULES', name=_('Validation')), + FieldSet('DEFAULT_USER_PREFERENCES', name=_('User Preferences')), + FieldSet( 'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL', - )), - (_('Config Revision'), ('comment',)) + name=_('Miscellaneous') + ), + FieldSet('comment', name=_('Config Revision')) ) class Meta: diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 79ecc8383..978a5d0a1 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -13,6 +13,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import BulkEditForm, add_blank_choice, form_from_model from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions from wireless.models import WirelessLAN, WirelessLANGroup from wireless.choices import WirelessRoleChoices @@ -75,7 +76,7 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm): model = Region fieldsets = ( - (None, ('parent', 'description')), + FieldSet('parent', 'description'), ) nullable_fields = ('parent', 'description') @@ -94,7 +95,7 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): model = SiteGroup fieldsets = ( - (None, ('parent', 'description')), + FieldSet('parent', 'description'), ) nullable_fields = ('parent', 'description') @@ -154,7 +155,7 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): model = Site fieldsets = ( - (None, ('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description')), + FieldSet('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description'), ) nullable_fields = ( 'region', 'group', 'tenant', 'asns', 'time_zone', 'description', 'comments', @@ -194,7 +195,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): model = Location fieldsets = ( - (None, ('site', 'parent', 'status', 'tenant', 'description')), + FieldSet('site', 'parent', 'status', 'tenant', 'description'), ) nullable_fields = ('parent', 'tenant', 'description') @@ -212,7 +213,7 @@ class RackRoleBulkEditForm(NetBoxModelBulkEditForm): model = RackRole fieldsets = ( - (None, ('color', 'description')), + FieldSet('color', 'description'), ) nullable_fields = ('color', 'description') @@ -341,12 +342,13 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): model = Rack fieldsets = ( - (_('Rack'), ('status', 'role', 'tenant', 'serial', 'asset_tag', 'description')), - (_('Location'), ('region', 'site_group', 'site', 'location')), - (_('Hardware'), ( + FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'description', name=_('Rack')), + FieldSet('region', 'site_group', 'site', 'location', name=_('Location')), + FieldSet( 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', - )), - (_('Weight'), ('weight', 'max_weight', 'weight_unit')), + name=_('Hardware') + ), + FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) nullable_fields = ( 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight', @@ -376,7 +378,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): model = RackReservation fieldsets = ( - (None, ('user', 'tenant', 'description')), + FieldSet('user', 'tenant', 'description'), ) nullable_fields = ('comments',) @@ -390,7 +392,7 @@ class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): model = Manufacturer fieldsets = ( - (None, ('description',)), + FieldSet('description'), ) nullable_fields = ('description',) @@ -450,11 +452,11 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): model = DeviceType fieldsets = ( - (_('Device Type'), ( + FieldSet( 'manufacturer', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', - 'airflow', 'description', - )), - (_('Weight'), ('weight', 'weight_unit')), + 'airflow', 'description', name=_('Device Type') + ), + FieldSet('weight', 'weight_unit', name=_('Weight')), ) nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments') @@ -489,8 +491,8 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): model = ModuleType fieldsets = ( - (_('Module Type'), ('manufacturer', 'part_number', 'description')), - (_('Weight'), ('weight', 'weight_unit')), + FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')), + FieldSet('weight', 'weight_unit', name=_('Weight')), ) nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments') @@ -518,7 +520,7 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): model = DeviceRole fieldsets = ( - (None, ('color', 'vm_role', 'config_template', 'description')), + FieldSet('color', 'vm_role', 'config_template', 'description'), ) nullable_fields = ('color', 'config_template', 'description') @@ -542,7 +544,7 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm): model = Platform fieldsets = ( - (None, ('manufacturer', 'config_template', 'description')), + FieldSet('manufacturer', 'config_template', 'description'), ) nullable_fields = ('manufacturer', 'config_template', 'description') @@ -621,10 +623,10 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): model = Device fieldsets = ( - (_('Device'), ('role', 'status', 'tenant', 'platform', 'description')), - (_('Location'), ('site', 'location')), - (_('Hardware'), ('manufacturer', 'device_type', 'airflow', 'serial')), - (_('Configuration'), ('config_template',)), + FieldSet('role', 'status', 'tenant', 'platform', 'description', name=_('Device')), + FieldSet('site', 'location', name=_('Location')), + FieldSet('manufacturer', 'device_type', 'airflow', 'serial', name=_('Hardware')), + FieldSet('config_template', name=_('Configuration')), ) nullable_fields = ( 'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments', @@ -668,7 +670,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): model = Module fieldsets = ( - (None, ('manufacturer', 'module_type', 'status', 'serial', 'description')), + FieldSet('manufacturer', 'module_type', 'status', 'serial', 'description'), ) nullable_fields = ('serial', 'description', 'comments') @@ -720,8 +722,8 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): model = Cable fieldsets = ( - (None, ('type', 'status', 'tenant', 'label', 'description')), - (_('Attributes'), ('color', 'length', 'length_unit')), + FieldSet('type', 'status', 'tenant', 'label', 'description'), + FieldSet('color', 'length', 'length_unit', name=_('Attributes')), ) nullable_fields = ( 'type', 'status', 'tenant', 'label', 'color', 'length', 'description', 'comments', @@ -743,7 +745,7 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): model = VirtualChassis fieldsets = ( - (None, ('domain', 'description')), + FieldSet('domain', 'description'), ) nullable_fields = ('domain', 'description', 'comments') @@ -791,7 +793,7 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): model = PowerPanel fieldsets = ( - (None, ('region', 'site_group', 'site', 'location', 'description')), + FieldSet('region', 'site_group', 'site', 'location', 'description'), ) nullable_fields = ('location', 'description', 'comments') @@ -861,8 +863,8 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): model = PowerFeed fieldsets = ( - (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description', 'tenant')), - (_('Power'), ('supply', 'phase', 'voltage', 'amperage', 'max_utilization')) + FieldSet('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description', 'tenant'), + FieldSet('supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Power')) ) nullable_fields = ('location', 'tenant', 'description', 'comments') @@ -1210,7 +1212,7 @@ class ConsolePortBulkEditForm( model = ConsolePort fieldsets = ( - (None, ('module', 'type', 'label', 'speed', 'description', 'mark_connected')), + FieldSet('module', 'type', 'label', 'speed', 'description', 'mark_connected'), ) nullable_fields = ('module', 'label', 'description') @@ -1227,7 +1229,7 @@ class ConsoleServerPortBulkEditForm( model = ConsoleServerPort fieldsets = ( - (None, ('module', 'type', 'label', 'speed', 'description', 'mark_connected')), + FieldSet('module', 'type', 'label', 'speed', 'description', 'mark_connected'), ) nullable_fields = ('module', 'label', 'description') @@ -1244,8 +1246,8 @@ class PowerPortBulkEditForm( model = PowerPort fieldsets = ( - (None, ('module', 'type', 'label', 'description', 'mark_connected')), - (_('Power'), ('maximum_draw', 'allocated_draw')), + FieldSet('module', 'type', 'label', 'description', 'mark_connected'), + FieldSet('maximum_draw', 'allocated_draw', name=_('Power')), ) nullable_fields = ('module', 'label', 'description', 'maximum_draw', 'allocated_draw') @@ -1262,8 +1264,8 @@ class PowerOutletBulkEditForm( model = PowerOutlet fieldsets = ( - (None, ('module', 'type', 'label', 'description', 'mark_connected')), - (_('Power'), ('feed_leg', 'power_port')), + FieldSet('module', 'type', 'label', 'description', 'mark_connected'), + FieldSet('feed_leg', 'power_port', name=_('Power')), ) nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description') @@ -1395,20 +1397,21 @@ class InterfaceBulkEditForm( model = Interface fieldsets = ( - (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')), - (_('Addressing'), ('vrf', 'mac_address', 'wwn')), - (_('Operation'), ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), - (_('PoE'), ('poe_mode', 'poe_type')), - (_('Related Interfaces'), ('parent', 'bridge', 'lag')), - (_('802.1Q Switching'), ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), - (_('Wireless'), ( + FieldSet('module', 'type', 'label', 'speed', 'duplex', 'description'), + FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')), + FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), + FieldSet('poe_mode', 'poe_type', name=_('PoE')), + FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), + FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), + FieldSet( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', - )), + name=_('Wireless') + ), ) nullable_fields = ( - 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description', - 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', - 'tagged_vlans', 'vrf', 'wireless_lans' + 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', + 'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', + 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans' ) def __init__(self, *args, **kwargs): @@ -1488,7 +1491,7 @@ class FrontPortBulkEditForm( model = FrontPort fieldsets = ( - (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')), + FieldSet('module', 'type', 'label', 'color', 'description', 'mark_connected'), ) nullable_fields = ('module', 'label', 'description', 'color') @@ -1505,7 +1508,7 @@ class RearPortBulkEditForm( model = RearPort fieldsets = ( - (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')), + FieldSet('module', 'type', 'label', 'color', 'description', 'mark_connected'), ) nullable_fields = ('module', 'label', 'description', 'color') @@ -1516,7 +1519,7 @@ class ModuleBayBulkEditForm( ): model = ModuleBay fieldsets = ( - (None, ('label', 'position', 'description')), + FieldSet('label', 'position', 'description'), ) nullable_fields = ('label', 'position', 'description') @@ -1527,7 +1530,7 @@ class DeviceBayBulkEditForm( ): model = DeviceBay fieldsets = ( - (None, ('label', 'description')), + FieldSet('label', 'description'), ) nullable_fields = ('label', 'description') @@ -1554,7 +1557,7 @@ class InventoryItemBulkEditForm( model = InventoryItem fieldsets = ( - (None, ('device', 'label', 'role', 'manufacturer', 'part_id', 'description')), + FieldSet('device', 'label', 'role', 'manufacturer', 'part_id', 'description'), ) nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') @@ -1576,7 +1579,7 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): model = InventoryItemRole fieldsets = ( - (None, ('color', 'description')), + FieldSet('color', 'description'), ) nullable_fields = ('color', 'description') @@ -1599,6 +1602,6 @@ class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm): ) model = VirtualDeviceContext fieldsets = ( - (None, ('device', 'status', 'tenant')), + FieldSet('device', 'status', 'tenant'), ) nullable_fields = ('device', 'tenant', ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index e35055851..4e8e3491c 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -12,7 +12,8 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField -from utilities.forms.widgets import APISelectMultiple, NumberWithOptions +from utilities.forms.rendering import FieldSet +from utilities.forms.widgets import NumberWithOptions from vpn.models import L2VPN from wireless.choices import * @@ -132,8 +133,8 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( - (None, ('q', 'filter_id', 'tag', 'parent_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')) + FieldSet('q', 'filter_id', 'tag', 'parent_id'), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) ) parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -146,8 +147,8 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( - (None, ('q', 'filter_id', 'tag', 'parent_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')) + FieldSet('q', 'filter_id', 'tag', 'parent_id'), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) ) parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), @@ -160,10 +161,10 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Site fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('status', 'region_id', 'group_id', 'asn_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) selector_fields = ('filter_id', 'q', 'region_id', 'group_id') status = forms.MultipleChoiceField( @@ -192,10 +193,10 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Location fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -241,13 +242,13 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm): class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Rack fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')), - (_('Function'), ('status', 'role_id')), - (_('Hardware'), ('type', 'width', 'serial', 'asset_tag')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), - (_('Weight'), ('weight', 'max_weight', 'weight_unit')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), + FieldSet('status', 'role_id', name=_('Function')), + FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), + FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id') region_id = DynamicModelMultipleChoiceField( @@ -326,13 +327,13 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte class RackElevationFilterForm(RackFilterForm): fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'id')), - (_('Function'), ('status', 'role_id')), - (_('Hardware'), ('type', 'width', 'serial', 'asset_tag')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), - (_('Weight'), ('weight', 'max_weight', 'weight_unit')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')), + FieldSet('status', 'role_id', name=_('Function')), + FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), + FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), @@ -348,10 +349,10 @@ class RackElevationFilterForm(RackFilterForm): class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('User'), ('user_id',)), - (_('Rack'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('user_id', name=_('User')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -401,8 +402,8 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')) + FieldSet('q', 'filter_id', 'tag'), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) ) tag = TagFilterField(model) @@ -410,14 +411,16 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Hardware'), ('manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow')), - (_('Images'), ('has_front_image', 'has_rear_image')), - (_('Components'), ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet( + 'manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow', name=_('Hardware') + ), + FieldSet('has_front_image', 'has_rear_image', name=_('Images')), + FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', - 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', - )), - (_('Weight'), ('weight', 'weight_unit')), + 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components') + ), + FieldSet('weight', 'weight_unit', name=_('Weight')), ) selector_fields = ('filter_id', 'q', 'manufacturer_id') manufacturer_id = DynamicModelMultipleChoiceField( @@ -536,13 +539,13 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Hardware'), ('manufacturer_id', 'part_number')), - (_('Components'), ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('manufacturer_id', 'part_number', name=_('Hardware')), + FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', - 'pass_through_ports', - )), - (_('Weight'), ('weight', 'weight_unit')), + 'pass_through_ports', name=_('Components') + ), + FieldSet('weight', 'weight_unit', name=_('Weight')), ) selector_fields = ('filter_id', 'q', 'manufacturer_id') manufacturer_id = DynamicModelMultipleChoiceField( @@ -642,18 +645,20 @@ class DeviceFilterForm( ): model = Device fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Operation'), ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), - (_('Hardware'), ('manufacturer_id', 'device_type_id', 'platform_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), - (_('Components'), ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')), + FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), + FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', - )), - (_('Miscellaneous'), ( + name=_('Components') + ), + FieldSet( 'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data', - )) + name=_('Miscellaneous') + ) ) selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id') region_id = DynamicModelMultipleChoiceField( @@ -817,9 +822,9 @@ class VirtualDeviceContextFilterForm( ): model = VirtualDeviceContext fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('device', 'status', 'has_primary_ip')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) device = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -844,8 +849,8 @@ class VirtualDeviceContextFilterForm( class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Module fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Hardware'), ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')), ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -879,9 +884,9 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualChassis fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -908,10 +913,10 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')), - (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')), + FieldSet('type', 'status', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -992,9 +997,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = PowerPanel fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) selector_fields = ('filter_id', 'q', 'site_id', 'location_id') region_id = DynamicModelMultipleChoiceField( @@ -1031,10 +1036,10 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = PowerFeed fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Attributes'), ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -1141,11 +1146,11 @@ class PathEndpointFilterForm(CabledFilterForm): class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsolePort fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type', 'speed')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1163,11 +1168,11 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsoleServerPort fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type', 'speed')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1185,11 +1190,11 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerPort fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1202,11 +1207,11 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerOutlet fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1219,14 +1224,14 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), - (_('Addressing'), ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')), - (_('PoE'), ('poe_mode', 'poe_type')), - (_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')), + FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')), + FieldSet('poe_mode', 'poe_type', name=_('PoE')), + FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) selector_fields = ('filter_id', 'q', 'device_id') vdc_id = DynamicModelMultipleChoiceField( @@ -1330,11 +1335,11 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type', 'color')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Cable'), ('cabled', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'occupied', name=_('Cable')), ) model = FrontPort type = forms.MultipleChoiceField( @@ -1352,11 +1357,11 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = RearPort fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type', 'color')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Cable'), ('cabled', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'occupied', name=_('Cable')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1373,10 +1378,10 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class ModuleBayFilterForm(DeviceComponentFilterForm): model = ModuleBay fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'position')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'position', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), ) tag = TagFilterField(model) position = forms.CharField( @@ -1388,10 +1393,10 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), ) tag = TagFilterField(model) @@ -1399,10 +1404,13 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet( + 'name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered', + name=_('Attributes') + ), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), ) role_id = DynamicModelMultipleChoiceField( queryset=InventoryItemRole.objects.all(), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index e0c25dbba..3559aabc6 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -16,7 +16,7 @@ from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField, ) -from utilities.forms.rendering import InlineFields, TabbedGroups +from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK from virtualization.models import Cluster from wireless.models import WirelessLAN, WirelessLANGroup @@ -78,9 +78,7 @@ class RegionForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Region'), ( - 'parent', 'name', 'slug', 'description', 'tags', - )), + FieldSet('parent', 'name', 'slug', 'description', 'tags'), ) class Meta: @@ -99,9 +97,7 @@ class SiteGroupForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Site Group'), ( - 'parent', 'name', 'slug', 'description', 'tags', - )), + FieldSet('parent', 'name', 'slug', 'description', 'tags'), ) class Meta: @@ -136,11 +132,12 @@ class SiteForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Site'), ( + FieldSet( 'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags', - )), - (_('Tenancy'), ('tenant_group', 'tenant')), - (_('Contact Info'), ('physical_address', 'shipping_address', 'latitude', 'longitude')), + name=_('Site') + ), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet('physical_address', 'shipping_address', 'latitude', 'longitude', name=_('Contact Info')), ) class Meta: @@ -180,8 +177,8 @@ class LocationForm(TenancyForm, NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Location'), ('site', 'parent', 'name', 'slug', 'status', 'facility', 'description', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('site', 'parent', 'name', 'slug', 'status', 'facility', 'description', 'tags', name=_('Location')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -195,9 +192,7 @@ class RackRoleForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Rack Role'), ( - 'name', 'slug', 'color', 'description', 'tags', - )), + FieldSet('name', 'slug', 'color', 'description', 'tags', name=_('Rack Role')), ) class Meta: @@ -229,19 +224,15 @@ class RackForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Rack'), ('site', 'location', 'name', 'status', 'role', 'description', 'tags')), - (_('Inventory Control'), ('facility_id', 'serial', 'asset_tag')), - (_('Tenancy'), ('tenant_group', 'tenant')), - (_('Dimensions'), ( - 'type', - 'width', - 'starting_unit', - 'u_height', + FieldSet('site', 'location', 'name', 'status', 'role', 'description', 'tags', name=_('Rack')), + FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet( + 'type', 'width', 'starting_unit', 'u_height', InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), - 'mounting_depth', - 'desc_units', - )), + 'mounting_depth', 'desc_units', name=_('Dimensions') + ), ) class Meta: @@ -273,8 +264,8 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Reservation'), ('rack', 'units', 'user', 'description', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('rack', 'units', 'user', 'description', 'tags', name=_('Reservation')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -288,9 +279,7 @@ class ManufacturerForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Manufacturer'), ( - 'name', 'slug', 'description', 'tags', - )), + FieldSet('name', 'slug', 'description', 'tags', name=_('Manufacturer')), ) class Meta: @@ -321,12 +310,12 @@ class DeviceTypeForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')), - (_('Chassis'), ( + FieldSet('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags', name=_('Device Type')), + FieldSet( 'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', - 'weight', 'weight_unit', - )), - (_('Images'), ('front_image', 'rear_image')), + 'weight', 'weight_unit', name=_('Chassis') + ), + FieldSet('front_image', 'rear_image', name=_('Images')), ) class Meta: @@ -354,8 +343,8 @@ class ModuleTypeForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Module Type'), ('manufacturer', 'model', 'part_number', 'description', 'tags')), - (_('Weight'), ('weight', 'weight_unit')) + FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), + FieldSet('weight', 'weight_unit', name=_('Weight')) ) class Meta: @@ -374,9 +363,9 @@ class DeviceRoleForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Device Role'), ( - 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', - )), + FieldSet( + 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', name=_('Device Role') + ), ) class Meta: @@ -403,7 +392,7 @@ class PlatformForm(NetBoxModelForm): ) fieldsets = ( - (_('Platform'), ('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags')), + FieldSet('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', name=_('Platform')), ) class Meta: @@ -618,10 +607,8 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm): ) fieldsets = ( - (_('Module'), ('device', 'module_bay', 'module_type', 'status', 'description', 'tags')), - (_('Hardware'), ( - 'serial', 'asset_tag', 'replicate_components', 'adopt_components', - )), + FieldSet('device', 'module_bay', 'module_type', 'status', 'description', 'tags', name=_('Module')), + FieldSet('serial', 'asset_tag', 'replicate_components', 'adopt_components', name=_('Hardware')), ) class Meta: @@ -675,7 +662,7 @@ class PowerPanelForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Power Panel', ('site', 'location', 'name', 'description', 'tags')), + FieldSet('site', 'location', 'name', 'description', 'tags', name=_('Power Panel')), ) class Meta: @@ -700,9 +687,12 @@ class PowerFeedForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Power Feed'), ('power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')), - (_('Characteristics'), ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet( + 'power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags', + name=_('Power Feed') + ), + FieldSet('supply', 'voltage', 'amperage', 'phase', 'max_utilization', name=_('Characteristics')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -849,7 +839,7 @@ class ModularComponentTemplateForm(ComponentTemplateForm): class ConsolePortTemplateForm(ModularComponentTemplateForm): fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')), + FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'), ) class Meta: @@ -861,7 +851,7 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm): class ConsoleServerPortTemplateForm(ModularComponentTemplateForm): fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')), + FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'), ) class Meta: @@ -873,9 +863,9 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm): class PowerPortTemplateForm(ModularComponentTemplateForm): fieldsets = ( - (None, ( + FieldSet( 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - )), + ), ) class Meta: @@ -896,7 +886,7 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm): ) fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')), + FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'), ) class Meta: @@ -918,9 +908,11 @@ class InterfaceTemplateForm(ModularComponentTemplateForm): ) fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge')), - (_('PoE'), ('poe_mode', 'poe_type')), - (_('Wireless'), ('rf_role',)), + FieldSet( + 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge', + ), + FieldSet('poe_mode', 'poe_type', name=_('PoE')), + FieldSet('rf_role', name=_('Wireless')), ) class Meta: @@ -942,10 +934,10 @@ class FrontPortTemplateForm(ModularComponentTemplateForm): ) fieldsets = ( - (None, ( + FieldSet( 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', - )), + ), ) class Meta: @@ -958,7 +950,7 @@ class FrontPortTemplateForm(ModularComponentTemplateForm): class RearPortTemplateForm(ModularComponentTemplateForm): fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description')), + FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description'), ) class Meta: @@ -970,7 +962,7 @@ class RearPortTemplateForm(ModularComponentTemplateForm): class ModuleBayTemplateForm(ComponentTemplateForm): fieldsets = ( - (None, ('device_type', 'name', 'label', 'position', 'description')), + FieldSet('device_type', 'name', 'label', 'position', 'description'), ) class Meta: @@ -982,7 +974,7 @@ class ModuleBayTemplateForm(ComponentTemplateForm): class DeviceBayTemplateForm(ComponentTemplateForm): fieldsets = ( - (None, ('device_type', 'name', 'label', 'description')), + FieldSet('device_type', 'name', 'label', 'description'), ) class Meta: @@ -1023,10 +1015,10 @@ class InventoryItemTemplateForm(ComponentTemplateForm): ) fieldsets = ( - (None, ( + FieldSet( 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', 'component_type', 'component_id', - )), + ), ) class Meta: @@ -1069,9 +1061,9 @@ class ModularDeviceComponentForm(DeviceComponentForm): class ConsolePortForm(ModularDeviceComponentForm): fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1082,11 +1074,10 @@ class ConsolePortForm(ModularDeviceComponentForm): class ConsoleServerPortForm(ModularDeviceComponentForm): - fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1097,12 +1088,11 @@ class ConsoleServerPortForm(ModularDeviceComponentForm): class PowerPortForm(ModularDeviceComponentForm): - fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1124,10 +1114,10 @@ class PowerOutletForm(ModularDeviceComponentForm): ) fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1223,15 +1213,18 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): ) fieldsets = ( - (_('Interface'), ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')), - (_('Addressing'), ('vrf', 'mac_address', 'wwn')), - (_('Operation'), ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), - (_('Related Interfaces'), ('parent', 'bridge', 'lag')), - (_('PoE'), ('poe_mode', 'poe_type')), - (_('802.1Q Switching'), ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), - (_('Wireless'), ( + FieldSet( + 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags', name=_('Interface') + ), + FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')), + FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), + FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), + FieldSet('poe_mode', 'poe_type', name=_('PoE')), + FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), + FieldSet( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', - )), + name=_('Wireless') + ), ) class Meta: @@ -1262,10 +1255,10 @@ class FrontPortForm(ModularDeviceComponentForm): ) fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1278,9 +1271,9 @@ class FrontPortForm(ModularDeviceComponentForm): class RearPortForm(ModularDeviceComponentForm): fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1292,7 +1285,7 @@ class RearPortForm(ModularDeviceComponentForm): class ModuleBayForm(DeviceComponentForm): fieldsets = ( - (None, ('device', 'name', 'label', 'position', 'description', 'tags',)), + FieldSet('device', 'name', 'label', 'position', 'description', 'tags',), ) class Meta: @@ -1304,7 +1297,7 @@ class ModuleBayForm(DeviceComponentForm): class DeviceBayForm(DeviceComponentForm): fieldsets = ( - (None, ('device', 'name', 'label', 'description', 'tags',)), + FieldSet('device', 'name', 'label', 'description', 'tags',), ) class Meta: @@ -1412,19 +1405,20 @@ class InventoryItemForm(DeviceComponentForm): ) fieldsets = ( - (_('Inventory Item'), ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')), - (_('Hardware'), ('manufacturer', 'part_id', 'serial', 'asset_tag')), - (_('Component Assignment'), ( + FieldSet('device', 'parent', 'name', 'label', 'role', 'description', 'tags', name=_('Inventory Item')), + FieldSet('manufacturer', 'part_id', 'serial', 'asset_tag', name=_('Hardware')), + FieldSet( TabbedGroups( - (_('Interface'), 'interface'), - (_('Console Port'), 'consoleport'), - (_('Console Server Port'), 'consoleserverport'), - (_('Front Port'), 'frontport'), - (_('Rear Port'), 'rearport'), - (_('Power Port'), 'powerport'), - (_('Power Outlet'), 'poweroutlet'), + FieldSet('interface', name=_('Interface')), + FieldSet('consoleport', name=_('Console Port')), + FieldSet('consoleserverport', name=_('Console Server Port')), + FieldSet('frontport', name=_('Front Port')), + FieldSet('rearport', name=_('Rear Port')), + FieldSet('powerport', name=_('Power Port')), + FieldSet('poweroutlet', name=_('Power Outlet')), ), - )) + name=_('Component Assignment') + ) ) class Meta: @@ -1484,9 +1478,7 @@ class InventoryItemRoleForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Inventory Item Role'), ( - 'name', 'slug', 'color', 'description', 'tags', - )), + FieldSet('name', 'slug', 'color', 'description', 'tags', name=_('Inventory Item Role')), ) class Meta: @@ -1522,8 +1514,11 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm): ) fieldsets = ( - (_('Virtual Device Context'), ('device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')) + FieldSet( + 'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags', + name=_('Virtual Device Context') + ), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')) ) class Meta: diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index ea842508f..f811700b4 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.models import * from netbox.forms import NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import APISelect from . import model_forms @@ -113,7 +114,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp # Override fieldsets from FrontPortTemplateForm to omit rear_port_position fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description')), + FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description'), ) class Meta(model_forms.FrontPortTemplateForm.Meta): @@ -274,9 +275,9 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): # Override fieldsets from FrontPortForm to omit rear_port_position fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta(model_forms.FrontPortForm.Meta): diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 73751872f..d4235c465 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -13,6 +13,7 @@ from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_ch from utilities.forms.fields import ( ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField, ) +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import APISelectMultiple, DateTimePicker from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -36,11 +37,11 @@ __all__ = ( class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Attributes'), ( + FieldSet('q', 'filter_id'), + FieldSet( 'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', - 'ui_editable', 'is_cloneable', - )), + 'ui_editable', 'is_cloneable', name=_('Attributes') + ), ) related_object_type_id = ContentTypeMultipleChoiceField( queryset=ObjectType.objects.with_feature('custom_fields'), @@ -93,8 +94,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Choices'), ('base_choices', 'choice')), + FieldSet('q', 'filter_id'), + FieldSet('base_choices', 'choice', name=_('Choices')), ) base_choices = forms.MultipleChoiceField( choices=CustomFieldChoiceSetBaseChoices, @@ -107,8 +108,8 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm): class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Attributes'), ('object_type', 'enabled', 'new_window', 'weight')), + FieldSet('q', 'filter_id'), + FieldSet('object_type', 'enabled', 'new_window', 'weight', name=_('Attributes')), ) object_type = ContentTypeMultipleChoiceField( label=_('Object types'), @@ -137,9 +138,9 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Data'), ('data_source_id', 'data_file_id')), - (_('Attributes'), ('object_type_id', 'mime_type', 'file_extension', 'as_attachment')), + FieldSet('q', 'filter_id'), + FieldSet('data_source_id', 'data_file_id', name=_('Data')), + FieldSet('object_type_id', 'mime_type', 'file_extension', 'as_attachment', name=_('Attributes')), ) data_source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -178,8 +179,8 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Attributes'), ('object_type_id', 'name',)), + FieldSet('q', 'filter_id'), + FieldSet('object_type_id', 'name', name=_('Attributes')), ) object_type_id = ContentTypeChoiceField( label=_('Object type'), @@ -194,8 +195,8 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm): class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Attributes'), ('object_type', 'enabled', 'shared', 'weight')), + FieldSet('q', 'filter_id'), + FieldSet('object_type', 'enabled', 'shared', 'weight', name=_('Attributes')), ) object_type = ContentTypeMultipleChoiceField( label=_('Object types'), @@ -225,8 +226,8 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): class WebhookFilterForm(NetBoxModelFilterSetForm): model = Webhook fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('payload_url', 'http_method', 'http_content_type')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('payload_url', 'http_method', 'http_content_type', name=_('Attributes')), ) http_content_type = forms.CharField( label=_('HTTP content type'), @@ -249,9 +250,9 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('object_type_id', 'action_type', 'enabled')), - (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('object_type_id', 'action_type', 'enabled', name=_('Attributes')), + FieldSet('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', name=_('Events')), ) object_type_id = ContentTypeMultipleChoiceField( queryset=ObjectType.objects.with_feature('event_rules'), @@ -323,12 +324,12 @@ class TagFilterForm(SavedFiltersMixin, FilterForm): class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id', 'tag_id')), - (_('Data'), ('data_source_id', 'data_file_id')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')), - (_('Device'), ('device_type_id', 'platform_id', 'role_id')), - (_('Cluster'), ('cluster_type_id', 'cluster_group_id', 'cluster_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')) + FieldSet('q', 'filter_id', 'tag_id'), + FieldSet('data_source_id', 'data_file_id', name=_('Data')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), + FieldSet('device_type_id', 'platform_id', 'role_id', name=_('Device')), + FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')) ) data_source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -412,8 +413,8 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Data'), ('data_source_id', 'data_file_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('data_source_id', 'data_file_id', name=_('Data')), ) data_source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -444,9 +445,9 @@ class LocalConfigContextFilterForm(forms.Form): class JournalEntryFilterForm(NetBoxModelFilterSetForm): model = JournalEntry fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Creation'), ('created_before', 'created_after', 'created_by_id')), - (_('Attributes'), ('assigned_object_type_id', 'kind')) + FieldSet('q', 'filter_id', 'tag'), + FieldSet('created_before', 'created_after', 'created_by_id', name=_('Creation')), + FieldSet('assigned_object_type_id', 'kind', name=_('Attributes')), ) created_after = forms.DateTimeField( required=False, @@ -482,9 +483,9 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): model = ObjectChange fieldsets = ( - (None, ('q', 'filter_id')), - (_('Time'), ('time_before', 'time_after')), - (_('Attributes'), ('action', 'user_id', 'changed_object_type_id')), + FieldSet('q', 'filter_id'), + FieldSet('time_before', 'time_after', name=_('Time')), + FieldSet('action', 'user_id', 'changed_object_type_id', name=_('Attributes')), ) time_after = forms.DateTimeField( required=False, diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 4e62b3ab7..680bec1e4 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -17,7 +17,7 @@ from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, ) -from utilities.forms.rendering import ObjectAttribute +from utilities.forms.rendering import FieldSet, ObjectAttribute from utilities.forms.widgets import ChoicesWidget, HTMXSelect from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -55,12 +55,15 @@ class CustomFieldForm(forms.ModelForm): ) fieldsets = ( - (_('Custom Field'), ( + FieldSet( 'object_types', 'name', 'label', 'group_name', 'type', 'related_object_type', 'required', 'description', - )), - (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')), - (_('Values'), ('default', 'choice_set')), - (_('Validation'), ('validation_minimum', 'validation_maximum', 'validation_regex')), + name=_('Custom Field') + ), + FieldSet( + 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior') + ), + FieldSet('default', 'choice_set', name=_('Values')), + FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')), ) class Meta: @@ -129,8 +132,11 @@ class CustomLinkForm(forms.ModelForm): ) fieldsets = ( - (_('Custom Link'), ('name', 'object_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), - (_('Templates'), ('link_text', 'link_url')), + FieldSet( + 'name', 'object_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window', + name=_('Custom Link') + ), + FieldSet('link_text', 'link_url', name=_('Templates')), ) class Meta: @@ -163,9 +169,9 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm): ) fieldsets = ( - (_('Export Template'), ('name', 'object_types', 'description', 'template_code')), - (_('Data Source'), ('data_source', 'data_file', 'auto_sync_enabled')), - (_('Rendering'), ('mime_type', 'file_extension', 'as_attachment')), + FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')), + FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), + FieldSet('mime_type', 'file_extension', 'as_attachment', name=_('Rendering')), ) class Meta: @@ -200,8 +206,8 @@ class SavedFilterForm(forms.ModelForm): parameters = JSONField() fieldsets = ( - (_('Saved Filter'), ('name', 'slug', 'object_types', 'description', 'weight', 'enabled', 'shared')), - (_('Parameters'), ('parameters',)), + FieldSet('name', 'slug', 'object_types', 'description', 'weight', 'enabled', 'shared', name=_('Saved Filter')), + FieldSet('parameters', name=_('Parameters')), ) class Meta: @@ -232,11 +238,12 @@ class BookmarkForm(forms.ModelForm): class WebhookForm(NetBoxModelForm): fieldsets = ( - (_('Webhook'), ('name', 'description', 'tags',)), - (_('HTTP Request'), ( + FieldSet('name', 'description', 'tags', name=_('Webhook')), + FieldSet( 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - )), - (_('SSL'), ('ssl_verification', 'ca_file_path')), + name=_('HTTP Request') + ), + FieldSet('ssl_verification', 'ca_file_path', name=_('SSL')), ) class Meta: @@ -267,12 +274,13 @@ class EventRuleForm(NetBoxModelForm): ) fieldsets = ( - (_('Event Rule'), ('name', 'description', 'object_types', 'enabled', 'tags')), - (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')), - (_('Conditions'), ('conditions',)), - (_('Action'), ( + FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')), + FieldSet('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', name=_('Events')), + FieldSet('conditions', name=_('Conditions')), + FieldSet( 'action_type', 'action_choice', 'action_object_type', 'action_object_id', 'action_data', - )), + name=_('Action') + ), ) class Meta: @@ -361,7 +369,7 @@ class TagForm(forms.ModelForm): ) fieldsets = ( - ('Tag', ('name', 'slug', 'color', 'description', 'object_types')), + FieldSet('name', 'slug', 'color', 'description', 'object_types', name=_('Tag')), ) class Meta: @@ -443,12 +451,13 @@ class ConfigContextForm(SyncedDataMixin, forms.ModelForm): ) fieldsets = ( - (_('Config Context'), ('name', 'weight', 'description', 'data', 'is_active')), - (_('Data Source'), ('data_source', 'data_file', 'auto_sync_enabled')), - (_('Assignment'), ( + FieldSet('name', 'weight', 'description', 'data', 'is_active', name=_('Config Context')), + FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), + FieldSet( 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', - )), + name=_('Assignment') + ), ) class Meta: @@ -495,9 +504,9 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm): ) fieldsets = ( - (_('Config Template'), ('name', 'description', 'environment_params', 'tags')), - (_('Content'), ('template_code',)), - (_('Data Source'), ('data_source', 'data_file', 'auto_sync_enabled')), + FieldSet('name', 'description', 'environment_params', 'tags', name=_('Config Template')), + FieldSet('template_code', name=_('Content')), + FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), ) class Meta: @@ -528,7 +537,7 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm): class ImageAttachmentForm(forms.ModelForm): fieldsets = ( - (None, (ObjectAttribute('parent'), 'name', 'image')), + FieldSet(ObjectAttribute('parent'), 'name', 'image'), ) class Meta: diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 72d57e941..c7f64ab1d 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -13,6 +13,7 @@ from utilities.forms import add_blank_choice from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, ) +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect from virtualization.models import Cluster, ClusterGroup @@ -55,7 +56,7 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): model = VRF fieldsets = ( - (None, ('tenant', 'enforce_unique', 'description')), + FieldSet('tenant', 'enforce_unique', 'description'), ) nullable_fields = ('tenant', 'description', 'comments') @@ -75,7 +76,7 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): model = RouteTarget fieldsets = ( - (None, ('tenant', 'description')), + FieldSet('tenant', 'description'), ) nullable_fields = ('tenant', 'description', 'comments') @@ -94,7 +95,7 @@ class RIRBulkEditForm(NetBoxModelBulkEditForm): model = RIR fieldsets = ( - (None, ('is_private', 'description')), + FieldSet('is_private', 'description'), ) nullable_fields = ('is_private', 'description') @@ -118,7 +119,7 @@ class ASNRangeBulkEditForm(NetBoxModelBulkEditForm): model = ASNRange fieldsets = ( - (None, ('rir', 'tenant', 'description')), + FieldSet('rir', 'tenant', 'description'), ) nullable_fields = ('description',) @@ -148,7 +149,7 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): model = ASN fieldsets = ( - (None, ('sites', 'rir', 'tenant', 'description')), + FieldSet('sites', 'rir', 'tenant', 'description'), ) nullable_fields = ('tenant', 'description', 'comments') @@ -177,7 +178,7 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): model = Aggregate fieldsets = ( - (None, ('rir', 'tenant', 'date_added', 'description')), + FieldSet('rir', 'tenant', 'date_added', 'description'), ) nullable_fields = ('date_added', 'description', 'comments') @@ -195,7 +196,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): model = Role fieldsets = ( - (None, ('weight', 'description')), + FieldSet('weight', 'description'), ) nullable_fields = ('description',) @@ -265,9 +266,9 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): model = Prefix fieldsets = ( - (None, ('tenant', 'status', 'role', 'description')), - (_('Site'), ('region', 'site_group', 'site')), - (_('Addressing'), ('vrf', 'prefix_length', 'is_pool', 'mark_utilized')), + FieldSet('tenant', 'status', 'role', 'description'), + FieldSet('region', 'site_group', 'site', name=_('Site')), + FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')), ) nullable_fields = ( 'site', 'vrf', 'tenant', 'role', 'description', 'comments', @@ -309,7 +310,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): model = IPRange fieldsets = ( - (None, ('status', 'role', 'vrf', 'tenant', 'mark_utilized', 'description')), + FieldSet('status', 'role', 'vrf', 'tenant', 'mark_utilized', 'description'), ) nullable_fields = ( 'vrf', 'tenant', 'role', 'description', 'comments', @@ -357,8 +358,8 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): model = IPAddress fieldsets = ( - (None, ('status', 'role', 'tenant', 'description')), - (_('Addressing'), ('vrf', 'mask_length', 'dns_name')), + FieldSet('status', 'role', 'tenant', 'description'), + FieldSet('vrf', 'mask_length', 'dns_name', name=_('Addressing')), ) nullable_fields = ( 'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments', @@ -400,8 +401,8 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): model = FHRPGroup fieldsets = ( - (None, ('protocol', 'group_id', 'name', 'description')), - (_('Authentication'), ('auth_type', 'auth_key')), + FieldSet('protocol', 'group_id', 'name', 'description'), + FieldSet('auth_type', 'auth_key', name=_('Authentication')), ) nullable_fields = ('auth_type', 'auth_key', 'name', 'description', 'comments') @@ -485,8 +486,10 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): model = VLANGroup fieldsets = ( - (None, ('site', 'min_vid', 'max_vid', 'description')), - (_('Scope'), ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), + FieldSet('site', 'min_vid', 'max_vid', 'description'), + FieldSet( + 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope') + ), ) nullable_fields = ('description',) @@ -556,8 +559,8 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): model = VLAN fieldsets = ( - (None, ('status', 'role', 'tenant', 'description')), - (_('Site & Group'), ('region', 'site_group', 'site', 'group')), + FieldSet('status', 'role', 'tenant', 'description'), + FieldSet('region', 'site_group', 'site', 'group', name=_('Site & Group')), ) nullable_fields = ( 'site', 'group', 'tenant', 'role', 'description', 'comments', @@ -587,7 +590,7 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): model = ServiceTemplate fieldsets = ( - (None, ('protocol', 'ports', 'description')), + FieldSet('protocol', 'ports', 'description'), ) nullable_fields = ('description', 'comments') diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index cf2e4d46e..6610bcaf3 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -9,6 +9,7 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField +from utilities.forms.rendering import FieldSet from virtualization.models import VirtualMachine from vpn.models import L2VPN @@ -42,9 +43,9 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VRF fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Route Targets'), ('import_target_id', 'export_target_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('import_target_id', 'export_target_id', name=_('Route Targets')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) import_target_id = DynamicModelMultipleChoiceField( queryset=RouteTarget.objects.all(), @@ -62,9 +63,9 @@ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RouteTarget fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('VRF'), ('importing_vrf_id', 'exporting_vrf_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('importing_vrf_id', 'exporting_vrf_id', name=_('VRF')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) importing_vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), @@ -94,9 +95,9 @@ class RIRFilterForm(NetBoxModelFilterSetForm): class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Aggregate fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('family', 'rir_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('family', 'rir_id', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) family = forms.ChoiceField( required=False, @@ -114,9 +115,9 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ASNRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = ASNRange fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Range'), ('rir_id', 'start', 'end')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('rir_id', 'start', 'end', name=_('Range')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) rir_id = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), @@ -137,9 +138,9 @@ class ASNRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = ASN fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Assignment'), ('rir_id', 'site_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('rir_id', 'site_id', name=_('Assignment')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) rir_id = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), @@ -162,11 +163,14 @@ class RoleFilterForm(NetBoxModelFilterSetForm): class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Prefix fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Addressing'), ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')), - (_('VRF'), ('vrf_id', 'present_in_vrf_id')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet( + 'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized', + name=_('Addressing') + ), + FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) mask_length__lte = forms.IntegerField( widget=forms.HiddenInput() @@ -251,9 +255,9 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPRange fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_utilized', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) family = forms.ChoiceField( required=False, @@ -290,11 +294,14 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPAddress fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name')), - (_('VRF'), ('vrf_id', 'present_in_vrf_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Device/VM'), ('device_id', 'virtual_machine_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet( + 'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name', + name=_('Attributes') + ), + FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')), ) selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role') parent = forms.CharField( @@ -364,9 +371,9 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class FHRPGroupFilterForm(NetBoxModelFilterSetForm): model = FHRPGroup fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'protocol', 'group_id')), - (_('Authentication'), ('auth_type', 'auth_key')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'protocol', 'group_id', name=_('Attributes')), + FieldSet('auth_type', 'auth_key', name=_('Authentication')), ) name = forms.CharField( label=_('Name'), @@ -396,9 +403,9 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm): class VLANGroupFilterForm(NetBoxModelFilterSetForm): fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region', 'sitegroup', 'site', 'location', 'rack')), - (_('VLAN ID'), ('min_vid', 'max_vid')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')), + FieldSet('min_vid', 'max_vid', name=_('VLAN ID')), ) model = VLANGroup region = DynamicModelMultipleChoiceField( @@ -444,10 +451,10 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm): class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VLAN fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Attributes'), ('group_id', 'status', 'role_id', 'vid', 'l2vpn_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) selector_fields = ('filter_id', 'q', 'site_id') region_id = DynamicModelMultipleChoiceField( @@ -504,8 +511,8 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): model = ServiceTemplate fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('protocol', 'port')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('protocol', 'port', name=_('Attributes')), ) protocol = forms.ChoiceField( label=_('Protocol'), @@ -522,9 +529,9 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): class ServiceFilterForm(ServiceTemplateFilterForm): model = Service fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('protocol', 'port')), - (_('Assignment'), ('device_id', 'virtual_machine_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('protocol', 'port', name=_('Attributes')), + FieldSet('device_id', 'virtual_machine_id', name=_('Assignment')), ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 0aba37fb9..0db9576f1 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -16,7 +16,7 @@ from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, SlugField, ) -from utilities.forms.rendering import InlineFields, ObjectAttribute, TabbedGroups +from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups from utilities.forms.widgets import DatePicker from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface @@ -57,9 +57,9 @@ class VRFForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('VRF'), ('name', 'rd', 'enforce_unique', 'description', 'tags')), - (_('Route Targets'), ('import_targets', 'export_targets')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('name', 'rd', 'enforce_unique', 'description', 'tags', name=_('VRF')), + FieldSet('import_targets', 'export_targets', name=_('Route Targets')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -75,8 +75,8 @@ class VRFForm(TenancyForm, NetBoxModelForm): class RouteTargetForm(TenancyForm, NetBoxModelForm): fieldsets = ( - ('Route Target', ('name', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), + FieldSet('name', 'description', 'tags', name=_('Route Target')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) comments = CommentField() @@ -91,9 +91,7 @@ class RIRForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('RIR'), ( - 'name', 'slug', 'is_private', 'description', 'tags', - )), + FieldSet('name', 'slug', 'is_private', 'description', 'tags', name=_('RIR')), ) class Meta: @@ -111,8 +109,8 @@ class AggregateForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Aggregate'), ('prefix', 'rir', 'date_added', 'description', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('prefix', 'rir', 'date_added', 'description', 'tags', name=_('Aggregate')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -132,8 +130,8 @@ class ASNRangeForm(TenancyForm, NetBoxModelForm): ) slug = SlugField() fieldsets = ( - (_('ASN Range'), ('name', 'slug', 'rir', 'start', 'end', 'description', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('name', 'slug', 'rir', 'start', 'end', 'description', 'tags', name=_('ASN Range')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -156,8 +154,8 @@ class ASNForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('ASN'), ('asn', 'rir', 'sites', 'description', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('asn', 'rir', 'sites', 'description', 'tags', name=_('ASN')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -185,9 +183,7 @@ class RoleForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Role'), ( - 'name', 'slug', 'weight', 'description', 'tags', - )), + FieldSet('name', 'slug', 'weight', 'description', 'tags', name=_('Role')), ) class Meta: @@ -227,9 +223,11 @@ class PrefixForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Prefix'), ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')), - (_('Site/VLAN Assignment'), ('site', 'vlan')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet( + 'prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix') + ), + FieldSet('site', 'vlan', name=_('Site/VLAN Assignment')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -254,8 +252,11 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('IP Range'), ('vrf', 'start_address', 'end_address', 'role', 'status', 'mark_utilized', 'description', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet( + 'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_utilized', 'description', 'tags', + name=_('IP Range') + ), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -309,17 +310,17 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('IP Address'), ('address', 'status', 'role', 'vrf', 'dns_name', 'description', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')), - (_('Assignment'), ( + FieldSet('address', 'status', 'role', 'vrf', 'dns_name', 'description', 'tags', name=_('IP Address')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet( TabbedGroups( - (_('Device'), 'interface'), - (_('Virtual Machine'), 'vminterface'), - (_('FHRP Group'), 'fhrpgroup'), + FieldSet('interface', name=_('Device')), + FieldSet('vminterface', name=_('Virtual Machine')), + FieldSet('fhrpgroup', name=_('FHRP Group')), ), - 'primary_for_parent', - )), - (_('NAT IP (Inside)'), ('nat_inside',)), + 'primary_for_parent', name=_('Assignment') + ), + FieldSet('nat_inside', name=_('NAT IP (Inside)')), ) class Meta: @@ -458,9 +459,9 @@ class FHRPGroupForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('FHRP Group'), ('protocol', 'group_id', 'name', 'description', 'tags')), - (_('Authentication'), ('auth_type', 'auth_key')), - (_('Virtual IP Address'), ('ip_vrf', 'ip_address', 'ip_status')) + FieldSet('protocol', 'group_id', 'name', 'description', 'tags', name=_('FHRP Group')), + FieldSet('auth_type', 'auth_key', name=_('Authentication')), + FieldSet('ip_vrf', 'ip_address', 'ip_status', name=_('Virtual IP Address')) ) class Meta: @@ -518,7 +519,7 @@ class FHRPGroupAssignmentForm(forms.ModelForm): ) fieldsets = ( - (None, (ObjectAttribute('interface'), 'group', 'priority')), + FieldSet(ObjectAttribute('interface'), 'group', 'priority'), ) class Meta: @@ -606,9 +607,12 @@ class VLANGroupForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('VLAN Group'), ('name', 'slug', 'description', 'tags')), - (_('Child VLANs'), ('min_vid', 'max_vid')), - (_('Scope'), ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), + FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')), + FieldSet('min_vid', 'max_vid', name=_('Child VLANs')), + FieldSet( + 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', + name=_('Scope') + ), ) class Meta: @@ -681,9 +685,7 @@ class ServiceTemplateForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Service Template'), ( - 'name', 'protocol', 'ports', 'description', 'tags', - )), + FieldSet('name', 'protocol', 'ports', 'description', 'tags', name=_('Service Template')), ) class Meta: @@ -724,17 +726,15 @@ class ServiceForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Service'), ( + FieldSet( TabbedGroups( - (_('Device'), 'device'), - (_('Virtual Machine'), 'virtual_machine'), + FieldSet('device', name=_('Device')), + FieldSet('virtual_machine', name=_('Virtual Machine')), ), 'name', InlineFields('protocol', 'ports', label=_('Port(s)')), - 'ipaddresses', - 'description', - 'tags', - )), + 'ipaddresses', 'description', 'tags', name=_('Service') + ), ) class Meta: @@ -752,19 +752,17 @@ class ServiceCreateForm(ServiceForm): ) fieldsets = ( - (_('Service'), ( + FieldSet( TabbedGroups( - (_('Device'), 'device'), - (_('Virtual Machine'), 'virtual_machine'), + FieldSet('device', name=_('Device')), + FieldSet('virtual_machine', name=_('Virtual Machine')), ), TabbedGroups( - (_('From Template'), 'service_template'), - (_('Custom'), 'name', 'protocol', 'ports'), + FieldSet('service_template', name=_('From Template')), + FieldSet('name', 'protocol', 'ports', name=_('Custom')), ), - 'ipaddresses', - 'description', - 'tags', - )), + 'ipaddresses', 'description', 'tags', name=_('Service') + ), ) class Meta(ServiceForm.Meta): diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 85064e79d..f63f56ff5 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -24,7 +24,7 @@ class NetBoxModelForm(CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields. Attributes: - fieldsets: An iterable of two-tuples which define a heading and field set to display per section of + fieldsets: An iterable of FieldSets which define a name and set of fields to display per section of the rendered form (optional). If not defined, the all fields will be rendered as a single section. """ fieldsets = () diff --git a/netbox/templates/generic/bulk_edit.html b/netbox/templates/generic/bulk_edit.html index 10788d62f..ebb0fbc0e 100644 --- a/netbox/templates/generic/bulk_edit.html +++ b/netbox/templates/generic/bulk_edit.html @@ -49,14 +49,18 @@ Context: {% if form.fieldsets %} {# Render grouped fields according to declared fieldsets #} - {% for group, fields in form.fieldsets %} + {% for fieldset in form.fieldsets %}
- {% if group %}{{ group }}{% else %}{{ model|meta:"verbose_name"|bettertitle }}{% endif %} + {% if fieldset.name %} + {{ fieldset.name }} + {% else %} + {{ model|meta:"verbose_name"|bettertitle }} + {% endif %}
- {% for name in fields %} + {% for name in fieldset.fields %} {% with field=form|getfield:name %} {% if field.name in form.nullable_fields %} {% render_field field bulk_nullable=True %} diff --git a/netbox/templates/inc/filter_list.html b/netbox/templates/inc/filter_list.html index ac87f252d..407add929 100644 --- a/netbox/templates/inc/filter_list.html +++ b/netbox/templates/inc/filter_list.html @@ -9,14 +9,14 @@ {{ field }} {% endfor %} {# List filters by group #} - {% for heading, fields in filter_form.fieldsets %} + {% for fieldset in filter_form.fieldsets %}
- {% if heading %} + {% if fieldset.name %}
- {{ heading }} + {{ fieldset.name }}
{% endif %} - {% for name in fields %} + {% for name in fieldset.fields %} {% with field=filter_form|get_item:name %} {% render_field field %} {% endwith %} diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 49866ca3e..5af3f22ac 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -6,6 +6,7 @@ from tenancy.choices import ContactPriorityChoices from tenancy.models import * from utilities.forms import add_blank_choice from utilities.forms.fields import CommentField, DynamicModelChoiceField +from utilities.forms.rendering import FieldSet __all__ = ( 'ContactAssignmentBulkEditForm', @@ -46,7 +47,7 @@ class TenantBulkEditForm(NetBoxModelBulkEditForm): model = Tenant fieldsets = ( - (None, ('group',)), + FieldSet('group'), ) nullable_fields = ('group',) @@ -69,7 +70,7 @@ class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): model = ContactGroup fieldsets = ( - (None, ('parent', 'description')), + FieldSet('parent', 'description'), ) nullable_fields = ('parent', 'description') @@ -83,7 +84,7 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): model = ContactRole fieldsets = ( - (None, ('description',)), + FieldSet('description'), ) nullable_fields = ('description',) @@ -126,7 +127,7 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): model = Contact fieldsets = ( - (None, ('group', 'title', 'phone', 'email', 'address', 'link', 'description')), + FieldSet('group', 'title', 'phone', 'email', 'address', 'link', 'description'), ) nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments') @@ -150,6 +151,6 @@ class ContactAssignmentBulkEditForm(NetBoxModelBulkEditForm): model = ContactAssignment fieldsets = ( - (None, ('contact', 'role', 'priority')), + FieldSet('contact', 'role', 'priority'), ) nullable_fields = ('priority',) diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index fbd0f2ad0..960ca45b1 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -9,6 +9,7 @@ from tenancy.forms import ContactModelFilterForm from utilities.forms.fields import ( ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField, ) +from utilities.forms.rendering import FieldSet __all__ = ( 'ContactAssignmentFilterForm', @@ -37,8 +38,8 @@ class TenantGroupFilterForm(NetBoxModelFilterSetForm): class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Tenant fieldsets = ( - (None, ('q', 'filter_id', 'tag', 'group_id')), - ('Contacts', ('contact', 'contact_role', 'contact_group')) + FieldSet('q', 'filter_id', 'tag', 'group_id'), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) ) group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), @@ -82,8 +83,8 @@ class ContactFilterForm(NetBoxModelFilterSetForm): class ContactAssignmentFilterForm(NetBoxModelFilterSetForm): model = ContactAssignment fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Assignment'), ('object_type_id', 'group_id', 'contact_id', 'role_id', 'priority')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('object_type_id', 'group_id', 'contact_id', 'role_id', 'priority', name=_('Assignment')), ) object_type_id = ContentTypeMultipleChoiceField( queryset=ObjectType.objects.with_feature('contacts'), diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index 7dcb4e433..bc18deed6 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelForm from tenancy.models import * from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField -from utilities.forms.rendering import ObjectAttribute +from utilities.forms.rendering import FieldSet, ObjectAttribute __all__ = ( 'ContactAssignmentForm', @@ -29,9 +29,7 @@ class TenantGroupForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Tenant Group'), ( - 'parent', 'name', 'slug', 'description', 'tags', - )), + FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Tenant Group')), ) class Meta: @@ -51,7 +49,7 @@ class TenantForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Tenant'), ('name', 'slug', 'group', 'description', 'tags')), + FieldSet('name', 'slug', 'group', 'description', 'tags', name=_('Tenant')), ) class Meta: @@ -74,9 +72,7 @@ class ContactGroupForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Contact Group'), ( - 'parent', 'name', 'slug', 'description', 'tags', - )), + FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Contact Group')), ) class Meta: @@ -88,9 +84,7 @@ class ContactRoleForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Contact Role'), ( - 'name', 'slug', 'description', 'tags', - )), + FieldSet('name', 'slug', 'description', 'tags', name=_('Contact Role')), ) class Meta: @@ -107,7 +101,10 @@ class ContactForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Contact'), ('group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags')), + FieldSet( + 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags', + name=_('Contact') + ), ) class Meta: @@ -142,7 +139,7 @@ class ContactAssignmentForm(NetBoxModelForm): ) fieldsets = ( - (None, (ObjectAttribute('object'), 'group', 'contact', 'role', 'priority', 'tags')), + FieldSet(ObjectAttribute('object'), 'group', 'contact', 'role', 'priority', 'tags'), ) class Meta: diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py index c56beff14..a26842d09 100644 --- a/netbox/users/forms/bulk_edit.py +++ b/netbox/users/forms/bulk_edit.py @@ -6,6 +6,7 @@ from ipam.formfields import IPNetworkFormField from ipam.validators import prefix_validator from users.models import * from utilities.forms import BulkEditForm +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect, DateTimePicker __all__ = ( @@ -48,7 +49,7 @@ class UserBulkEditForm(forms.Form): model = User fieldsets = ( - (None, ('first_name', 'last_name', 'is_active', 'is_staff', 'is_superuser')), + FieldSet('first_name', 'last_name', 'is_active', 'is_staff', 'is_superuser'), ) nullable_fields = ('first_name', 'last_name') @@ -71,7 +72,7 @@ class ObjectPermissionBulkEditForm(forms.Form): model = ObjectPermission fieldsets = ( - (None, ('enabled', 'description')), + FieldSet('enabled', 'description'), ) nullable_fields = ('description',) @@ -104,7 +105,7 @@ class TokenBulkEditForm(BulkEditForm): model = Token fieldsets = ( - (None, ('write_enabled', 'description', 'expires', 'allowed_ips')), + FieldSet('write_enabled', 'description', 'expires', 'allowed_ips'), ) nullable_fields = ( 'expires', 'description', 'allowed_ips', diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index 23bbe45e1..2d5644b98 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -7,6 +7,7 @@ from netbox.forms.mixins import SavedFiltersMixin from users.models import Group, ObjectPermission, Token, User from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms.fields import DynamicModelMultipleChoiceField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import DateTimePicker __all__ = ( @@ -20,16 +21,16 @@ __all__ = ( class GroupFilterForm(NetBoxModelFilterSetForm): model = Group fieldsets = ( - (None, ('q', 'filter_id',)), + FieldSet('q', 'filter_id',), ) class UserFilterForm(NetBoxModelFilterSetForm): model = User fieldsets = ( - (None, ('q', 'filter_id',)), - (_('Group'), ('group_id',)), - (_('Status'), ('is_active', 'is_staff', 'is_superuser')), + FieldSet('q', 'filter_id',), + FieldSet('group_id', name=_('Group')), + FieldSet('is_active', 'is_staff', 'is_superuser', name=_('Status')), ) group_id = DynamicModelMultipleChoiceField( queryset=Group.objects.all(), @@ -62,9 +63,9 @@ class UserFilterForm(NetBoxModelFilterSetForm): class ObjectPermissionFilterForm(NetBoxModelFilterSetForm): model = ObjectPermission fieldsets = ( - (None, ('q', 'filter_id',)), - (_('Permission'), ('enabled', 'group_id', 'user_id')), - (_('Actions'), ('can_view', 'can_add', 'can_change', 'can_delete')), + FieldSet('q', 'filter_id',), + FieldSet('enabled', 'group_id', 'user_id', name=_('Permission')), + FieldSet('can_view', 'can_add', 'can_change', 'can_delete', name=_('Actions')), ) enabled = forms.NullBooleanField( label=_('Enabled'), @@ -116,8 +117,8 @@ class ObjectPermissionFilterForm(NetBoxModelFilterSetForm): class TokenFilterForm(SavedFiltersMixin, FilterForm): model = Token fieldsets = ( - (None, ('q', 'filter_id',)), - (_('Token'), ('user_id', 'write_enabled', 'expires', 'last_used')), + FieldSet('q', 'filter_id',), + FieldSet('user_id', 'write_enabled', 'expires', 'last_used', name=_('Token')), ) user_id = DynamicModelMultipleChoiceField( queryset=get_user_model().objects.all(), diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 6c717d1ea..1f199d35c 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -13,6 +13,7 @@ from netbox.preferences import PREFERENCES from users.constants import * from users.models import * from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import DateTimePicker from utilities.permissions import qs_filter_from_constraints from utilities.utils import flatten_dict @@ -53,15 +54,10 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass): fieldsets = ( - (_('User Interface'), ( - 'locale.language', - 'pagination.per_page', - 'pagination.placement', - 'ui.colormode', - )), - (_('Miscellaneous'), ( - 'data_format', - )), + FieldSet( + 'locale.language', 'pagination.per_page', 'pagination.placement', 'ui.colormode', name=_('User Interface') + ), + FieldSet('data_format', name=_('Miscellaneous')), ) # List of clearable preferences pk = forms.MultipleChoiceField( @@ -189,10 +185,10 @@ class UserForm(forms.ModelForm): ) fieldsets = ( - (_('User'), ('username', 'password', 'confirm_password', 'first_name', 'last_name', 'email')), - (_('Groups'), ('groups', )), - (_('Status'), ('is_active', 'is_staff', 'is_superuser')), - (_('Permissions'), ('object_permissions',)), + FieldSet('username', 'password', 'confirm_password', 'first_name', 'last_name', 'email', name=_('User')), + FieldSet('groups', name=_('Groups')), + FieldSet('is_active', 'is_staff', 'is_superuser', name=_('Status')), + FieldSet('object_permissions', name=_('Permissions')), ) class Meta: @@ -246,9 +242,9 @@ class GroupForm(forms.ModelForm): ) fieldsets = ( - (None, ('name', )), - (_('Users'), ('users', )), - (_('Permissions'), ('object_permissions', )), + FieldSet('name'), + FieldSet('users', name=_('Users')), + FieldSet('object_permissions', name=_('Permissions')), ) class Meta: @@ -312,11 +308,11 @@ class ObjectPermissionForm(forms.ModelForm): ) fieldsets = ( - (None, ('name', 'description', 'enabled',)), - (_('Actions'), ('can_view', 'can_add', 'can_change', 'can_delete', 'actions')), - (_('Objects'), ('object_types', )), - (_('Assignment'), ('groups', 'users')), - (_('Constraints'), ('constraints',)) + FieldSet('name', 'description', 'enabled'), + FieldSet('can_view', 'can_add', 'can_change', 'can_delete', 'actions', name=_('Actions')), + FieldSet('object_types', name=_('Objects')), + FieldSet('groups', 'users', name=_('Assignment')), + FieldSet('constraints', name=_('Constraints')) ) class Meta: diff --git a/netbox/utilities/forms/rendering.py b/netbox/utilities/forms/rendering.py index ea73c38ff..0d9344131 100644 --- a/netbox/utilities/forms/rendering.py +++ b/netbox/utilities/forms/rendering.py @@ -33,10 +33,11 @@ class TabbedGroups: """ Two or more groups of fields (FieldSets) arranged under tabs among which the user can navigate. """ - def __init__(self, *groups): - self.groups = [ - FieldSet(*group, name=name) for name, *group in groups - ] + def __init__(self, *fieldsets): + for fs in fieldsets: + if not fs.name: + raise ValueError(f"Grouped fieldset {fs} must have a name.") + self.groups = fieldsets # Initialize a random ID for the group (for tab selection) self.id = ''.join( diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index 48a1a5aa8..5365e1c80 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -1,3 +1,5 @@ +import warnings + from django import template from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups @@ -52,8 +54,12 @@ def render_fieldset(form, fieldset): """ Render a group set of fields. """ + # TODO: Remove in NetBox v4.1 # Handle legacy tuple-based fieldset definitions, e.g. (_('Label'), ('field1, 'field2', 'field3')) if type(fieldset) is not FieldSet: + warnings.warn( + f"{form.__class__} fieldsets contains a non-FieldSet item: {fieldset}" + ) name, fields = fieldset fieldset = FieldSet(*fields, name=name) diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index eaf252824..2bd3434ac 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -10,6 +10,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import BulkRenameForm, add_blank_choice from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect from virtualization.choices import * from virtualization.models import * @@ -35,7 +36,7 @@ class ClusterTypeBulkEditForm(NetBoxModelBulkEditForm): model = ClusterType fieldsets = ( - (None, ('description',)), + FieldSet('description'), ) nullable_fields = ('description',) @@ -49,7 +50,7 @@ class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): model = ClusterGroup fieldsets = ( - (None, ('description',)), + FieldSet('description'), ) nullable_fields = ('description',) @@ -104,8 +105,8 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): model = Cluster fieldsets = ( - (None, ('type', 'group', 'status', 'tenant', 'description')), - (_('Site'), ('region', 'site_group', 'site')), + FieldSet('type', 'group', 'status', 'tenant', 'description'), + FieldSet('region', 'site_group', 'site', name=_('Site')), ) nullable_fields = ( 'group', 'site', 'tenant', 'description', 'comments', @@ -185,9 +186,9 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): model = VirtualMachine fieldsets = ( - (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform', 'description')), - (_('Resources'), ('vcpus', 'memory', 'disk')), - ('Configuration', ('config_template',)), + FieldSet('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform', 'description'), + FieldSet('vcpus', 'memory', 'disk', name=_('Resources')), + FieldSet('config_template', name=_('Configuration')), ) nullable_fields = ( 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments', @@ -262,9 +263,9 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): model = VMInterface fieldsets = ( - (None, ('mtu', 'enabled', 'vrf', 'description')), - (_('Related Interfaces'), ('parent', 'bridge')), - (_('802.1Q Switching'), ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), + FieldSet('mtu', 'enabled', 'vrf', 'description'), + FieldSet('parent', 'bridge', name=_('Related Interfaces')), + FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), ) nullable_fields = ( 'parent', 'bridge', 'mtu', 'vrf', 'description', @@ -340,7 +341,7 @@ class VirtualDiskBulkEditForm(NetBoxModelBulkEditForm): model = VirtualDisk fieldsets = ( - (None, ('size', 'description')), + FieldSet('size', 'description'), ) nullable_fields = ('description',) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 5b0d097f8..1cb652a1b 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -9,6 +9,7 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField +from utilities.forms.rendering import FieldSet from virtualization.choices import * from virtualization.models import * from vpn.models import L2VPN @@ -32,19 +33,19 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Cluster fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('group_id', 'type_id', 'status')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('group_id', 'type_id', 'status', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) selector_fields = ('filter_id', 'q', 'group_id') type_id = DynamicModelMultipleChoiceField( @@ -94,12 +95,15 @@ class VirtualMachineFilterForm( ): model = VirtualMachine fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Cluster'), ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Attributes'), ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'config_template_id', 'local_context_data')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id', name=_('Cluster')), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet( + 'status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'config_template_id', + 'local_context_data', name=_('Attributes') + ), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), @@ -185,9 +189,9 @@ class VirtualMachineFilterForm( class VMInterfaceFilterForm(NetBoxModelFilterSetForm): model = VMInterface fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Virtual Machine'), ('cluster_id', 'virtual_machine_id')), - (_('Attributes'), ('enabled', 'mac_address', 'vrf_id', 'l2vpn_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('cluster_id', 'virtual_machine_id', name=_('Virtual Machine')), + FieldSet('enabled', 'mac_address', 'vrf_id', 'l2vpn_id', name=_('Attributes')), ) selector_fields = ('filter_id', 'q', 'virtual_machine_id') cluster_id = DynamicModelMultipleChoiceField( @@ -230,9 +234,9 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm): class VirtualDiskFilterForm(NetBoxModelFilterSetForm): model = VirtualDisk fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Virtual Machine'), ('virtual_machine_id',)), - (_('Attributes'), ('size',)), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('virtual_machine_id', name=_('Virtual Machine')), + FieldSet('size', name=_('Attributes')), ) virtual_machine_id = DynamicModelMultipleChoiceField( queryset=VirtualMachine.objects.all(), diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 186ab8182..bfdfc9ada 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -13,6 +13,7 @@ from utilities.forms import ConfirmationForm from utilities.forms.fields import ( CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, ) +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import HTMXSelect from virtualization.models import * @@ -32,9 +33,7 @@ class ClusterTypeForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Cluster Type'), ( - 'name', 'slug', 'description', 'tags', - )), + FieldSet('name', 'slug', 'description', 'tags', name=_('Cluster Type')), ) class Meta: @@ -48,9 +47,7 @@ class ClusterGroupForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Cluster Group'), ( - 'name', 'slug', 'description', 'tags', - )), + FieldSet('name', 'slug', 'description', 'tags', name=_('Cluster Group')), ) class Meta: @@ -79,8 +76,8 @@ class ClusterForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Cluster'), ('name', 'type', 'group', 'site', 'status', 'description', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('name', 'type', 'group', 'site', 'status', 'description', 'tags', name=_('Cluster')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -220,12 +217,12 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Virtual Machine'), ('name', 'role', 'status', 'description', 'tags')), - (_('Site/Cluster'), ('site', 'cluster', 'device')), - (_('Tenancy'), ('tenant_group', 'tenant')), - (_('Management'), ('platform', 'primary_ip4', 'primary_ip6', 'config_template')), - (_('Resources'), ('vcpus', 'memory', 'disk')), - (_('Config Context'), ('local_context_data',)), + FieldSet('name', 'role', 'status', 'description', 'tags', name=_('Virtual Machine')), + FieldSet('site', 'cluster', 'device', name=_('Site/Cluster')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet('platform', 'primary_ip4', 'primary_ip6', 'config_template', name=_('Management')), + FieldSet('vcpus', 'memory', 'disk', name=_('Resources')), + FieldSet('local_context_data', name=_('Config Context')), ) class Meta: @@ -348,11 +345,11 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): ) fieldsets = ( - (_('Interface'), ('virtual_machine', 'name', 'description', 'tags')), - (_('Addressing'), ('vrf', 'mac_address')), - (_('Operation'), ('mtu', 'enabled')), - (_('Related Interfaces'), ('parent', 'bridge')), - (_('802.1Q Switching'), ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), + FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')), + FieldSet('vrf', 'mac_address', name=_('Addressing')), + FieldSet('mtu', 'enabled', name=_('Operation')), + FieldSet('parent', 'bridge', name=_('Related Interfaces')), + FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), ) class Meta: @@ -372,7 +369,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): class VirtualDiskForm(VMComponentForm): fieldsets = ( - (_('Disk'), ('virtual_machine', 'name', 'size', 'description', 'tags')), + FieldSet('virtual_machine', 'name', 'size', 'description', 'tags', name=_('Disk')), ) class Meta: diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py index c3e8eb3ca..a7595a2a7 100644 --- a/netbox/vpn/forms/bulk_edit.py +++ b/netbox/vpn/forms/bulk_edit.py @@ -5,6 +5,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import add_blank_choice from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.rendering import FieldSet from vpn.choices import * from vpn.models import * @@ -72,9 +73,9 @@ class TunnelBulkEditForm(NetBoxModelBulkEditForm): model = Tunnel fieldsets = ( - (_('Tunnel'), ('status', 'group', 'encapsulation', 'tunnel_id', 'description')), - (_('Security'), ('ipsec_profile',)), - (_('Tenancy'), ('tenant',)), + FieldSet('status', 'group', 'encapsulation', 'tunnel_id', 'description', name=_('Tunnel')), + FieldSet('ipsec_profile', name=_('Security')), + FieldSet('tenant', name=_('Tenancy')), ) nullable_fields = ( 'group', 'ipsec_profile', 'tunnel_id', 'tenant', 'description', 'comments', @@ -125,10 +126,10 @@ class IKEProposalBulkEditForm(NetBoxModelBulkEditForm): model = IKEProposal fieldsets = ( - (None, ( + FieldSet( 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime', 'description', - )), + ), ) nullable_fields = ( 'sa_lifetime', 'description', 'comments', @@ -159,9 +160,7 @@ class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm): model = IKEPolicy fieldsets = ( - (None, ( - 'version', 'mode', 'preshared_key', 'description', - )), + FieldSet('version', 'mode', 'preshared_key', 'description'), ) nullable_fields = ( 'mode', 'preshared_key', 'description', 'comments', @@ -196,10 +195,10 @@ class IPSecProposalBulkEditForm(NetBoxModelBulkEditForm): model = IPSecProposal fieldsets = ( - (None, ( + FieldSet( 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data', 'description', - )), + ), ) nullable_fields = ( 'sa_lifetime_seconds', 'sa_lifetime_data', 'description', 'comments', @@ -221,7 +220,7 @@ class IPSecPolicyBulkEditForm(NetBoxModelBulkEditForm): model = IPSecPolicy fieldsets = ( - (None, ('pfs_group', 'description',)), + FieldSet('pfs_group', 'description'), ) nullable_fields = ( 'pfs_group', 'description', 'comments', @@ -253,9 +252,7 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): model = IPSecProfile fieldsets = ( - (_('Profile'), ( - 'mode', 'ike_policy', 'ipsec_policy', 'description', - )), + FieldSet('mode', 'ike_policy', 'ipsec_policy', 'description', name=_('Profile')), ) nullable_fields = ( 'description', 'comments', @@ -282,7 +279,7 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): model = L2VPN fieldsets = ( - (None, ('type', 'tenant', 'description')), + FieldSet('type', 'tenant', 'description'), ) nullable_fields = ('tenant', 'description', 'comments') diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index a9326c4bc..d25719d06 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -9,6 +9,7 @@ from tenancy.forms import TenancyFilterForm from utilities.forms.fields import ( ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField, ) +from utilities.forms.rendering import FieldSet from utilities.forms.utils import add_blank_choice from virtualization.models import VirtualMachine from vpn.choices import * @@ -37,10 +38,10 @@ class TunnelGroupFilterForm(NetBoxModelFilterSetForm): class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Tunnel fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id')), - (_('Security'), ('ipsec_profile_id',)), - (_('Tenancy'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('status', 'encapsulation', 'tunnel_id', name=_('Tunnel')), + FieldSet('ipsec_profile_id', name=_('Security')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenancy')), ) status = forms.MultipleChoiceField( label=_('Status'), @@ -72,8 +73,8 @@ class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class TunnelTerminationFilterForm(NetBoxModelFilterSetForm): model = TunnelTermination fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Termination'), ('tunnel_id', 'role')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('tunnel_id', 'role', name=_('Termination')), ) tunnel_id = DynamicModelMultipleChoiceField( queryset=Tunnel.objects.all(), @@ -91,8 +92,10 @@ class TunnelTerminationFilterForm(NetBoxModelFilterSetForm): class IKEProposalFilterForm(NetBoxModelFilterSetForm): model = IKEProposal fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Parameters'), ('authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet( + 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', name=_('Parameters') + ), ) authentication_method = forms.MultipleChoiceField( label=_('Authentication method'), @@ -120,8 +123,8 @@ class IKEProposalFilterForm(NetBoxModelFilterSetForm): class IKEPolicyFilterForm(NetBoxModelFilterSetForm): model = IKEPolicy fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Parameters'), ('version', 'mode', 'proposal_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('version', 'mode', 'proposal_id', name=_('Parameters')), ) version = forms.MultipleChoiceField( label=_('IKE version'), @@ -144,8 +147,8 @@ class IKEPolicyFilterForm(NetBoxModelFilterSetForm): class IPSecProposalFilterForm(NetBoxModelFilterSetForm): model = IPSecProposal fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Parameters'), ('encryption_algorithm', 'authentication_algorithm')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('encryption_algorithm', 'authentication_algorithm', name=_('Parameters')), ) encryption_algorithm = forms.MultipleChoiceField( label=_('Encryption algorithm'), @@ -163,8 +166,8 @@ class IPSecProposalFilterForm(NetBoxModelFilterSetForm): class IPSecPolicyFilterForm(NetBoxModelFilterSetForm): model = IPSecPolicy fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Parameters'), ('proposal_id', 'pfs_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('proposal_id', 'pfs_group', name=_('Parameters')), ) proposal_id = DynamicModelMultipleChoiceField( queryset=IKEProposal.objects.all(), @@ -182,8 +185,8 @@ class IPSecPolicyFilterForm(NetBoxModelFilterSetForm): class IPSecProfileFilterForm(NetBoxModelFilterSetForm): model = IPSecProfile fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Profile'), ('mode', 'ike_policy_id', 'ipsec_policy_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('mode', 'ike_policy_id', 'ipsec_policy_id', name=_('Profile')), ) mode = forms.MultipleChoiceField( label=_('Mode'), @@ -206,9 +209,9 @@ class IPSecProfileFilterForm(NetBoxModelFilterSetForm): class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = L2VPN fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('type', 'import_target_id', 'export_target_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('type', 'import_target_id', 'export_target_id', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) type = forms.ChoiceField( label=_('Type'), @@ -231,10 +234,11 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): model = L2VPNTermination fieldsets = ( - (None, ('filter_id', 'l2vpn_id',)), - (_('Assigned Object'), ( + FieldSet('filter_id', 'l2vpn_id',), + FieldSet( 'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id', - )), + name=_('Assigned Object') + ), ) l2vpn_id = DynamicModelChoiceField( queryset=L2VPN.objects.all(), diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 9674ee2f9..eb2f839d5 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -7,7 +7,7 @@ from ipam.models import IPAddress, RouteTarget, VLAN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField -from utilities.forms.rendering import TabbedGroups +from utilities.forms.rendering import FieldSet, TabbedGroups from utilities.forms.utils import add_blank_choice, get_field_value from utilities.forms.widgets import HTMXSelect from virtualization.models import VirtualMachine, VMInterface @@ -33,7 +33,7 @@ class TunnelGroupForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Tunnel Group'), ('name', 'slug', 'description', 'tags')), + FieldSet('name', 'slug', 'description', 'tags', name=_('Tunnel Group')), ) class Meta: @@ -57,9 +57,9 @@ class TunnelForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Tunnel'), ('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags')), - (_('Security'), ('ipsec_profile',)), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags', name=_('Tunnel')), + FieldSet('ipsec_profile', name=_('Security')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -142,17 +142,15 @@ class TunnelCreateForm(TunnelForm): ) fieldsets = ( - (_('Tunnel'), ('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags')), - (_('Security'), ('ipsec_profile',)), - (_('Tenancy'), ('tenant_group', 'tenant')), - (_('First Termination'), ( + FieldSet('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags', name=_('Tunnel')), + FieldSet('ipsec_profile', name=_('Security')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet( 'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_termination', - 'termination1_outside_ip', - )), - (_('Second Termination'), ( + 'termination1_outside_ip', name=_('First Termination')), + FieldSet( 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_termination', - 'termination2_outside_ip', - )), + 'termination2_outside_ip', name=_('Second Termination')), ) def __init__(self, *args, initial=None, **kwargs): @@ -254,7 +252,7 @@ class TunnelTerminationForm(NetBoxModelForm): ) fieldsets = ( - (None, ('tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags')), + FieldSet('tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags'), ) class Meta: @@ -297,10 +295,11 @@ class TunnelTerminationForm(NetBoxModelForm): class IKEProposalForm(NetBoxModelForm): fieldsets = ( - (_('Proposal'), ('name', 'description', 'tags')), - (_('Parameters'), ( + FieldSet('name', 'description', 'tags', name=_('Proposal')), + FieldSet( 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime', - )), + name=_('Parameters') + ), ) class Meta: @@ -318,8 +317,8 @@ class IKEPolicyForm(NetBoxModelForm): ) fieldsets = ( - (_('Policy'), ('name', 'description', 'tags')), - (_('Parameters'), ('version', 'mode', 'proposals', 'preshared_key')), + FieldSet('name', 'description', 'tags', name=_('Policy')), + FieldSet('version', 'mode', 'proposals', 'preshared_key', name=_('Parameters')), ) class Meta: @@ -332,10 +331,11 @@ class IKEPolicyForm(NetBoxModelForm): class IPSecProposalForm(NetBoxModelForm): fieldsets = ( - (_('Proposal'), ('name', 'description', 'tags')), - (_('Parameters'), ( + FieldSet('name', 'description', 'tags', name=_('Proposal')), + FieldSet( 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data', - )), + name=_('Parameters') + ), ) class Meta: @@ -353,8 +353,8 @@ class IPSecPolicyForm(NetBoxModelForm): ) fieldsets = ( - (_('Policy'), ('name', 'description', 'tags')), - (_('Parameters'), ('proposals', 'pfs_group')), + FieldSet('name', 'description', 'tags', name=_('Policy')), + FieldSet('proposals', 'pfs_group', name=_('Parameters')), ) class Meta: @@ -376,8 +376,8 @@ class IPSecProfileForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Profile'), ('name', 'description', 'tags')), - (_('Parameters'), ('mode', 'ike_policy', 'ipsec_policy')), + FieldSet('name', 'description', 'tags', name=_('Profile')), + FieldSet('mode', 'ike_policy', 'ipsec_policy', name=_('Parameters')), ) class Meta: @@ -406,9 +406,9 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('L2VPN'), ('name', 'slug', 'type', 'identifier', 'description', 'tags')), - (_('Route Targets'), ('import_targets', 'export_targets')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('name', 'slug', 'type', 'identifier', 'description', 'tags', name=_('L2VPN')), + FieldSet('import_targets', 'export_targets', name=_('Route Targets')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -446,15 +446,15 @@ class L2VPNTerminationForm(NetBoxModelForm): ) fieldsets = ( - (None, ( + FieldSet( 'l2vpn', TabbedGroups( - (_('VLAN'), 'vlan'), - (_('Device'), 'interface'), - (_('Virtual Machine'), 'vminterface'), + FieldSet('vlan', name=_('VLAN')), + FieldSet('interface', name=_('Device')), + FieldSet('vminterface', name=_('Virtual Machine')), ), 'tags', - )), + ), ) class Meta: diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 43e804345..84916e8d9 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -7,6 +7,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import add_blank_choice from utilities.forms.fields import CommentField, DynamicModelChoiceField +from utilities.forms.rendering import FieldSet from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH from wireless.models import * @@ -32,7 +33,7 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): model = WirelessLANGroup fieldsets = ( - (None, ('parent', 'description')), + FieldSet('parent', 'description'), ) nullable_fields = ('parent', 'description') @@ -86,8 +87,8 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): model = WirelessLAN fieldsets = ( - (None, ('group', 'ssid', 'status', 'vlan', 'tenant', 'description')), - (_('Authentication'), ('auth_type', 'auth_cipher', 'auth_psk')), + FieldSet('group', 'ssid', 'status', 'vlan', 'tenant', 'description'), + FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) nullable_fields = ( 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments', @@ -133,8 +134,8 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): model = WirelessLink fieldsets = ( - (None, ('ssid', 'status', 'tenant', 'description')), - (_('Authentication'), ('auth_type', 'auth_cipher', 'auth_psk')) + FieldSet('ssid', 'status', 'tenant', 'description'), + FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')) ) nullable_fields = ( 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments', diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index f4c1cb523..2458d7b48 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -6,6 +6,7 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import add_blank_choice from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField +from utilities.forms.rendering import FieldSet from wireless.choices import * from wireless.models import * @@ -29,10 +30,10 @@ class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm): class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLAN fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('ssid', 'group_id', 'status')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Authentication'), ('auth_type', 'auth_cipher', 'auth_psk')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('ssid', 'group_id', 'status', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) ssid = forms.CharField( required=False, @@ -69,10 +70,10 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLink fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('ssid', 'status',)), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Authentication'), ('auth_type', 'auth_cipher', 'auth_psk')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('ssid', 'status', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) ssid = forms.CharField( required=False, diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 04e6fce83..05debf8bf 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -6,6 +6,7 @@ from ipam.models import VLAN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField +from utilities.forms.rendering import FieldSet from wireless.models import * __all__ = ( @@ -24,9 +25,7 @@ class WirelessLANGroupForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Wireless LAN Group'), ( - 'parent', 'name', 'slug', 'description', 'tags', - )), + FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Wireless LAN Group')), ) class Meta: @@ -51,9 +50,9 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Wireless LAN'), ('ssid', 'group', 'vlan', 'status', 'description', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')), - (_('Authentication'), ('auth_type', 'auth_cipher', 'auth_psk')), + FieldSet('ssid', 'group', 'vlan', 'status', 'description', 'tags', name=_('Wireless LAN')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) class Meta: @@ -158,11 +157,11 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Side A'), ('site_a', 'location_a', 'device_a', 'interface_a')), - (_('Side B'), ('site_b', 'location_b', 'device_b', 'interface_b')), - (_('Link'), ('status', 'ssid', 'description', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')), - (_('Authentication'), ('auth_type', 'auth_cipher', 'auth_psk')), + FieldSet('site_a', 'location_a', 'device_a', 'interface_a', name=_('Side A')), + FieldSet('site_b', 'location_b', 'device_b', 'interface_b', name=_('Side B')), + FieldSet('status', 'ssid', 'description', 'tags', name=_('Link')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) class Meta: From 708d93c9e0dc4ef0ee724a6b8d564526e0d698de Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Mar 2024 15:48:17 -0400 Subject: [PATCH 51/56] Use render_fieldset() for bulk edit & filter forms --- netbox/templates/account/preferences.html | 11 ++-------- netbox/templates/generic/bulk_edit.html | 21 +------------------ netbox/templates/inc/filter_list.html | 11 +--------- netbox/utilities/templatetags/form_helpers.py | 8 +++++-- 4 files changed, 10 insertions(+), 41 deletions(-) diff --git a/netbox/templates/account/preferences.html b/netbox/templates/account/preferences.html index 93ca5dfc2..c5a93c162 100644 --- a/netbox/templates/account/preferences.html +++ b/netbox/templates/account/preferences.html @@ -10,15 +10,8 @@ {% csrf_token %} {# Built-in preferences #} - {% for group, fields in form.fieldsets %} -
-
-
{{ group }}
-
- {% for name in fields %} - {% render_field form|getfield:name %} - {% endfor %} -
+ {% for fieldset in form.fieldsets %} + {% render_fieldset form fieldset %} {% endfor %} {# Plugin preferences #} diff --git a/netbox/templates/generic/bulk_edit.html b/netbox/templates/generic/bulk_edit.html index ebb0fbc0e..90b68b25b 100644 --- a/netbox/templates/generic/bulk_edit.html +++ b/netbox/templates/generic/bulk_edit.html @@ -50,26 +50,7 @@ Context: {# Render grouped fields according to declared fieldsets #} {% for fieldset in form.fieldsets %} -
-
-
- {% if fieldset.name %} - {{ fieldset.name }} - {% else %} - {{ model|meta:"verbose_name"|bettertitle }} - {% endif %} -
-
- {% for name in fieldset.fields %} - {% with field=form|getfield:name %} - {% if field.name in form.nullable_fields %} - {% render_field field bulk_nullable=True %} - {% else %} - {% render_field field %} - {% endif %} - {% endwith %} - {% endfor %} -
+ {% render_fieldset form fieldset %} {% endfor %} {# Render tag add/remove fields #} diff --git a/netbox/templates/inc/filter_list.html b/netbox/templates/inc/filter_list.html index 407add929..b8c93ca4c 100644 --- a/netbox/templates/inc/filter_list.html +++ b/netbox/templates/inc/filter_list.html @@ -11,16 +11,7 @@ {# List filters by group #} {% for fieldset in filter_form.fieldsets %}
- {% if fieldset.name %} -
- {{ fieldset.name }} -
- {% endif %} - {% for name in fieldset.fields %} - {% with field=filter_form|get_item:name %} - {% render_field field %} - {% endwith %} - {% endfor %} + {% render_fieldset filter_form fieldset %}
{% empty %} {# List all non-customfield filters as declared in the form class #} diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index 5365e1c80..723c5206a 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -101,8 +101,12 @@ def render_fieldset(form, fieldset): # A single form field elif item in form.fields: + field = form[item] + # Annotate nullability for bulk editing + if field.name in getattr(form, 'nullable_fields', []): + field._nullable = True rows.append( - ('field', None, [form[item]]) + ('field', None, [field]) ) return { @@ -119,7 +123,7 @@ def render_field(field, bulk_nullable=False, label=None): return { 'field': field, 'label': label or field.label, - 'bulk_nullable': bulk_nullable, + 'bulk_nullable': bulk_nullable or getattr(field, '_nullable', False), } From 89150f4b27d7ed7007d4e2b439f98f669561ee36 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 19 Mar 2024 08:50:42 -0400 Subject: [PATCH 52/56] Add form rendering utilities to plugins dev docs --- docs/development/internationalization.md | 5 +-- docs/plugins/development/forms.md | 39 ++++++++++++++++-------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/docs/development/internationalization.md b/docs/development/internationalization.md index bebc97470..df0176b89 100644 --- a/docs/development/internationalization.md +++ b/docs/development/internationalization.md @@ -62,10 +62,11 @@ class Circuit(PrimaryModel): 1. Import `gettext_lazy` as `_`. 2. All form fields must specify a `label` wrapped with `gettext_lazy()`. -3. All headers under a form's `fieldsets` property must be wrapped with `gettext_lazy()`. +3. The name of each FieldSet on a form must be wrapped with `gettext_lazy()`. ```python from django.utils.translation import gettext_lazy as _ +from utilities.forms.rendering import FieldSet class CircuitBulkEditForm(NetBoxModelBulkEditForm): description = forms.CharField( @@ -74,7 +75,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): ) fieldsets = ( - (_('Circuit'), ('provider', 'type', 'status', 'description')), + FieldSet('provider', 'type', 'status', 'description', name=_('Circuit')), ) ``` diff --git a/docs/plugins/development/forms.md b/docs/plugins/development/forms.md index 31751855e..332544df7 100644 --- a/docs/plugins/development/forms.md +++ b/docs/plugins/development/forms.md @@ -15,16 +15,18 @@ NetBox provides several base form classes for use by plugins. This is the base form for creating and editing NetBox models. It extends Django's ModelForm to add support for tags and custom fields. -| Attribute | Description | -|-------------|-------------------------------------------------------------| -| `fieldsets` | A tuple of two-tuples defining the form's layout (optional) | +| Attribute | Description | +|-------------|---------------------------------------------------------------------------------------| +| `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) | **Example** ```python +from django.utils.translation import gettext_lazy as _ from dcim.models import Site from netbox.forms import NetBoxModelForm from utilities.forms.fields import CommentField, DynamicModelChoiceField +from utilities.forms.rendering import FieldSet from .models import MyModel class MyModelForm(NetBoxModelForm): @@ -33,8 +35,8 @@ class MyModelForm(NetBoxModelForm): ) comments = CommentField() fieldsets = ( - ('Model Stuff', ('name', 'status', 'site', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), + FieldSet('name', 'status', 'site', 'tags', name=_('Model Stuff')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -52,6 +54,7 @@ This form facilitates the bulk import of new objects from CSV, JSON, or YAML dat **Example** ```python +from django.utils.translation import gettext_lazy as _ from dcim.models import Site from netbox.forms import NetBoxModelImportForm from utilities.forms import CSVModelChoiceField @@ -62,7 +65,7 @@ class MyModelImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Assigned site' + help_text=_('Assigned site') ) class Meta: @@ -77,16 +80,18 @@ This form facilitates editing multiple objects in bulk. Unlike a model form, thi | Attribute | Description | |-------------------|---------------------------------------------------------------------------------------------| | `model` | The model of object being edited | -| `fieldsets` | A tuple of two-tuples defining the form's layout (optional) | +| `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) | | `nullable_fields` | A tuple of fields which can be nullified (set to empty) using the bulk edit form (optional) | **Example** ```python from django import forms +from django.utils.translation import gettext_lazy as _ from dcim.models import Site from netbox.forms import NetBoxModelImportForm from utilities.forms import CommentField, DynamicModelChoiceField +from utilities.forms.rendering import FieldSet from .models import MyModel, MyModelStatusChoices @@ -106,7 +111,7 @@ class MyModelEditForm(NetBoxModelImportForm): model = MyModel fieldsets = ( - ('Model Stuff', ('name', 'status', 'site')), + FieldSet('name', 'status', 'site', name=_('Model Stuff')), ) nullable_fields = ('site', 'comments') ``` @@ -115,10 +120,10 @@ class MyModelEditForm(NetBoxModelImportForm): This form class is used to render a form expressly for filtering a list of objects. Its fields should correspond to filters defined on the model's filter set. -| Attribute | Description | -|-------------------|-------------------------------------------------------------| -| `model` | The model of object being edited | -| `fieldsets` | A tuple of two-tuples defining the form's layout (optional) | +| Attribute | Description | +|-------------|---------------------------------------------------------------------------------------| +| `model` | The model of object being edited | +| `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) | **Example** @@ -206,3 +211,13 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c ::: utilities.forms.fields.CSVMultipleContentTypeField options: members: false + +## Form Rendering + +::: utilities.forms.rendering.FieldSet + +::: utilities.forms.rendering.InlineFields + +::: utilities.forms.rendering.TabbedGroups + +::: utilities.forms.rendering.ObjectAttribute From 32edb8dfe6cbfbbc037a45b11bf46486800055c9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 19 Mar 2024 09:20:49 -0400 Subject: [PATCH 53/56] Misc cleanup & documentation for FieldSets --- netbox/utilities/forms/rendering.py | 39 +++++++++++++++---- netbox/utilities/templatetags/form_helpers.py | 2 +- netbox/vpn/forms/filtersets.py | 2 +- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/netbox/utilities/forms/rendering.py b/netbox/utilities/forms/rendering.py index 0d9344131..723e911e6 100644 --- a/netbox/utilities/forms/rendering.py +++ b/netbox/utilities/forms/rendering.py @@ -12,17 +12,31 @@ __all__ = ( class FieldSet: """ - A generic grouping of fields, with an optional name. Each field will be rendered - on its own row under the heading (name). + A generic grouping of fields, with an optional name. Each item will be rendered + on its own row under the provided heading (name), if any. The following types + may be passed as items: + + * Field name (string) + * InlineFields instance + * TabbedGroups instance + * ObjectAttribute instance + + Parameters: + items: An iterable of items to be rendered (one per row) + name: The fieldset's name, displayed as a heading (optional) """ - def __init__(self, *fields, name=None): - self.fields = fields + def __init__(self, *items, name=None): + self.items = items self.name = name class InlineFields: """ - A set of fields rendered inline (side-by-side) with a shared label; typically nested within a FieldSet. + A set of fields rendered inline (side-by-side) with a shared label. + + Parameters: + fields: An iterable of form field names + label: The label text to render for the row (optional) """ def __init__(self, *fields, label=None): self.fields = fields @@ -31,7 +45,11 @@ class InlineFields: class TabbedGroups: """ - Two or more groups of fields (FieldSets) arranged under tabs among which the user can navigate. + Two or more groups of fields (FieldSets) arranged under tabs among which the user can toggle. + + Parameters: + fieldsets: An iterable of FieldSet instances, one per tab. Each FieldSet *must* have a + name assigned, which will be employed as the tab's label. """ def __init__(self, *fieldsets): for fs in fieldsets: @@ -50,14 +68,19 @@ class TabbedGroups: { 'id': f'{self.id}_{i}', 'title': group.name, - 'fields': group.fields, + 'fields': group.items, } for i, group in enumerate(self.groups, start=1) ] class ObjectAttribute: """ - Renders the value for a specific attribute on the form's instance. + Renders the value for a specific attribute on the form's instance. This may be used to + display a read-only value and convey additional context to the user. If the attribute has + a `get_absolute_url()` method, it will be rendered as a hyperlink. + + Parameters: + name: The name of the attribute to be displayed """ def __init__(self, name): self.name = name diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index 723c5206a..e9edfed31 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -64,7 +64,7 @@ def render_fieldset(form, fieldset): fieldset = FieldSet(*fields, name=name) rows = [] - for item in fieldset.fields: + for item in fieldset.items: # Multiple fields side-by-side if type(item) is InlineFields: diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index d25719d06..10dc441e2 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -234,7 +234,7 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): model = L2VPNTermination fieldsets = ( - FieldSet('filter_id', 'l2vpn_id',), + FieldSet('filter_id', 'l2vpn_id'), FieldSet( 'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id', name=_('Assigned Object') From 849a9d32d1c5535c2776588a12ee591cdc5d9478 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 19 Mar 2024 14:06:24 -0400 Subject: [PATCH 54/56] Fixes #15340: Fix flicker on page load with dark mode enabled (#15475) --- netbox/project-static/js/setmode.js | 5 +++-- netbox/templates/base/base.html | 25 ++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/netbox/project-static/js/setmode.js b/netbox/project-static/js/setmode.js index 8441a542f..ff1c5366b 100644 --- a/netbox/project-static/js/setmode.js +++ b/netbox/project-static/js/setmode.js @@ -5,10 +5,11 @@ * @param inferred {boolean} Value is inferred from browser/system preference. */ function setMode(mode, inferred) { - document.documentElement.setAttribute("data-netbox-color-mode", mode); + document.documentElement.setAttribute("data-bs-theme", mode); localStorage.setItem("netbox-color-mode", mode); localStorage.setItem("netbox-color-mode-inferred", inferred); } + /** * Determine the best initial color mode to use prior to rendering. */ @@ -69,4 +70,4 @@ function initMode() { console.error(error); } return setMode("light", true); -}; +} diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 1c58047ef..bb35cd3bf 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -9,13 +9,7 @@ data-netbox-url-name="{{ request.resolver_match.url_name }}" data-netbox-base-path="{{ settings.BASE_PATH }}" {% with preferences|get_key:'ui.colormode' as color_mode %} - {% if color_mode == 'dark'%} - data-netbox-color-mode="dark" - {% elif color_mode == 'light' %} - data-netbox-color-mode="light" - {% else %} - data-netbox-color-mode="unset" - {% endif %} + data-netbox-color-mode="{{ color_mode|default:"unset" }}" {% endwith %} > @@ -25,7 +19,16 @@ {# Page title #} {% block title %}{% trans "Home" %}{% endblock %} | NetBox + {# Initialize color mode #} + @@ -53,13 +56,9 @@ {# Additional content #} {% block head %}{% endblock %} - - + + {# Page layout #} {% block layout %}{% endblock %} From a3ce14ad3c1cc1c5b209dff011b52873d2759332 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 19 Mar 2024 14:18:15 -0400 Subject: [PATCH 55/56] Update release notes --- docs/release-notes/version-4.0.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/release-notes/version-4.0.md b/docs/release-notes/version-4.0.md index 60b3115f0..b5889f8cd 100644 --- a/docs/release-notes/version-4.0.md +++ b/docs/release-notes/version-4.0.md @@ -6,6 +6,7 @@ * The deprecated `device_role` & `device_role_id` filters for devices have been removed. (Use `role` and `role_id` instead.) * The legacy reports functionality has been dropped. Reports will be automatically converted to custom scripts on upgrade. +* The `parent` and `parent_id` filters for locations now return only immediate children of the specified location. (Use `ancestor` and `ancestor_id` to return _all_ descendants.) ### New Features @@ -17,18 +18,26 @@ The NetBox user interface has been completely refreshed and updated. The REST API now supports specifying which fields to include in the response data. +#### Advanced FieldSet Functionality ([#14739](https://github.com/netbox-community/netbox/issues/14739)) + +New resources have been introduced to enable advanced form rendering without a need for custom HTML templates. + ### Enhancements * [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3 * [#13283](https://github.com/netbox-community/netbox/issues/13283) - Display additional context on API-backed dropdown fields +* [#13918](https://github.com/netbox-community/netbox/issues/13918) - Add `facility` field to Location model * [#14237](https://github.com/netbox-community/netbox/issues/14237) - Automatically clear dependent selection fields when modifying a parent selection +* [#14454](https://github.com/netbox-community/netbox/issues/14454) - Include member devices for virtual chassis in REST API * [#14637](https://github.com/netbox-community/netbox/issues/14637) - Upgrade to Django 5.0 * [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12 * [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI * [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI * [#14438](https://github.com/netbox-community/netbox/issues/14438) - Track individual custom scripts as database objects * [#15131](https://github.com/netbox-community/netbox/issues/15131) - Automatically annotate related object counts on REST API querysets +* [#15237](https://github.com/netbox-community/netbox/issues/15237) - Ensure consistent filtering ability for all model fields * [#15238](https://github.com/netbox-community/netbox/issues/15238) - Include the `description` field in "brief" REST API serializations +* [#15383](https://github.com/netbox-community/netbox/issues/15383) - Standardize filtering logic for the parents of recursively-nested models (parent & ancestor filters) ### Other Changes @@ -44,6 +53,7 @@ The REST API now supports specifying which fields to include in the response dat * [#15042](https://github.com/netbox-community/netbox/issues/15042) - Rearchitect the logic for registering models & model features * [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices * [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class +* [#15193](https://github.com/netbox-community/netbox/issues/15193) - Switch to compiled distribution of the `psycopg` library * [#15277](https://github.com/netbox-community/netbox/issues/15277) - Replace references to ContentType without ObjectType proxy model & standardize field names * [#15292](https://github.com/netbox-community/netbox/issues/15292) - Remove obsolete `device_role` attribute from Device model (this field was renamed to `role` in v3.6) From a83b233341a02b9a81e9645aec6d368cd381ba27 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Mar 2024 08:26:04 -0400 Subject: [PATCH 56/56] Closes #15339: Consume entire viewport (#15480) * Closes #15339: Consume entire viewport, except for object detail views * Use fluid containers for all views --- netbox/templates/base/layout.html | 6 +++--- netbox/templates/core/rq_task_list.html | 2 +- netbox/templates/core/rq_worker_list.html | 2 +- netbox/templates/extras/script_result.html | 2 +- netbox/templates/generic/_base.html | 4 ++-- netbox/templates/generic/bulk_delete.html | 2 +- netbox/templates/generic/bulk_remove.html | 8 +++++--- netbox/templates/generic/object.html | 2 +- 8 files changed, 15 insertions(+), 13 deletions(-) diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index fff12c1e8..071396575 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -41,7 +41,7 @@ Blocks: {# Top menu #}