diff --git a/docs/customization/custom-validation.md b/docs/customization/custom-validation.md index 79aa82bc9..e9bc6302a 100644 --- a/docs/customization/custom-validation.md +++ b/docs/customization/custom-validation.md @@ -4,7 +4,7 @@ NetBox validates every object prior to it being written to the database to ensur ## Custom Validation Rules -Custom validation rules are expressed as a mapping of model attributes to a set of rules to which that attribute must conform. For example: +Custom validation rules are expressed as a mapping of object attributes to a set of rules to which that attribute must conform. For example: ```json { @@ -17,6 +17,8 @@ Custom validation rules are expressed as a mapping of model attributes to a set This defines a custom validator which checks that the length of the `name` attribute for an object is at least five characters long, and no longer than 30 characters. This validation is executed _after_ NetBox has performed its own internal validation. +### Validation Types + The `CustomValidator` class supports several validation types: * `min`: Minimum value @@ -34,16 +36,33 @@ The `min` and `max` types should be defined for numeric values, whereas `min_len !!! warning Bear in mind that these validators merely supplement NetBox's own validation: They will not override it. For example, if a certain model field is required by NetBox, setting a validator for it with `{'prohibited': True}` will not work. +### Validating Request Parameters + +!!! info "This feature was introduced in NetBox v4.0." + +In addition to validating object attributes, custom validators can also match against parameters of the current request (where available). For example, the following rule will permit only the user named "admin" to modify an object: + +```json +{ + "request.user.username": { + "eq": "admin" + } +} +``` + +!!! tip + Custom validation should generally not be used to enforce permissions. NetBox provides a robust [object-based permissions](../administration/permissions.md) mechanism which should be used for this purpose. + ### Custom Validation Logic -There may be instances where the provided validation types are insufficient. NetBox provides a `CustomValidator` class which can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected. +There may be instances where the provided validation types are insufficient. NetBox provides a `CustomValidator` class which can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected. The `validate()` method should accept an instance (the object being saved) as well as the current request effecting the change. ```python from extras.validators import CustomValidator class MyValidator(CustomValidator): - def validate(self, instance): + def validate(self, instance, request): if instance.status == 'active' and not instance.description: self.fail("Active sites must have a description set!", field='status') ``` 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/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/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/plugins/development/navigation.md b/docs/plugins/development/navigation.md index dc895b2ab..45d02f805 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -49,8 +49,8 @@ menu_items = (item1, item2, item3) Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below. ```python title="navigation.py" +from netbox.choices import ButtonColorChoices from netbox.plugins import PluginMenuButton, PluginMenuItem -from utilities.choices import ButtonColorChoices item1 = PluginMenuItem( link='plugins:myplugin:myview', 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/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/views.py b/netbox/circuits/views.py index 64dd82682..54f875975 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -6,7 +6,7 @@ from dcim.views import PathTraceView from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm -from utilities.utils import count_related +from utilities.query import count_related from utilities.views import register_model_view from . import filtersets, forms, tables from .models import * @@ -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/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/models/data.py b/netbox/core/models/data.py index 4ceb22ba9..48fa2ff71 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -1,3 +1,4 @@ +import hashlib import logging import os import yaml @@ -18,7 +19,6 @@ from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED from netbox.models import PrimaryModel from netbox.models.features import JobsMixin from netbox.registry import registry -from utilities.files import sha256_hash from utilities.querysets import RestrictedQuerySet from ..choices import * from ..exceptions import SyncError @@ -357,7 +357,8 @@ class DataFile(models.Model): has changed. """ file_path = os.path.join(source_root, self.path) - file_hash = sha256_hash(file_path).hexdigest() + with open(file_path, 'rb') as f: + file_hash = hashlib.sha256(f.read()).hexdigest() # Update instance file attributes & data if is_modified := file_hash != self.hash: diff --git a/netbox/core/views.py b/netbox/core/views.py index 0379900e1..400b421d5 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -25,7 +25,7 @@ from netbox.views import generic from netbox.views.generic.base import BaseObjectView from netbox.views.generic.mixins import TableMixin from utilities.forms import ConfirmationForm -from utilities.utils import count_related +from utilities.query import count_related from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from . import filtersets, forms, tables from .models import * diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index 6fb3811ba..8063278a7 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -92,7 +92,7 @@ class LocationSerializer(NestedGroupModelSerializer): class Meta: model = Location fields = [ - 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility', 'description', + 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index aa8a68296..ad1e29f26 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -10,12 +10,12 @@ from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate from ipam.filtersets import PrimaryIPFilterSet from ipam.models import ASN, IPAddress, VRF +from netbox.choices import ColorChoices from netbox.filtersets import ( BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, ) from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.models import * -from utilities.choices import ColorChoices from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, @@ -270,13 +270,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM class Meta: model = Location - fields = ('id', 'name', 'slug', 'status', 'description') + fields = ('id', 'name', 'slug', 'status', 'facility', 'description') def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( Q(name__icontains=value) | + Q(facility__icontains=value) | Q(description__icontains=value) ) diff --git a/netbox/dcim/forms/bulk_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 e35055851..4e8e3491c 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -12,7 +12,8 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField -from utilities.forms.widgets import APISelectMultiple, NumberWithOptions +from utilities.forms.rendering import FieldSet +from utilities.forms.widgets import NumberWithOptions from vpn.models import L2VPN from wireless.choices import * @@ -132,8 +133,8 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( - (None, ('q', 'filter_id', 'tag', 'parent_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')) + FieldSet('q', 'filter_id', 'tag', 'parent_id'), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) ) parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -146,8 +147,8 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( - (None, ('q', 'filter_id', 'tag', 'parent_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')) + FieldSet('q', 'filter_id', 'tag', 'parent_id'), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) ) parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), @@ -160,10 +161,10 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Site fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('status', 'region_id', 'group_id', 'asn_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) selector_fields = ('filter_id', 'q', 'region_id', 'group_id') status = forms.MultipleChoiceField( @@ -192,10 +193,10 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Location fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -241,13 +242,13 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm): class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Rack fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')), - (_('Function'), ('status', 'role_id')), - (_('Hardware'), ('type', 'width', 'serial', 'asset_tag')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), - (_('Weight'), ('weight', 'max_weight', 'weight_unit')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), + FieldSet('status', 'role_id', name=_('Function')), + FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), + FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id') region_id = DynamicModelMultipleChoiceField( @@ -326,13 +327,13 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte class RackElevationFilterForm(RackFilterForm): fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'id')), - (_('Function'), ('status', 'role_id')), - (_('Hardware'), ('type', 'width', 'serial', 'asset_tag')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), - (_('Weight'), ('weight', 'max_weight', 'weight_unit')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')), + FieldSet('status', 'role_id', name=_('Function')), + FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), + FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), @@ -348,10 +349,10 @@ class RackElevationFilterForm(RackFilterForm): class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('User'), ('user_id',)), - (_('Rack'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('user_id', name=_('User')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -401,8 +402,8 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')) + FieldSet('q', 'filter_id', 'tag'), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) ) tag = TagFilterField(model) @@ -410,14 +411,16 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Hardware'), ('manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow')), - (_('Images'), ('has_front_image', 'has_rear_image')), - (_('Components'), ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet( + 'manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow', name=_('Hardware') + ), + FieldSet('has_front_image', 'has_rear_image', name=_('Images')), + FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', - 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', - )), - (_('Weight'), ('weight', 'weight_unit')), + 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components') + ), + FieldSet('weight', 'weight_unit', name=_('Weight')), ) selector_fields = ('filter_id', 'q', 'manufacturer_id') manufacturer_id = DynamicModelMultipleChoiceField( @@ -536,13 +539,13 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Hardware'), ('manufacturer_id', 'part_number')), - (_('Components'), ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('manufacturer_id', 'part_number', name=_('Hardware')), + FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', - 'pass_through_ports', - )), - (_('Weight'), ('weight', 'weight_unit')), + 'pass_through_ports', name=_('Components') + ), + FieldSet('weight', 'weight_unit', name=_('Weight')), ) selector_fields = ('filter_id', 'q', 'manufacturer_id') manufacturer_id = DynamicModelMultipleChoiceField( @@ -642,18 +645,20 @@ class DeviceFilterForm( ): model = Device fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Operation'), ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), - (_('Hardware'), ('manufacturer_id', 'device_type_id', 'platform_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), - (_('Components'), ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')), + FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), + FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', - )), - (_('Miscellaneous'), ( + name=_('Components') + ), + FieldSet( 'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data', - )) + name=_('Miscellaneous') + ) ) selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id') region_id = DynamicModelMultipleChoiceField( @@ -817,9 +822,9 @@ class VirtualDeviceContextFilterForm( ): model = VirtualDeviceContext fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('device', 'status', 'has_primary_ip')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) device = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -844,8 +849,8 @@ class VirtualDeviceContextFilterForm( class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Module fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Hardware'), ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')), ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -879,9 +884,9 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualChassis fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -908,10 +913,10 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')), - (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')), + FieldSet('type', 'status', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -992,9 +997,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = PowerPanel fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) selector_fields = ('filter_id', 'q', 'site_id', 'location_id') region_id = DynamicModelMultipleChoiceField( @@ -1031,10 +1036,10 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = PowerFeed fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Attributes'), ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -1141,11 +1146,11 @@ class PathEndpointFilterForm(CabledFilterForm): class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsolePort fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type', 'speed')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1163,11 +1168,11 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsoleServerPort fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type', 'speed')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1185,11 +1190,11 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerPort fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1202,11 +1207,11 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerOutlet fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1219,14 +1224,14 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), - (_('Addressing'), ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')), - (_('PoE'), ('poe_mode', 'poe_type')), - (_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')), + FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')), + FieldSet('poe_mode', 'poe_type', name=_('PoE')), + FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) selector_fields = ('filter_id', 'q', 'device_id') vdc_id = DynamicModelMultipleChoiceField( @@ -1330,11 +1335,11 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type', 'color')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Cable'), ('cabled', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'occupied', name=_('Cable')), ) model = FrontPort type = forms.MultipleChoiceField( @@ -1352,11 +1357,11 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = RearPort fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type', 'color')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Cable'), ('cabled', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'occupied', name=_('Cable')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1373,10 +1378,10 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class ModuleBayFilterForm(DeviceComponentFilterForm): model = ModuleBay fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'position')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'position', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), ) tag = TagFilterField(model) position = forms.CharField( @@ -1388,10 +1393,10 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), ) tag = TagFilterField(model) @@ -1399,10 +1404,13 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet( + 'name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered', + name=_('Attributes') + ), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), ) role_id = DynamicModelMultipleChoiceField( queryset=InventoryItemRole.objects.all(), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 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/cables.py b/netbox/dcim/models/cables.py index f8a61a794..64f0b8560 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -15,9 +15,9 @@ from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node from netbox.models import ChangeLoggedModel, PrimaryModel +from utilities.conversion import to_meters from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet -from utilities.utils import to_meters from wireless.models import WirelessLink from .device_components import FrontPort, RearPort, PathEndpoint diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 5b2564b32..9438b741f 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -12,8 +12,8 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField, WWNField +from netbox.choices import ColorChoices from netbox.models import OrganizationalModel, NetBoxModel -from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 4f221fe16..10792e387 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -18,10 +18,10 @@ from dcim.choices import * from dcim.constants import * from extras.models import ConfigContextModel, CustomField from extras.querysets import ConfigContextModelQuerySet +from netbox.choices import ColorChoices from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel from netbox.models.features import ContactsMixin, ImageAttachmentsMixin -from utilities.choices import ColorChoices from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField from utilities.tracking import TrackingModelMixin from .device_components import * diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 9be8dc0a3..d4a05699c 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ from dcim.choices import * -from utilities.utils import to_grams +from utilities.conversion import to_grams __all__ = ( 'RenderConfigMixin', diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 3cb4e0225..289c38133 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -14,11 +14,12 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG +from netbox.choices import ColorChoices from netbox.models import OrganizationalModel, PrimaryModel from netbox.models.features import ContactsMixin, ImageAttachmentsMixin -from utilities.choices import ColorChoices +from utilities.conversion import to_grams +from utilities.data import array_to_string, drange from utilities.fields import ColorField, NaturalOrderingField -from utilities.utils import array_to_string, drange, to_grams from .device_components import PowerPort from .devices import Device, Module from .mixins import WeightMixin 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/svg/cables.py b/netbox/dcim/svg/cables.py index d7365161e..aaa9e24ed 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -6,7 +6,7 @@ from svgwrite.text import Text from django.conf import settings from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH -from utilities.utils import foreground_color +from utilities.html import foreground_color __all__ = ( diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 07ea55a33..0f73095b5 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -14,7 +14,8 @@ from django.urls import reverse from django.utils.http import urlencode from netbox.config import get_config -from utilities.utils import foreground_color, array_to_ranges +from utilities.data import array_to_ranges +from utilities.html import foreground_color from dcim.constants import RACK_ELEVATION_BORDER_WIDTH diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index a0a71ab30..e179ec43a 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -152,7 +152,9 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Location fields = ( - 'pk', 'id', 'name', 'site', 'status', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description', - 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count', + 'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description' ) - default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 1e46d66ac..96ea020b3 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -6,13 +6,12 @@ from dcim.choices import * from dcim.filtersets import * from dcim.models import * from ipam.models import ASN, IPAddress, RIR, VRF +from netbox.choices import ColorChoices from tenancy.models import Tenant, TenantGroup -from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices, WirelessRoleChoices - User = get_user_model() @@ -359,9 +358,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): location.save() locations = ( - Location(name='Location 1A', slug='location-1a', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='foobar1'), - Location(name='Location 2A', slug='location-2a', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='foobar2'), - Location(name='Location 3A', slug='location-3a', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='foobar3'), + Location(name='Location 1A', slug='location-1a', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, facility='Facility 1', description='foobar1'), + Location(name='Location 2A', slug='location-2a', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, facility='Facility 2', description='foobar2'), + Location(name='Location 3A', slug='location-3a', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, facility='Facility 3', description='foobar3'), ) for location in locations: location.save() @@ -390,6 +389,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_facility(self): + params = {'facility': ['Facility 1', 'Facility 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 1a5cc8435..cab1760ed 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -7,7 +7,7 @@ from dcim.choices import * from dcim.models import * from extras.models import CustomField from tenancy.models import Tenant -from utilities.utils import drange +from utilities.data import drange class LocationTestCase(TestCase): diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index e9e5a557b..ec85fc1d5 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,12 +11,11 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF +from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from tenancy.models import Tenant -from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN - User = get_user_model() @@ -213,6 +212,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 93e5f04dc..120bbcb59 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -25,8 +25,8 @@ from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model +from utilities.query import count_related from utilities.query_functions import CollateAsChar -from utilities.utils import count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view from virtualization.models import VirtualMachine from . import filtersets, forms, tables @@ -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') @@ -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/views.py b/netbox/extras/api/views.py index 3439f6f3f..0a5303741 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -20,7 +20,7 @@ from netbox.api.metadata import ContentTypeMetadata from netbox.api.renderers import TextRenderer from netbox.api.viewsets import NetBoxModelViewSet from utilities.exceptions import RQWorkerNotRunningException -from utilities.utils import copy_safe_request +from utilities.request import copy_safe_request from . import serializers from .mixins import ConfigTemplateRenderMixin diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index d4b41aba9..2c9d5836a 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -2,7 +2,8 @@ import logging from django.utils.translation import gettext_lazy as _ -from utilities.choices import ButtonColorChoices, ChoiceSet +from netbox.choices import ButtonColorChoices +from utilities.choices import ChoiceSet # diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 69bef0d8f..23f082ce2 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -14,10 +14,12 @@ from django.utils.translation import gettext as _ from core.models import ObjectType from extras.choices import BookmarkOrderingChoices -from utilities.choices import ButtonColorChoices +from netbox.choices import ButtonColorChoices +from utilities.object_types import object_type_identifier, object_type_name from utilities.permissions import get_permission_for_model +from utilities.querydict import dict_to_querydict from utilities.templatetags.builtins.filters import render_markdown -from utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname +from utilities.views import get_viewname from .utils import register_widget __all__ = ( @@ -33,15 +35,15 @@ __all__ = ( def get_object_type_choices(): return [ - (content_type_identifier(ct), content_type_name(ct)) - for ct in ObjectType.objects.public().order_by('app_label', 'model') + (object_type_identifier(ot), object_type_name(ot)) + for ot in ObjectType.objects.public().order_by('app_label', 'model') ] def get_bookmarks_object_type_choices(): return [ - (content_type_identifier(ct), content_type_name(ct)) - for ct in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model') + (object_type_identifier(ot), object_type_name(ot)) + for ot in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model') ] diff --git a/netbox/extras/events.py b/netbox/extras/events.py index 0ee4cffa8..a33ac213c 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -1,9 +1,6 @@ -import logging - from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone from django.utils.module_loading import import_string from django.utils.translation import gettext as _ @@ -15,9 +12,9 @@ from netbox.constants import RQ_QUEUE_DEFAULT from netbox.registry import registry from utilities.api import get_serializer_for_model from utilities.rqworker import get_rq_retry -from utilities.utils import serialize_object +from utilities.serialization import serialize_object from .choices import * -from .models import EventRule, ScriptModule +from .models import EventRule logger = logging.getLogger('netbox.events_processor') diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 73751872f..d4235c465 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -13,6 +13,7 @@ from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_ch from utilities.forms.fields import ( ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField, ) +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import APISelectMultiple, DateTimePicker from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -36,11 +37,11 @@ __all__ = ( class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Attributes'), ( + FieldSet('q', 'filter_id'), + FieldSet( 'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', - 'ui_editable', 'is_cloneable', - )), + 'ui_editable', 'is_cloneable', name=_('Attributes') + ), ) related_object_type_id = ContentTypeMultipleChoiceField( queryset=ObjectType.objects.with_feature('custom_fields'), @@ -93,8 +94,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Choices'), ('base_choices', 'choice')), + FieldSet('q', 'filter_id'), + FieldSet('base_choices', 'choice', name=_('Choices')), ) base_choices = forms.MultipleChoiceField( choices=CustomFieldChoiceSetBaseChoices, @@ -107,8 +108,8 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm): class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Attributes'), ('object_type', 'enabled', 'new_window', 'weight')), + FieldSet('q', 'filter_id'), + FieldSet('object_type', 'enabled', 'new_window', 'weight', name=_('Attributes')), ) object_type = ContentTypeMultipleChoiceField( label=_('Object types'), @@ -137,9 +138,9 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Data'), ('data_source_id', 'data_file_id')), - (_('Attributes'), ('object_type_id', 'mime_type', 'file_extension', 'as_attachment')), + FieldSet('q', 'filter_id'), + FieldSet('data_source_id', 'data_file_id', name=_('Data')), + FieldSet('object_type_id', 'mime_type', 'file_extension', 'as_attachment', name=_('Attributes')), ) data_source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -178,8 +179,8 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Attributes'), ('object_type_id', 'name',)), + FieldSet('q', 'filter_id'), + FieldSet('object_type_id', 'name', name=_('Attributes')), ) object_type_id = ContentTypeChoiceField( label=_('Object type'), @@ -194,8 +195,8 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm): class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Attributes'), ('object_type', 'enabled', 'shared', 'weight')), + FieldSet('q', 'filter_id'), + FieldSet('object_type', 'enabled', 'shared', 'weight', name=_('Attributes')), ) object_type = ContentTypeMultipleChoiceField( label=_('Object types'), @@ -225,8 +226,8 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): class WebhookFilterForm(NetBoxModelFilterSetForm): model = Webhook fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('payload_url', 'http_method', 'http_content_type')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('payload_url', 'http_method', 'http_content_type', name=_('Attributes')), ) http_content_type = forms.CharField( label=_('HTTP content type'), @@ -249,9 +250,9 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('object_type_id', 'action_type', 'enabled')), - (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('object_type_id', 'action_type', 'enabled', name=_('Attributes')), + FieldSet('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', name=_('Events')), ) object_type_id = ContentTypeMultipleChoiceField( queryset=ObjectType.objects.with_feature('event_rules'), @@ -323,12 +324,12 @@ class TagFilterForm(SavedFiltersMixin, FilterForm): class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id', 'tag_id')), - (_('Data'), ('data_source_id', 'data_file_id')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')), - (_('Device'), ('device_type_id', 'platform_id', 'role_id')), - (_('Cluster'), ('cluster_type_id', 'cluster_group_id', 'cluster_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')) + FieldSet('q', 'filter_id', 'tag_id'), + FieldSet('data_source_id', 'data_file_id', name=_('Data')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), + FieldSet('device_type_id', 'platform_id', 'role_id', name=_('Device')), + FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')) ) data_source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -412,8 +413,8 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Data'), ('data_source_id', 'data_file_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('data_source_id', 'data_file_id', name=_('Data')), ) data_source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -444,9 +445,9 @@ class LocalConfigContextFilterForm(forms.Form): class JournalEntryFilterForm(NetBoxModelFilterSetForm): model = JournalEntry fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Creation'), ('created_before', 'created_after', 'created_by_id')), - (_('Attributes'), ('assigned_object_type_id', 'kind')) + FieldSet('q', 'filter_id', 'tag'), + FieldSet('created_before', 'created_after', 'created_by_id', name=_('Creation')), + FieldSet('assigned_object_type_id', 'kind', name=_('Attributes')), ) created_after = forms.DateTimeField( required=False, @@ -482,9 +483,9 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): model = ObjectChange fieldsets = ( - (None, ('q', 'filter_id')), - (_('Time'), ('time_before', 'time_after')), - (_('Attributes'), ('action', 'user_id', 'changed_object_type_id')), + FieldSet('q', 'filter_id'), + FieldSet('time_before', 'time_after', name=_('Time')), + FieldSet('action', 'user_id', 'changed_object_type_id', name=_('Attributes')), ) time_after = forms.DateTimeField( required=False, diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 09d2d9535..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 @@ -54,12 +55,15 @@ class CustomFieldForm(forms.ModelForm): ) fieldsets = ( - (_('Custom Field'), ( + FieldSet( 'object_types', 'name', 'label', 'group_name', 'type', 'related_object_type', 'required', 'description', - )), - (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')), - (_('Values'), ('default', 'choice_set')), - (_('Validation'), ('validation_minimum', 'validation_maximum', 'validation_regex')), + name=_('Custom Field') + ), + FieldSet( + 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior') + ), + FieldSet('default', 'choice_set', name=_('Values')), + FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')), ) class Meta: @@ -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/forms/reports.py b/netbox/extras/forms/reports.py index ad37eb744..358ee90e3 100644 --- a/netbox/extras/forms/reports.py +++ b/netbox/extras/forms/reports.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from extras.choices import DurationChoices from utilities.forms.widgets import DateTimePicker, NumberWithOptions -from utilities.utils import local_now +from utilities.datetime import local_now __all__ = ( 'ReportForm', diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index f67ad3e75..ece96f5e4 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from extras.choices import DurationChoices from utilities.forms.widgets import DateTimePicker, NumberWithOptions -from utilities.utils import local_now +from utilities.datetime import local_now __all__ = ( 'ScriptForm', diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index 2098b7a82..160e8813f 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -14,7 +14,7 @@ from extras.context_managers import event_tracking from extras.scripts import get_module_and_script from extras.signals import clear_events from utilities.exceptions import AbortTransaction -from utilities.utils import NetBoxFakeRequest +from utilities.request import NetBoxFakeRequest class Command(BaseCommand): diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index ff39c3e8b..6b52d4c02 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -9,11 +9,11 @@ from jinja2.sandbox import SandboxedEnvironment 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, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin -from utilities.jinja2 import ConfigTemplateLoader -from utilities.utils import deepmerge +from netbox.registry import registry +from utilities.data import deepmerge +from utilities.jinja2 import DataFileLoader __all__ = ( 'ConfigContext', @@ -290,7 +290,7 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta """ # Initialize the template loader & cache the base template code (if applicable) if self.data_file: - loader = ConfigTemplateLoader(data_source=self.data_source) + loader = DataFileLoader(data_source=self.data_source) loader.cache_templates({ self.data_file.path: self.template_code }) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index b55aaa11d..16f10b485 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -22,8 +22,10 @@ from netbox.models import ChangeLoggedModel from netbox.models.features import ( CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin, ) +from utilities.html import clean_html +from utilities.querydict import dict_to_querydict from utilities.querysets import RestrictedQuerySet -from utilities.utils import clean_html, dict_to_querydict, render_jinja2 +from utilities.jinja2 import render_jinja2 __all__ = ( 'Bookmark', diff --git a/netbox/extras/models/search.py b/netbox/extras/models/search.py index 3c2cebe8e..ae99f1735 100644 --- a/netbox/extras/models/search.py +++ b/netbox/extras/models/search.py @@ -4,9 +4,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from netbox.search.utils import get_indexer -from netbox.registry import registry from utilities.fields import RestrictedGenericForeignKey -from utilities.utils import content_type_identifier from ..fields import CachedValueField __all__ = ( diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py index f15d8d470..6e381ce70 100644 --- a/netbox/extras/models/staging.py +++ b/netbox/extras/models/staging.py @@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _ from extras.choices import ChangeActionChoices from netbox.models import ChangeLoggedModel from netbox.models.features import * -from utilities.utils import deserialize_object +from utilities.serialization import deserialize_object __all__ = ( 'Branch', diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 27b05638e..6af0d41c8 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -5,9 +5,9 @@ from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from taggit.models import TagBase, GenericTaggedItemBase +from netbox.choices import ColorChoices from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin -from utilities.choices import ColorChoices from utilities.fields import ColorField __all__ = ( diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 833ce0036..2813ed7ae 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -1,7 +1,8 @@ +import importlib import logging from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.db.models.fields.reverse_related import ManyToManyRel from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver, Signal @@ -13,7 +14,6 @@ from core.signals import job_end, job_start from extras.constants import EVENT_JOB_END, EVENT_JOB_START from extras.events import process_event_rules from extras.models import EventRule -from extras.validators import run_validators from netbox.config import get_config from netbox.context import current_request, events_queue from netbox.models.features import ChangeLoggingMixin @@ -22,6 +22,30 @@ from utilities.exceptions import AbortRequest from .choices import ObjectChangeActionChoices from .events import enqueue_object, get_snapshots, serialize_for_event from .models import CustomField, ObjectChange, TaggedItem +from .validators import CustomValidator + + +def run_validators(instance, validators): + """ + Run the provided iterable of validators for the instance. + """ + request = current_request.get() + for validator in validators: + + # Loading a validator class by dotted path + if type(validator) is str: + module, cls = validator.rsplit('.', 1) + validator = getattr(importlib.import_module(module), cls)() + + # Constructing a new instance on the fly from a ruleset + elif type(validator) is dict: + validator = CustomValidator(validator) + + elif not issubclass(validator.__class__, CustomValidator): + raise ImproperlyConfigured(f"Invalid value for custom validator: {validator}") + + validator(instance, request) + # # Change logging/webhooks diff --git a/netbox/extras/tests/test_custom_validation.py b/netbox/extras/tests/test_custom_validation.py index e375b49f5..652bc241b 100644 --- a/netbox/extras/tests/test_custom_validation.py +++ b/netbox/extras/tests/test_custom_validation.py @@ -5,7 +5,7 @@ from circuits.api.serializers import ProviderSerializer from circuits.forms import ProviderForm from circuits.models import Provider from ipam.models import ASN, RIR -from utilities.choices import CSVDelimiterChoices, ImportFormatChoices +from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 0c8b86f93..d4917cde9 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -12,7 +12,7 @@ from dcim.models import Manufacturer, Rack, Site from extras.choices import * from extras.models import CustomField, CustomFieldChoiceSet from ipam.models import VLAN -from utilities.choices import CSVDelimiterChoices, ImportFormatChoices +from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine diff --git a/netbox/extras/tests/test_customvalidation.py b/netbox/extras/tests/test_customvalidation.py index d74ad599b..7dca8a270 100644 --- a/netbox/extras/tests/test_customvalidation.py +++ b/netbox/extras/tests/test_customvalidation.py @@ -3,11 +3,13 @@ from django.core.exceptions import ValidationError from django.db import transaction from django.test import TestCase, override_settings -from ipam.models import ASN, RIR from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.validators import CustomValidator +from ipam.models import ASN, RIR +from users.models import User from utilities.exceptions import AbortRequest +from utilities.request import NetBoxFakeRequest class MyValidator(CustomValidator): @@ -79,6 +81,13 @@ prohibited_validator = CustomValidator({ } }) + +request_validator = CustomValidator({ + 'request.user.username': { + 'eq': 'Bob' + } +}) + custom_validator = MyValidator() @@ -154,6 +163,28 @@ class CustomValidatorTest(TestCase): def test_custom_valid(self): Site(name='foo', slug='foo').clean() + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [request_validator]}) + def test_request_validation(self): + alice = User.objects.create(username='Alice') + bob = User.objects.create(username='Bob') + request = NetBoxFakeRequest({ + 'META': {}, + 'POST': {}, + 'GET': {}, + 'FILES': {}, + 'user': alice, + 'path': '', + }) + site = Site(name='abc', slug='abc') + + # Attempt to create the Site as Alice + with self.assertRaises(ValidationError): + request_validator(site, request) + + # Creating the Site as Bob should succeed + request.user = bob + request_validator(site, request) + class CustomValidatorConfigTest(TestCase): diff --git a/netbox/extras/validators.py b/netbox/extras/validators.py index 30c9397d5..8d91ca66b 100644 --- a/netbox/extras/validators.py +++ b/netbox/extras/validators.py @@ -1,4 +1,5 @@ -import importlib +import inspect +import operator from django.core import validators from django.core.exceptions import ValidationError @@ -74,6 +75,8 @@ class CustomValidator: :param validation_rules: A dictionary mapping object attributes to validation rules """ + REQUEST_TOKEN = 'request' + VALIDATORS = { 'eq': IsEqualValidator, 'neq': IsNotEqualValidator, @@ -88,25 +91,56 @@ class CustomValidator: def __init__(self, validation_rules=None): self.validation_rules = validation_rules or {} - assert type(self.validation_rules) is dict, "Validation rules must be passed as a dictionary" + if type(self.validation_rules) is not dict: + raise ValueError(_("Validation rules must be passed as a dictionary")) - def __call__(self, instance): - # Validate instance attributes per validation rules - for attr_name, rules in self.validation_rules.items(): - attr = self._getattr(instance, attr_name) + def __call__(self, instance, request=None): + """ + Validate the instance and (optional) request against the validation rule(s). + """ + for attr_path, rules in self.validation_rules.items(): + + # The rule applies to the current request + if attr_path.split('.')[0] == self.REQUEST_TOKEN: + # Skip if no request has been provided (we can't validate) + if request is None: + continue + attr = self._get_request_attr(request, attr_path) + # The rule applies to the instance + else: + attr = self._get_instance_attr(instance, attr_path) + + # Validate the attribute's value against each of the rules defined for it for descriptor, value in rules.items(): validator = self.get_validator(descriptor, value) try: validator(attr) except ValidationError as exc: - # Re-package the raised ValidationError to associate it with the specific attr - raise ValidationError({attr_name: exc}) + raise ValidationError( + _("Custom validation failed for {attribute}: {exception}").format( + attribute=attr_path, exception=exc + ) + ) # Execute custom validation logic (if any) - self.validate(instance) + # TODO: Remove in v4.1 + # Inspect the validate() method, which may have been overridden, to determine + # whether we should pass the request (maintains backward compatibility for pre-v4.0) + if 'request' in inspect.signature(self.validate).parameters: + self.validate(instance, request) + else: + self.validate(instance) @staticmethod - def _getattr(instance, name): + def _get_request_attr(request, name): + name = name.split('.', maxsplit=1)[1] # Remove token + try: + return operator.attrgetter(name)(request) + except AttributeError: + raise ValidationError(_('Invalid attribute "{name}" for request').format(name=name)) + + @staticmethod + def _get_instance_attr(instance, name): # Attempt to resolve many-to-many fields to their stored values m2m_fields = [f.name for f in instance._meta.local_many_to_many] if name in m2m_fields: @@ -137,7 +171,7 @@ class CustomValidator: validator_cls = self.VALIDATORS.get(descriptor) return validator_cls(value) - def validate(self, instance): + def validate(self, instance, request): """ Custom validation method, to be overridden by the user. Validation failures should raise a ValidationError exception. @@ -151,21 +185,3 @@ class CustomValidator: if field is not None: raise ValidationError({field: message}) raise ValidationError(message) - - -def run_validators(instance, validators): - """ - Run the provided iterable of validators for the instance. - """ - for validator in validators: - - # Loading a validator class by dotted path - if type(validator) is str: - module, cls = validator.rsplit('.', 1) - validator = getattr(importlib.import_module(module), cls)() - - # Constructing a new instance on the fly from a ruleset - elif type(validator) is dict: - validator = CustomValidator(validator) - - validator(instance) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 1fa2a30aa..2468e9236 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -18,12 +18,15 @@ 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.data import shallow_compare_dict from utilities.forms import ConfirmationForm, get_field_value from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.query import count_related +from utilities.querydict import normalize_querydict +from utilities.request import copy_safe_request from utilities.rqworker import get_workers_for_queue from utilities.templatetags.builtins.filters import render_markdown -from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict -from utilities.views import ContentTypePermissionRequiredMixin, register_model_view +from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view from . import filtersets, forms, tables from .models import * from .scripts import run_script @@ -759,7 +762,6 @@ class ImageAttachmentListView(generic.ObjectListView): class ImageAttachmentEditView(generic.ObjectEditView): queryset = ImageAttachment.objects.all() form = forms.ImageAttachmentForm - template_name = 'extras/imageattachment_edit.html' def alter_object(self, instance, request, args, kwargs): if not instance.pk: diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 72d57e941..c7f64ab1d 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -13,6 +13,7 @@ from utilities.forms import add_blank_choice from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, ) +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect from virtualization.models import Cluster, ClusterGroup @@ -55,7 +56,7 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): model = VRF fieldsets = ( - (None, ('tenant', 'enforce_unique', 'description')), + FieldSet('tenant', 'enforce_unique', 'description'), ) nullable_fields = ('tenant', 'description', 'comments') @@ -75,7 +76,7 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): model = RouteTarget fieldsets = ( - (None, ('tenant', 'description')), + FieldSet('tenant', 'description'), ) nullable_fields = ('tenant', 'description', 'comments') @@ -94,7 +95,7 @@ class RIRBulkEditForm(NetBoxModelBulkEditForm): model = RIR fieldsets = ( - (None, ('is_private', 'description')), + FieldSet('is_private', 'description'), ) nullable_fields = ('is_private', 'description') @@ -118,7 +119,7 @@ class ASNRangeBulkEditForm(NetBoxModelBulkEditForm): model = ASNRange fieldsets = ( - (None, ('rir', 'tenant', 'description')), + FieldSet('rir', 'tenant', 'description'), ) nullable_fields = ('description',) @@ -148,7 +149,7 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): model = ASN fieldsets = ( - (None, ('sites', 'rir', 'tenant', 'description')), + FieldSet('sites', 'rir', 'tenant', 'description'), ) nullable_fields = ('tenant', 'description', 'comments') @@ -177,7 +178,7 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): model = Aggregate fieldsets = ( - (None, ('rir', 'tenant', 'date_added', 'description')), + FieldSet('rir', 'tenant', 'date_added', 'description'), ) nullable_fields = ('date_added', 'description', 'comments') @@ -195,7 +196,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): model = Role fieldsets = ( - (None, ('weight', 'description')), + FieldSet('weight', 'description'), ) nullable_fields = ('description',) @@ -265,9 +266,9 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): model = Prefix fieldsets = ( - (None, ('tenant', 'status', 'role', 'description')), - (_('Site'), ('region', 'site_group', 'site')), - (_('Addressing'), ('vrf', 'prefix_length', 'is_pool', 'mark_utilized')), + FieldSet('tenant', 'status', 'role', 'description'), + FieldSet('region', 'site_group', 'site', name=_('Site')), + FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')), ) nullable_fields = ( 'site', 'vrf', 'tenant', 'role', 'description', 'comments', @@ -309,7 +310,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): model = IPRange fieldsets = ( - (None, ('status', 'role', 'vrf', 'tenant', 'mark_utilized', 'description')), + FieldSet('status', 'role', 'vrf', 'tenant', 'mark_utilized', 'description'), ) nullable_fields = ( 'vrf', 'tenant', 'role', 'description', 'comments', @@ -357,8 +358,8 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): model = IPAddress fieldsets = ( - (None, ('status', 'role', 'tenant', 'description')), - (_('Addressing'), ('vrf', 'mask_length', 'dns_name')), + FieldSet('status', 'role', 'tenant', 'description'), + FieldSet('vrf', 'mask_length', 'dns_name', name=_('Addressing')), ) nullable_fields = ( 'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments', @@ -400,8 +401,8 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): model = FHRPGroup fieldsets = ( - (None, ('protocol', 'group_id', 'name', 'description')), - (_('Authentication'), ('auth_type', 'auth_key')), + FieldSet('protocol', 'group_id', 'name', 'description'), + FieldSet('auth_type', 'auth_key', name=_('Authentication')), ) nullable_fields = ('auth_type', 'auth_key', 'name', 'description', 'comments') @@ -485,8 +486,10 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): model = VLANGroup fieldsets = ( - (None, ('site', 'min_vid', 'max_vid', 'description')), - (_('Scope'), ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), + FieldSet('site', 'min_vid', 'max_vid', 'description'), + FieldSet( + 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope') + ), ) nullable_fields = ('description',) @@ -556,8 +559,8 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): model = VLAN fieldsets = ( - (None, ('status', 'role', 'tenant', 'description')), - (_('Site & Group'), ('region', 'site_group', 'site', 'group')), + FieldSet('status', 'role', 'tenant', 'description'), + FieldSet('region', 'site_group', 'site', 'group', name=_('Site & Group')), ) nullable_fields = ( 'site', 'group', 'tenant', 'role', 'description', 'comments', @@ -587,7 +590,7 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): model = ServiceTemplate fieldsets = ( - (None, ('protocol', 'ports', 'description')), + FieldSet('protocol', 'ports', 'description'), ) nullable_fields = ('description', 'comments') diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index cf2e4d46e..6610bcaf3 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -9,6 +9,7 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField +from utilities.forms.rendering import FieldSet from virtualization.models import VirtualMachine from vpn.models import L2VPN @@ -42,9 +43,9 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VRF fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Route Targets'), ('import_target_id', 'export_target_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('import_target_id', 'export_target_id', name=_('Route Targets')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) import_target_id = DynamicModelMultipleChoiceField( queryset=RouteTarget.objects.all(), @@ -62,9 +63,9 @@ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RouteTarget fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('VRF'), ('importing_vrf_id', 'exporting_vrf_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('importing_vrf_id', 'exporting_vrf_id', name=_('VRF')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) importing_vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), @@ -94,9 +95,9 @@ class RIRFilterForm(NetBoxModelFilterSetForm): class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Aggregate fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('family', 'rir_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('family', 'rir_id', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) family = forms.ChoiceField( required=False, @@ -114,9 +115,9 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ASNRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = ASNRange fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Range'), ('rir_id', 'start', 'end')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('rir_id', 'start', 'end', name=_('Range')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) rir_id = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), @@ -137,9 +138,9 @@ class ASNRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = ASN fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Assignment'), ('rir_id', 'site_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('rir_id', 'site_id', name=_('Assignment')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) rir_id = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), @@ -162,11 +163,14 @@ class RoleFilterForm(NetBoxModelFilterSetForm): class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Prefix fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Addressing'), ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')), - (_('VRF'), ('vrf_id', 'present_in_vrf_id')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet( + 'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized', + name=_('Addressing') + ), + FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) mask_length__lte = forms.IntegerField( widget=forms.HiddenInput() @@ -251,9 +255,9 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPRange fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_utilized', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) family = forms.ChoiceField( required=False, @@ -290,11 +294,14 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPAddress fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name')), - (_('VRF'), ('vrf_id', 'present_in_vrf_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Device/VM'), ('device_id', 'virtual_machine_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet( + 'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name', + name=_('Attributes') + ), + FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')), ) selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role') parent = forms.CharField( @@ -364,9 +371,9 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class FHRPGroupFilterForm(NetBoxModelFilterSetForm): model = FHRPGroup fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'protocol', 'group_id')), - (_('Authentication'), ('auth_type', 'auth_key')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'protocol', 'group_id', name=_('Attributes')), + FieldSet('auth_type', 'auth_key', name=_('Authentication')), ) name = forms.CharField( label=_('Name'), @@ -396,9 +403,9 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm): class VLANGroupFilterForm(NetBoxModelFilterSetForm): fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region', 'sitegroup', 'site', 'location', 'rack')), - (_('VLAN ID'), ('min_vid', 'max_vid')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')), + FieldSet('min_vid', 'max_vid', name=_('VLAN ID')), ) model = VLANGroup region = DynamicModelMultipleChoiceField( @@ -444,10 +451,10 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm): class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VLAN fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Attributes'), ('group_id', 'status', 'role_id', 'vid', 'l2vpn_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) selector_fields = ('filter_id', 'q', 'site_id') region_id = DynamicModelMultipleChoiceField( @@ -504,8 +511,8 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): model = ServiceTemplate fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('protocol', 'port')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('protocol', 'port', name=_('Attributes')), ) protocol = forms.ChoiceField( label=_('Protocol'), @@ -522,9 +529,9 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): class ServiceFilterForm(ServiceTemplateFilterForm): model = Service fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('protocol', 'port')), - (_('Assignment'), ('device_id', 'virtual_machine_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('protocol', 'port', name=_('Attributes')), + FieldSet('device_id', 'virtual_machine_id', name=_('Assignment')), ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 47087139a..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 = [ @@ -443,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: @@ -502,6 +518,10 @@ class FHRPGroupAssignmentForm(forms.ModelForm): queryset=FHRPGroup.objects.all() ) + fieldsets = ( + FieldSet(ObjectAttribute('interface'), 'group', 'priority'), + ) + class Meta: model = FHRPGroupAssignment fields = ('group', 'priority') @@ -587,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: @@ -662,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: @@ -704,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 = [ @@ -718,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/models/services.py b/netbox/ipam/models/services.py index 3e3261ee9..37b559801 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -8,8 +8,7 @@ from django.utils.translation import gettext_lazy as _ from ipam.choices import * from ipam.constants import * from netbox.models import PrimaryModel -from utilities.utils import array_to_string - +from utilities.data import array_to_string __all__ = ( 'Service', diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 2ff8a8b6e..a3f37fe3c 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -3,8 +3,8 @@ from django.db.models import Count, F, OuterRef, Q, Subquery, Value from django.db.models.expressions import RawSQL from django.db.models.functions import Round +from utilities.query import count_related from utilities.querysets import RestrictedQuerySet -from utilities.utils import count_related __all__ = ( 'ASNRangeQuerySet', diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 9c4a9a102..24d82d186 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -9,8 +9,8 @@ from circuits.models import Provider from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from netbox.views import generic +from utilities.query import count_related from utilities.tables import get_table_ordering -from utilities.utils import count_related from utilities.views import ViewTab, register_model_view from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface @@ -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/api/serializers/generic.py b/netbox/netbox/api/serializers/generic.py index fb4fab8b0..b1cf40406 100644 --- a/netbox/netbox/api/serializers/generic.py +++ b/netbox/netbox/api/serializers/generic.py @@ -2,9 +2,10 @@ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from core.models import ObjectType from netbox.api.fields import ContentTypeField from utilities.api import get_serializer_for_model -from utilities.utils import content_type_identifier +from utilities.object_types import object_type_identifier __all__ = ( 'GenericObjectSerializer', @@ -27,9 +28,9 @@ class GenericObjectSerializer(serializers.Serializer): return model.objects.get(pk=data['object_id']) def to_representation(self, instance): - ct = ContentType.objects.get_for_model(instance) + object_type = ObjectType.objects.get_for_model(instance) data = { - 'object_type': content_type_identifier(ct), + 'object_type': object_type_identifier(object_type), 'object_id': instance.pk, } if 'request' in self.context: diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index c70c68bc0..2b66639c8 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -12,7 +12,7 @@ from django.utils.translation import gettext_lazy as _ from users.constants import CONSTRAINT_TOKEN_USER from users.models import Group, ObjectPermission from utilities.permissions import ( - permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct, + permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_type, ) UserModel = get_user_model() @@ -284,11 +284,9 @@ class RemoteUserBackend(_RemoteUserBackend): permissions_list = [] for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items(): try: - object_type, action = resolve_permission_ct( - permission_name) - # TODO: Merge multiple actions into a single ObjectPermission per content type - obj_perm = ObjectPermission( - actions=[action], constraints=constraints) + object_type, action = resolve_permission_type(permission_name) + # TODO: Merge multiple actions into a single ObjectPermission per object type + obj_perm = ObjectPermission(actions=[action], constraints=constraints) obj_perm.save() obj_perm.users.add(user) obj_perm.object_types.add(object_type) @@ -303,7 +301,9 @@ class RemoteUserBackend(_RemoteUserBackend): f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}") else: logger.debug( - f"Skipped initial assignment of permissions and groups to remotely-authenticated user {user} as Group sync is enabled") + f"Skipped initial assignment of permissions and groups to remotely-authenticated user {user} as " + f"Group sync is enabled" + ) return user diff --git a/netbox/netbox/choices.py b/netbox/netbox/choices.py new file mode 100644 index 000000000..fe941056f --- /dev/null +++ b/netbox/netbox/choices.py @@ -0,0 +1,162 @@ +from django.utils.translation import gettext_lazy as _ + +from utilities.choices import ChoiceSet +from utilities.constants import CSV_DELIMITERS + +__all__ = ( + 'ButtonColorChoices', + 'ColorChoices', + 'CSVDelimiterChoices', + 'ImportFormatChoices', + 'ImportMethodChoices', +) + + +# +# Generic color choices +# + +class ColorChoices(ChoiceSet): + COLOR_DARK_RED = 'aa1409' + COLOR_RED = 'f44336' + COLOR_PINK = 'e91e63' + COLOR_ROSE = 'ffe4e1' + COLOR_FUCHSIA = 'ff66ff' + COLOR_PURPLE = '9c27b0' + COLOR_DARK_PURPLE = '673ab7' + COLOR_INDIGO = '3f51b5' + COLOR_BLUE = '2196f3' + COLOR_LIGHT_BLUE = '03a9f4' + COLOR_CYAN = '00bcd4' + COLOR_TEAL = '009688' + COLOR_AQUA = '00ffff' + COLOR_DARK_GREEN = '2f6a31' + COLOR_GREEN = '4caf50' + COLOR_LIGHT_GREEN = '8bc34a' + COLOR_LIME = 'cddc39' + COLOR_YELLOW = 'ffeb3b' + COLOR_AMBER = 'ffc107' + COLOR_ORANGE = 'ff9800' + COLOR_DARK_ORANGE = 'ff5722' + COLOR_BROWN = '795548' + COLOR_LIGHT_GREY = 'c0c0c0' + COLOR_GREY = '9e9e9e' + COLOR_DARK_GREY = '607d8b' + COLOR_BLACK = '111111' + COLOR_WHITE = 'ffffff' + + CHOICES = ( + (COLOR_DARK_RED, _('Dark Red')), + (COLOR_RED, _('Red')), + (COLOR_PINK, _('Pink')), + (COLOR_ROSE, _('Rose')), + (COLOR_FUCHSIA, _('Fuchsia')), + (COLOR_PURPLE, _('Purple')), + (COLOR_DARK_PURPLE, _('Dark Purple')), + (COLOR_INDIGO, _('Indigo')), + (COLOR_BLUE, _('Blue')), + (COLOR_LIGHT_BLUE, _('Light Blue')), + (COLOR_CYAN, _('Cyan')), + (COLOR_TEAL, _('Teal')), + (COLOR_AQUA, _('Aqua')), + (COLOR_DARK_GREEN, _('Dark Green')), + (COLOR_GREEN, _('Green')), + (COLOR_LIGHT_GREEN, _('Light Green')), + (COLOR_LIME, _('Lime')), + (COLOR_YELLOW, _('Yellow')), + (COLOR_AMBER, _('Amber')), + (COLOR_ORANGE, _('Orange')), + (COLOR_DARK_ORANGE, _('Dark Orange')), + (COLOR_BROWN, _('Brown')), + (COLOR_LIGHT_GREY, _('Light Grey')), + (COLOR_GREY, _('Grey')), + (COLOR_DARK_GREY, _('Dark Grey')), + (COLOR_BLACK, _('Black')), + (COLOR_WHITE, _('White')), + ) + + +# +# Button color choices +# + +class ButtonColorChoices(ChoiceSet): + """ + Map standard button color choices to Bootstrap 3 button classes + """ + DEFAULT = 'outline-dark' + BLUE = 'blue' + INDIGO = 'indigo' + PURPLE = 'purple' + PINK = 'pink' + RED = 'red' + ORANGE = 'orange' + YELLOW = 'yellow' + GREEN = 'green' + TEAL = 'teal' + CYAN = 'cyan' + GRAY = 'gray' + GREY = 'gray' # Backward compatability for <3.2 + BLACK = 'black' + WHITE = 'white' + + CHOICES = ( + (DEFAULT, _('Default')), + (BLUE, _('Blue')), + (INDIGO, _('Indigo')), + (PURPLE, _('Purple')), + (PINK, _('Pink')), + (RED, _('Red')), + (ORANGE, _('Orange')), + (YELLOW, _('Yellow')), + (GREEN, _('Green')), + (TEAL, _('Teal')), + (CYAN, _('Cyan')), + (GRAY, _('Gray')), + (BLACK, _('Black')), + (WHITE, _('White')), + ) + + +# +# Import Choices +# + +class ImportMethodChoices(ChoiceSet): + DIRECT = 'direct' + UPLOAD = 'upload' + DATA_FILE = 'datafile' + + CHOICES = [ + (DIRECT, _('Direct')), + (UPLOAD, _('Upload')), + (DATA_FILE, _('Data file')), + ] + + +class ImportFormatChoices(ChoiceSet): + AUTO = 'auto' + CSV = 'csv' + JSON = 'json' + YAML = 'yaml' + + CHOICES = [ + (AUTO, _('Auto-detect')), + (CSV, 'CSV'), + (JSON, 'JSON'), + (YAML, 'YAML'), + ] + + +class CSVDelimiterChoices(ChoiceSet): + AUTO = 'auto' + COMMA = CSV_DELIMITERS['comma'] + SEMICOLON = CSV_DELIMITERS['semicolon'] + TAB = CSV_DELIMITERS['tab'] + + CHOICES = [ + (AUTO, _('Auto-detect')), + (COMMA, _('Comma')), + (SEMICOLON, _('Semicolon')), + (TAB, _('Tab')), + ] 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/middleware.py b/netbox/netbox/middleware.py index cb7d2c8ba..6e7da9ab0 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -13,7 +13,8 @@ from django.http import Http404, HttpResponseRedirect from extras.context_managers import event_tracking from netbox.config import clear_config, get_config from netbox.views import handler_500 -from utilities.api import is_api_request, rest_api_server_error +from utilities.api import is_api_request +from utilities.error_handlers import handle_rest_api_exception __all__ = ( 'CoreMiddleware', @@ -71,7 +72,7 @@ class CoreMiddleware: # Cleanly handle exceptions that occur from REST API requests if is_api_request(request): - return rest_api_server_error(request) + return handle_rest_api_exception(request) # Ignore Http404s (defer to Django's built-in 404 handling) if isinstance(exception, Http404): @@ -211,7 +212,7 @@ class MaintenanceModeMiddleware: 'operations. Please try again later.' if is_api_request(request): - return rest_api_server_error(request, error=error_message) + return handle_rest_api_exception(request, error=error_message) messages.error(request, error_message) return HttpResponseRedirect(request.path_info) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index bff9ee59f..000e717a4 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -17,7 +17,7 @@ from netbox.config import get_config from netbox.registry import registry from netbox.signals import post_clean from utilities.json import CustomFieldJSONEncoder -from utilities.utils import serialize_object +from utilities.serialization import serialize_object from utilities.views import register_model_view __all__ = ( diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py index 63d2af9c1..d13282f7e 100644 --- a/netbox/netbox/navigation/__init__.py +++ b/netbox/netbox/navigation/__init__.py @@ -1,8 +1,6 @@ from dataclasses import dataclass from typing import Sequence, Optional -from utilities.choices import ButtonColorChoices - __all__ = ( 'get_model_item', diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 621bd4f5d..688c5a3ad 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,7 +1,6 @@ from django.utils.translation import gettext_lazy as _ from netbox.registry import registry -from utilities.choices import ButtonColorChoices from . import * # diff --git a/netbox/netbox/plugins/navigation.py b/netbox/netbox/plugins/navigation.py index aae569412..01b8a0442 100644 --- a/netbox/netbox/plugins/navigation.py +++ b/netbox/netbox/plugins/navigation.py @@ -1,8 +1,9 @@ -from netbox.navigation import MenuGroup -from utilities.choices import ButtonColorChoices from django.utils.text import slugify from django.utils.translation import gettext as _ +from netbox.choices import ButtonColorChoices +from netbox.navigation import MenuGroup + __all__ = ( 'PluginMenu', 'PluginMenuButton', diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index a9e867b9f..227a79205 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -14,8 +14,9 @@ from netaddr.core import AddrFormatError from core.models import ObjectType from extras.models import CachedValue, CustomField from netbox.registry import registry +from utilities.object_types import object_type_identifier from utilities.querysets import RestrictedPrefetch -from utilities.utils import content_type_identifier, title +from utilities.string import title from . import FieldTypes, LookupTypes, get_indexer DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL @@ -156,7 +157,7 @@ class CachedValueSearchBackend(SearchBackend): # related objects necessary to render the prescribed display attributes (display_attrs). for object_type in object_types: model = object_type.model_class() - indexer = registry['search'].get(content_type_identifier(object_type)) + indexer = registry['search'].get(object_type_identifier(object_type)) if not (display_attrs := getattr(indexer, 'display_attrs', None)): continue diff --git a/netbox/netbox/search/utils.py b/netbox/netbox/search/utils.py index 824fbfb3d..9ae5edee5 100644 --- a/netbox/netbox/search/utils.py +++ b/netbox/netbox/search/utils.py @@ -1,14 +1,14 @@ from netbox.registry import registry -from utilities.utils import content_type_identifier +from utilities.object_types import object_type_identifier __all__ = ( 'get_indexer', ) -def get_indexer(content_type): +def get_indexer(object_type): """ Return the registered search indexer for the given ContentType. """ - ct_identifier = content_type_identifier(content_type) - return registry['search'].get(ct_identifier) + identifier = object_type_identifier(object_type) + return registry['search'].get(identifier) diff --git a/netbox/netbox/staging.py b/netbox/netbox/staging.py index ec38dcadc..4d37fb7ad 100644 --- a/netbox/netbox/staging.py +++ b/netbox/netbox/staging.py @@ -6,7 +6,7 @@ from django.db.models.signals import m2m_changed, pre_delete, post_save from extras.choices import ChangeActionChoices from extras.models import StagedChange -from utilities.utils import serialize_object +from utilities.serialization import serialize_object logger = logging.getLogger('netbox.staging') diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 442e5f260..193bf8a17 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -18,9 +18,10 @@ from django_tables2.columns import library from django_tables2.utils import Accessor from extras.choices import CustomFieldTypeChoices +from utilities.object_types import object_type_identifier, object_type_name from utilities.permissions import get_permission_for_model from utilities.templatetags.builtins.filters import render_markdown -from utilities.utils import content_type_identifier, content_type_name, get_viewname +from utilities.views import get_viewname __all__ = ( 'ActionsColumn', @@ -338,12 +339,12 @@ class ContentTypeColumn(tables.Column): def render(self, value): if value is None: return None - return content_type_name(value, include_app=False) + return object_type_name(value, include_app=False) def value(self, value): if value is None: return None - return content_type_identifier(value) + return object_type_identifier(value) class ContentTypesColumn(tables.ManyToManyColumn): @@ -357,11 +358,11 @@ class ContentTypesColumn(tables.ManyToManyColumn): super().__init__(separator=separator, *args, **kwargs) def transform(self, obj): - return content_type_name(obj, include_app=False) + return object_type_name(obj, include_app=False) def value(self, value): return ','.join([ - content_type_identifier(ct) for ct in self.filter(value) + object_type_identifier(ot) for ot in self.filter(value) ]) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 31502f6c5..d8db511a2 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -17,7 +17,9 @@ from extras.models import CustomField, CustomLink from netbox.registry import registry from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count -from utilities.utils import get_viewname, highlight_string, title +from utilities.html import highlight +from utilities.string import title +from utilities.views import get_viewname from .template_code import * __all__ = ( @@ -273,6 +275,6 @@ class SearchTable(tables.Table): if not self.highlight: return value - value = highlight_string(value, self.highlight, trim_pre=self.trim_length, trim_post=self.trim_length) + value = highlight(value, self.highlight, trim_pre=self.trim_length, trim_post=self.trim_length) return mark_safe(value) diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py index b0b21a07d..f382d0112 100644 --- a/netbox/netbox/tests/test_import.py +++ b/netbox/netbox/tests/test_import.py @@ -2,8 +2,8 @@ from django.test import override_settings from core.models import ObjectType from dcim.models import * +from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from users.models import ObjectPermission -from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import ModelViewTestCase, create_tags diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 022059e51..ba4e585ad 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -24,8 +24,7 @@ from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViol from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields from utilities.forms.bulk_import import BulkImportForm from utilities.permissions import get_permission_for_model -from utilities.utils import get_viewname -from utilities.views import GetReturnURLMixin +from utilities.views import GetReturnURLMixin, get_viewname from .base import BaseMultiObjectView from .mixins import ActionsMixin, TableMixin from .utils import get_prerequisite_model diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 38c0ab488..26bd7de65 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -18,8 +18,8 @@ from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.forms import ConfirmationForm, restrict_form_fields from utilities.permissions import get_permission_for_model -from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields -from utilities.views import GetReturnURLMixin +from utilities.querydict import normalize_querydict, prepare_cloned_fields +from utilities.views import GetReturnURLMixin, get_viewname from .base import BaseObjectView from .mixins import ActionsMixin, TableMixin from .utils import get_prerequisite_model 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 %} -
-
-
{{ group }}
-
- {% for name in fields %} - {% render_field form|getfield:name %} - {% endfor %} -
+ {% for fieldset in form.fieldsets %} + {% render_fieldset form fieldset %} {% endfor %} {# Plugin preferences #} diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 1c58047ef..bb35cd3bf 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -9,13 +9,7 @@ data-netbox-url-name="{{ request.resolver_match.url_name }}" data-netbox-base-path="{{ settings.BASE_PATH }}" {% with preferences|get_key:'ui.colormode' as color_mode %} - {% if color_mode == 'dark'%} - data-netbox-color-mode="dark" - {% elif color_mode == 'light' %} - data-netbox-color-mode="light" - {% else %} - data-netbox-color-mode="unset" - {% endif %} + data-netbox-color-mode="{{ color_mode|default:"unset" }}" {% endwith %} > @@ -25,7 +19,16 @@ {# Page title #} {% block title %}{% trans "Home" %}{% endblock %} | NetBox + {# Initialize color mode #} + @@ -53,13 +56,9 @@ {# Additional content #} {% block head %}{% endblock %} - - + + {# Page layout #} {% block layout %}{% endblock %} diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index fff12c1e8..071396575 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -41,7 +41,7 @@ Blocks: {# Top menu #}