diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index b0b8c02ad..612d01d89 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -17,15 +17,16 @@ 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 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index af3d303b2..d794786f1 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/*' diff --git a/base_requirements.txt b/base_requirements.txt index 49d259349..383628fe3 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/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/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: 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/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/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 diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 21e7489c3..9724c4488 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -1,6 +1,31 @@ # NetBox v3.7 -## v3.7.4 (FUTURE) +## v3.7.5 (FUTURE) + +--- + +## v3.7.4 (2024-03-13) + +### 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 --- 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) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 97be1cf57..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): @@ -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/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 0809cb2f4..ee5e47ce7 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 FieldSet, TabbedGroups from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( @@ -29,7 +30,7 @@ class ProviderForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Provider'), ('name', 'slug', 'asns', 'description', 'tags')), + FieldSet('name', 'slug', 'asns', 'description', 'tags'), ) class Meta: @@ -61,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: @@ -75,9 +76,7 @@ class CircuitTypeForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Circuit Type'), ( - 'name', 'slug', 'color', 'description', 'tags', - )), + FieldSet('name', 'slug', 'color', 'description', 'tags'), ) class Meta: @@ -107,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: @@ -146,6 +145,18 @@ class CircuitTerminationForm(NetBoxModelForm): selector=True ) + fieldsets = ( + FieldSet( + 'circuit', 'term_side', 'description', 'tags', + TabbedGroups( + FieldSet('site', name=_('Site')), + FieldSet('provider_network', name=_('Provider Network')), + ), + 'mark_connected', name=_('Circuit Termination') + ), + FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')), + ) + class Meta: model = CircuitTermination fields = [ 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/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/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/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/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/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 1d9828ee3..bcb141a71 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -309,6 +309,14 @@ class ModuleNestedModuleBaySerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class ModuleBayNestedModuleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + + class Meta: + model = models.Module + fields = ['id', 'url', 'display', 'serial'] + + class NestedModuleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') device = NestedDeviceSerializer(read_only=True) @@ -392,11 +400,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_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index 2384f7b02..0bd8ba824 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -28,8 +28,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer): subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True) 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) - 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) 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/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 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)'), ) ), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6b1611694..2ff9f49ae 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -18,11 +18,12 @@ 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 from wireless.choices import WirelessRoleChoices, WirelessChannelChoices +from wireless.models import WirelessLAN, WirelessLink from .choices import * from .constants import * from .models import * @@ -89,10 +90,23 @@ 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 - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): @@ -106,10 +120,23 @@ 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 - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): @@ -152,12 +179,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(): @@ -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', @@ -234,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) ) @@ -249,7 +286,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): @@ -328,10 +365,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(): @@ -411,10 +448,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(): @@ -431,7 +472,7 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet) class Meta: model = Manufacturer - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class DeviceTypeFilterSet(NetBoxModelFilterSet): @@ -502,10 +543,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(): @@ -599,7 +652,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(): @@ -639,12 +692,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 @@ -655,32 +711,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): @@ -688,10 +747,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): @@ -715,7 +778,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): @@ -723,10 +786,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', 'type', 'color', 'description'] + fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description') class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -737,21 +803,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): @@ -784,7 +850,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(): @@ -805,7 +871,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): @@ -831,7 +897,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): @@ -943,6 +1009,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)'), @@ -1032,10 +1103,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(): @@ -1098,24 +1181,29 @@ 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)') ) 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: model = VirtualDeviceContext - fields = ['id', 'device', 'name', 'description'] + fields = ('id', 'device', 'name', 'identifier', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1181,7 +1269,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(): @@ -1325,6 +1413,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', @@ -1366,7 +1458,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( @@ -1382,7 +1474,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( @@ -1398,7 +1490,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( @@ -1415,10 +1509,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): @@ -1533,27 +1633,37 @@ 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') + ) + wireless_link_id = django_filters.ModelMultipleChoiceFilter( + queryset=WirelessLink.objects.all(), + label=_('Wireless link') ) 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', + ) def filter_virtual_chassis_member(self, queryset, name, value): try: @@ -1582,10 +1692,15 @@ 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', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end', + ) class RearPortFilterSet( @@ -1600,21 +1715,38 @@ 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): + installed_module_id = django_filters.ModelMultipleChoiceFilter( + field_name='installed_module', + queryset=ModuleBay.objects.all(), + label=_('Installed module (ID)'), + ) 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): @@ -1650,7 +1782,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(): @@ -1669,7 +1801,7 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = InventoryItemRole - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ('id', 'name', 'slug', 'color', 'description') class VirtualChassisFilterSet(NetBoxModelFilterSet): @@ -1734,7 +1866,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(): @@ -1839,7 +1971,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(): @@ -1917,12 +2049,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): @@ -1971,7 +2103,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(): @@ -2037,10 +2169,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(): @@ -2099,18 +2231,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/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/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/filtersets.py b/netbox/dcim/forms/filtersets.py index 89793528d..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( @@ -754,7 +759,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 ) @@ -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 6c33ea8d6..3559aabc6 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 FieldSet, InlineFields, TabbedGroups from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK from virtualization.models import Cluster from wireless.models import WirelessLAN, WirelessLANGroup @@ -77,9 +78,7 @@ class RegionForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Region'), ( - 'parent', 'name', 'slug', 'description', 'tags', - )), + FieldSet('parent', 'name', 'slug', 'description', 'tags'), ) class Meta: @@ -98,9 +97,7 @@ class SiteGroupForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Site Group'), ( - 'parent', 'name', 'slug', 'description', 'tags', - )), + FieldSet('parent', 'name', 'slug', 'description', 'tags'), ) class Meta: @@ -135,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: @@ -179,14 +177,14 @@ class LocationForm(TenancyForm, NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Location'), ('site', 'parent', 'name', 'slug', 'status', '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: model = Location fields = ( - 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'tags', + 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'facility', 'tags', ) @@ -194,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: @@ -227,6 +223,18 @@ class RackForm(TenancyForm, NetBoxModelForm): ) comments = CommentField() + fieldsets = ( + 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', name=_('Dimensions') + ), + ) + class Meta: model = Rack fields = [ @@ -256,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: @@ -271,9 +279,7 @@ class ManufacturerForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Manufacturer'), ( - 'name', 'slug', 'description', 'tags', - )), + FieldSet('name', 'slug', 'description', 'tags', name=_('Manufacturer')), ) class Meta: @@ -304,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: @@ -337,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: @@ -357,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: @@ -386,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: @@ -601,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: @@ -658,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: @@ -683,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: @@ -832,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: @@ -844,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: @@ -856,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: @@ -879,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: @@ -901,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: @@ -925,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: @@ -941,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: @@ -953,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: @@ -965,7 +974,7 @@ class ModuleBayTemplateForm(ComponentTemplateForm): class DeviceBayTemplateForm(ComponentTemplateForm): fieldsets = ( - (None, ('device_type', 'name', 'label', 'description')), + FieldSet('device_type', 'name', 'label', 'description'), ) class Meta: @@ -1006,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: @@ -1052,9 +1061,9 @@ class ModularDeviceComponentForm(DeviceComponentForm): class ConsolePortForm(ModularDeviceComponentForm): fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1065,11 +1074,10 @@ class ConsolePortForm(ModularDeviceComponentForm): class ConsoleServerPortForm(ModularDeviceComponentForm): - fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1080,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: @@ -1107,10 +1114,10 @@ class PowerOutletForm(ModularDeviceComponentForm): ) fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1206,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: @@ -1245,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: @@ -1261,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: @@ -1275,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: @@ -1287,7 +1297,7 @@ class ModuleBayForm(DeviceComponentForm): class DeviceBayForm(DeviceComponentForm): fieldsets = ( - (None, ('device', 'name', 'label', 'description', 'tags',)), + FieldSet('device', 'name', 'label', 'description', 'tags',), ) class Meta: @@ -1395,8 +1405,20 @@ class InventoryItemForm(DeviceComponentForm): ) fieldsets = ( - (_('Inventory Item'), ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')), - (_('Hardware'), ('manufacturer', 'part_id', 'serial', 'asset_tag')), + FieldSet('device', 'parent', 'name', 'label', 'role', 'description', 'tags', name=_('Inventory Item')), + FieldSet('manufacturer', 'part_id', 'serial', 'asset_tag', name=_('Hardware')), + FieldSet( + TabbedGroups( + 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: @@ -1412,22 +1434,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 @@ -1461,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: @@ -1499,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/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/devices.py b/netbox/dcim/models/devices.py index c75757fa7..4f221fe16 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 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/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/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 b255c283e..fffa82a10 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,16 +179,24 @@ 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() filterset = SiteFilterSet + ignore_fields = ('physical_address', 'shipping_address') @classmethod def setUpTestData(cls): @@ -314,21 +351,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, 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() + 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) @@ -345,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) @@ -352,31 +401,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() @@ -416,6 +472,7 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests): class RackTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Rack.objects.all() filterset = RackFilterSet + ignore_fields = ('units',) @classmethod def setUpTestData(cls): @@ -675,6 +732,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackReservation.objects.all() filterset = RackReservationFilterSet + ignore_fields = ('units',) @classmethod def setUpTestData(cls): @@ -838,6 +896,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): @@ -1829,6 +1888,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Device.objects.all() filterset = DeviceFilterSet + ignore_fields = ('local_context_data', 'oob_ip', 'primary_ip4', 'primary_ip6', 'vc_master_for') @classmethod def setUpTestData(cls): @@ -2281,6 +2341,7 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Module.objects.all() filterset = ModuleFilterSet + ignore_fields = ('local_context_data',) @classmethod def setUpTestData(cls): @@ -3178,6 +3239,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = Interface.objects.all() filterset = InterfaceFilterSet + ignore_fields = ('tagged_vlans', 'untagged_vlan', 'vdcs') @classmethod def setUpTestData(cls): @@ -5281,6 +5343,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): @@ -5350,15 +5413,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'), @@ -5366,13 +5436,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): @@ -5380,8 +5449,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']} @@ -5391,10 +5463,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/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/dcim/views.py b/netbox/dcim/views.py index b5a90ced3..49bbe9be1 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') @@ -1079,7 +1078,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 ) @@ -2925,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/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..4674335c9 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(): @@ -89,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' @@ -103,10 +106,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 +121,7 @@ class EventRuleFilterSet(NetBoxModelFilterSet): ) -class CustomFieldFilterSet(BaseFilterSet): +class CustomFieldFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), @@ -126,12 +129,18 @@ class CustomFieldFilterSet(BaseFilterSet): 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 = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='related_object_type' + ) + related_object_type = ContentTypeFilter() choice_set_id = django_filters.ModelMultipleChoiceFilter( queryset=CustomFieldChoiceSet.objects.all() ) @@ -143,10 +152,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(): @@ -159,7 +169,7 @@ class CustomFieldFilterSet(BaseFilterSet): ) -class CustomFieldChoiceSetFilterSet(BaseFilterSet): +class CustomFieldChoiceSetFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), @@ -170,9 +180,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(): @@ -187,13 +197,14 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet): return queryset.filter(extra_choices__overlap=value) -class CustomLinkFilterSet(BaseFilterSet): +class CustomLinkFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( 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' @@ -201,9 +212,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(): @@ -216,13 +227,14 @@ class CustomLinkFilterSet(BaseFilterSet): ) -class ExportTemplateFilterSet(BaseFilterSet): +class ExportTemplateFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( 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' @@ -238,7 +250,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(): @@ -249,13 +264,14 @@ class ExportTemplateFilterSet(BaseFilterSet): ) -class SavedFilterFilterSet(BaseFilterSet): +class SavedFilterFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( 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' @@ -276,7 +292,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(): @@ -317,20 +333,19 @@ class BookmarkFilterSet(BaseFilterSet): class Meta: model = Bookmark - fields = ['id', 'object_id'] + 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(): @@ -360,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(): @@ -385,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(): @@ -482,12 +497,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', @@ -573,9 +588,13 @@ 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', '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(): @@ -587,7 +606,7 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): ) -class ConfigTemplateFilterSet(BaseFilterSet): +class ConfigTemplateFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), @@ -604,7 +623,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(): @@ -652,10 +671,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(): @@ -678,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/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..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,16 +37,16 @@ __all__ = ( 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', - )), + FieldSet('q', 'filter_id'), + FieldSet( + 'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', + 'ui_editable', 'is_cloneable', name=_('Attributes') + ), ) - 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, @@ -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 7f36db657..680bec1e4 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 FieldSet, ObjectAttribute from utilities.forms.widgets import ChoicesWidget, HTMXSelect from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -42,8 +43,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)") @@ -54,12 +55,15 @@ class CustomFieldForm(forms.ModelForm): ) fieldsets = ( - (_('Custom Field'), ( - 'object_types', 'name', 'label', 'group_name', 'type', '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')), + FieldSet( + 'object_types', 'name', 'label', 'group_name', 'type', 'related_object_type', 'required', 'description', + 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: @@ -128,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: @@ -162,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: @@ -199,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: @@ -231,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: @@ -266,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: @@ -360,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: @@ -442,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: @@ -494,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: @@ -526,6 +536,9 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm): class ImageAttachmentForm(forms.ModelForm): + fieldsets = ( + FieldSet(ObjectAttribute('parent'), 'name', 'image'), + ) class Meta: model = ImageAttachment 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/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/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/configs.py b/netbox/extras/models/configs.py index 6ea2167c2..ff39c3e8b 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 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 fee0c9f29..a0f504931 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', @@ -55,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') @@ -71,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') @@ -507,3 +512,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/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..b68c02efc 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): @@ -86,6 +87,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 +119,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) @@ -139,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): @@ -172,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): @@ -236,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): @@ -389,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 @@ -458,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): @@ -631,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): @@ -667,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): @@ -744,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() @@ -857,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): @@ -1025,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): @@ -1080,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): @@ -1109,6 +1128,93 @@ class ConfigTemplateTestCase(TestCase, BaseFilterSetTests): class TagTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Tag.objects.all() filterset = TagFilterSet + 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): @@ -1177,6 +1283,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/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/extras/views.py b/netbox/extras/views.py index 73fdb6b83..cb3fdd39c 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 # @@ -757,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: @@ -1143,19 +1144,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/ipam/filtersets.py b/netbox/ipam/filtersets.py index 404baf71b..d58f5bfc9 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 @@ -75,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): @@ -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(): @@ -112,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): @@ -144,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(): @@ -183,7 +206,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(): @@ -214,10 +237,21 @@ 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 - fields = ['id', 'asn', 'description'] + fields = ('id', 'asn', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -234,7 +268,7 @@ class RoleFilterSet(OrganizationalModelFilterSet): class Meta: model = Role - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description', 'weight') class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): @@ -359,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(): @@ -475,7 +509,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(): @@ -628,10 +662,20 @@ 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)'), + ) + 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'] + fields = ('id', 'dns_name', 'description', 'assigned_object_type', 'assigned_object_id') def search(self, queryset, name, value): if not value.strip(): @@ -758,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(): @@ -819,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}) @@ -849,7 +893,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,16 +905,20 @@ 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'] + fields = ('id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id') def search(self, queryset, name, value): if not value.strip(): @@ -882,8 +930,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 ) @@ -975,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(): @@ -1008,7 +1057,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(): @@ -1041,26 +1090,29 @@ 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', label=_('IP address'), ) - port = NumericArrayFilter( field_name='ports', 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'] + fields = ('id', 'name', 'protocol', 'description') def search(self, queryset, name, value): if not value.strip(): 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 909de886f..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( @@ -304,7 +311,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): 'placeholder': 'Prefix', } ), - label='Parent Prefix' + label=_('Parent Prefix') ) family = forms.ChoiceField( required=False, @@ -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 689e18940..0db9576f1 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 FieldSet, InlineFields, ObjectAttribute, TabbedGroups from utilities.forms.widgets import DatePicker from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface @@ -56,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: @@ -74,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() @@ -90,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: @@ -110,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: @@ -131,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: @@ -155,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: @@ -184,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: @@ -226,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: @@ -253,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: @@ -307,6 +309,20 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): ) comments = CommentField() + fieldsets = ( + FieldSet('address', 'status', 'role', 'vrf', 'dns_name', 'description', 'tags', name=_('IP Address')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet( + TabbedGroups( + FieldSet('interface', name=_('Device')), + FieldSet('vminterface', name=_('Virtual Machine')), + FieldSet('fhrpgroup', name=_('FHRP Group')), + ), + 'primary_for_parent', name=_('Assignment') + ), + FieldSet('nat_inside', name=_('NAT IP (Inside)')), + ) + class Meta: model = IPAddress fields = [ @@ -373,20 +389,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) @@ -457,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: @@ -516,6 +518,10 @@ class FHRPGroupAssignmentForm(forms.ModelForm): queryset=FHRPGroup.objects.all() ) + fieldsets = ( + FieldSet(ObjectAttribute('interface'), 'group', 'priority'), + ) + class Meta: model = FHRPGroupAssignment fields = ('group', 'priority') @@ -601,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: @@ -676,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: @@ -718,6 +725,18 @@ class ServiceForm(NetBoxModelForm): ) comments = CommentField() + fieldsets = ( + FieldSet( + TabbedGroups( + FieldSet('device', name=_('Device')), + FieldSet('virtual_machine', name=_('Virtual Machine')), + ), + 'name', + InlineFields('protocol', 'ports', label=_('Port(s)')), + 'ipaddresses', 'description', 'tags', name=_('Service') + ), + ) + class Meta: model = Service fields = [ @@ -732,6 +751,20 @@ class ServiceCreateForm(ServiceForm): required=False ) + fieldsets = ( + FieldSet( + TabbedGroups( + FieldSet('device', name=_('Device')), + FieldSet('virtual_machine', name=_('Virtual Machine')), + ), + TabbedGroups( + FieldSet('service_template', name=_('From Template')), + FieldSet('name', 'protocol', 'ports', name=_('Custom')), + ), + 'ipaddresses', 'description', 'tags', name=_('Service') + ), + ) + class Meta(ServiceForm.Meta): fields = [ 'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 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 diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index cce5b6b68..422c5ba37 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() diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index bb4f50c21..3a46423a5 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,11 +188,24 @@ 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() filterset = VRFFilterSet + def get_m2m_filter_name(self, 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 ChangeLoggedFilterSetTests.get_m2m_filter_name(field) + @classmethod def setUpTestData(cls): @@ -277,6 +302,18 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RouteTarget.objects.all() filterset = RouteTargetFilterSet + def get_m2m_filter_name(self, 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): @@ -322,6 +359,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) @@ -344,6 +392,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]} @@ -922,6 +984,7 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests): class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = IPAddress.objects.all() filterset = IPAddressFilterSet + ignore_fields = ('fhrpgroup',) @classmethod def setUpTestData(cls): @@ -1092,6 +1155,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) @@ -1231,6 +1304,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() @@ -1475,6 +1553,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): @@ -1733,6 +1812,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ServiceTemplate.objects.all() filterset = ServiceTemplateFilterSet + ignore_fields = ('ports',) @classmethod def setUpTestData(cls): @@ -1797,6 +1877,7 @@ class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Service.objects.all() filterset = ServiceFilterSet + ignore_fields = ('ports',) @classmethod def setUpTestData(cls): @@ -1883,9 +1964,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/ipam/views.py b/netbox/ipam/views.py index 9c4a9a102..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): @@ -1059,7 +1058,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: @@ -1236,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/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/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 62a143de5..8a31c995c 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -264,9 +264,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: 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/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 %} -