Jelajahi Sumber

Merge branch 'feature' into 9856-strawberry-2

Arthur 1 tahun lalu
induk
melakukan
be522467ab
100 mengubah file dengan 1129 tambahan dan 1025 penghapusan
  1. 22 3
      docs/customization/custom-validation.md
  2. 3 2
      docs/development/internationalization.md
  3. 4 0
      docs/models/dcim/location.md
  4. 27 12
      docs/plugins/development/forms.md
  5. 1 1
      docs/plugins/development/navigation.md
  6. 10 0
      docs/release-notes/version-4.0.md
  7. 8 7
      netbox/circuits/forms/bulk_edit.py
  8. 17 16
      netbox/circuits/forms/filtersets.py
  9. 19 8
      netbox/circuits/forms/model_forms.py
  10. 1 2
      netbox/circuits/views.py
  11. 2 1
      netbox/core/forms/bulk_edit.py
  12. 12 11
      netbox/core/forms/filtersets.py
  13. 22 15
      netbox/core/forms/model_forms.py
  14. 3 2
      netbox/core/models/data.py
  15. 1 1
      netbox/core/views.py
  16. 2 2
      netbox/dcim/api/serializers_/sites.py
  17. 3 2
      netbox/dcim/filtersets.py
  18. 58 55
      netbox/dcim/forms/bulk_edit.py
  19. 1 1
      netbox/dcim/forms/bulk_import.py
  20. 133 125
      netbox/dcim/forms/filtersets.py
  21. 110 92
      netbox/dcim/forms/model_forms.py
  22. 4 3
      netbox/dcim/forms/object_create.py
  23. 18 0
      netbox/dcim/migrations/0186_location_facility.py
  24. 1 1
      netbox/dcim/models/cables.py
  25. 1 1
      netbox/dcim/models/device_components.py
  26. 1 1
      netbox/dcim/models/devices.py
  27. 1 1
      netbox/dcim/models/mixins.py
  28. 3 2
      netbox/dcim/models/racks.py
  29. 7 1
      netbox/dcim/models/sites.py
  30. 2 1
      netbox/dcim/search.py
  31. 1 1
      netbox/dcim/svg/cables.py
  32. 2 1
      netbox/dcim/svg/racks.py
  33. 5 3
      netbox/dcim/tables/sites.py
  34. 8 5
      netbox/dcim/tests/test_filtersets.py
  35. 1 1
      netbox/dcim/tests/test_models.py
  36. 2 2
      netbox/dcim/tests/test_views.py
  37. 1 4
      netbox/dcim/views.py
  38. 1 1
      netbox/extras/api/views.py
  39. 2 1
      netbox/extras/choices.py
  40. 8 6
      netbox/extras/dashboard/widgets.py
  41. 2 5
      netbox/extras/events.py
  42. 35 34
      netbox/extras/forms/filtersets.py
  43. 42 29
      netbox/extras/forms/model_forms.py
  44. 1 1
      netbox/extras/forms/reports.py
  45. 1 1
      netbox/extras/forms/scripts.py
  46. 1 1
      netbox/extras/management/commands/runscript.py
  47. 4 4
      netbox/extras/models/configs.py
  48. 3 1
      netbox/extras/models/models.py
  49. 0 2
      netbox/extras/models/search.py
  50. 1 1
      netbox/extras/models/staging.py
  51. 1 1
      netbox/extras/models/tags.py
  52. 26 2
      netbox/extras/signals.py
  53. 1 1
      netbox/extras/tests/test_custom_validation.py
  54. 1 1
      netbox/extras/tests/test_customfields.py
  55. 32 1
      netbox/extras/tests/test_customvalidation.py
  56. 45 29
      netbox/extras/validators.py
  57. 5 3
      netbox/extras/views.py
  58. 23 20
      netbox/ipam/forms/bulk_edit.py
  59. 50 43
      netbox/ipam/forms/filtersets.py
  60. 78 31
      netbox/ipam/forms/model_forms.py
  61. 1 2
      netbox/ipam/models/services.py
  62. 1 1
      netbox/ipam/querysets.py
  63. 1 5
      netbox/ipam/views.py
  64. 4 3
      netbox/netbox/api/serializers/generic.py
  65. 7 7
      netbox/netbox/authentication.py
  66. 162 0
      netbox/netbox/choices.py
  67. 1 1
      netbox/netbox/forms/base.py
  68. 4 3
      netbox/netbox/middleware.py
  69. 1 1
      netbox/netbox/models/features.py
  70. 0 2
      netbox/netbox/navigation/__init__.py
  71. 0 1
      netbox/netbox/navigation/menu.py
  72. 3 2
      netbox/netbox/plugins/navigation.py
  73. 3 2
      netbox/netbox/search/backends.py
  74. 4 4
      netbox/netbox/search/utils.py
  75. 1 1
      netbox/netbox/staging.py
  76. 6 5
      netbox/netbox/tables/columns.py
  77. 4 2
      netbox/netbox/tables/tables.py
  78. 1 1
      netbox/netbox/tests/test_import.py
  79. 1 2
      netbox/netbox/views/generic/bulk_views.py
  80. 2 2
      netbox/netbox/views/generic/object_views.py
  81. 3 2
      netbox/project-static/js/setmode.js
  82. 2 9
      netbox/templates/account/preferences.html
  83. 12 13
      netbox/templates/base/base.html
  84. 3 3
      netbox/templates/base/layout.html
  85. 0 58
      netbox/templates/circuits/circuittermination_edit.html
  86. 1 1
      netbox/templates/core/rq_task_list.html
  87. 1 1
      netbox/templates/core/rq_worker_list.html
  88. 0 107
      netbox/templates/dcim/inventoryitem_edit.html
  89. 4 0
      netbox/templates/dcim/location.html
  90. 0 90
      netbox/templates/dcim/rack_edit.html
  91. 0 19
      netbox/templates/extras/imageattachment_edit.html
  92. 1 1
      netbox/templates/extras/script_result.html
  93. 2 2
      netbox/templates/generic/_base.html
  94. 1 1
      netbox/templates/generic/bulk_delete.html
  95. 2 17
      netbox/templates/generic/bulk_edit.html
  96. 5 3
      netbox/templates/generic/bulk_remove.html
  97. 1 1
      netbox/templates/generic/object.html
  98. 2 15
      netbox/templates/htmx/form.html
  99. 2 11
      netbox/templates/inc/filter_list.html
  100. 0 19
      netbox/templates/ipam/fhrpgroupassignment_edit.html

+ 22 - 3
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
 
 
-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
 ```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.
 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:
 The `CustomValidator` class supports several validation types:
 
 
 * `min`: Minimum value
 * `min`: Minimum value
@@ -34,16 +36,33 @@ The `min` and `max` types should be defined for numeric values, whereas `min_len
 !!! warning
 !!! 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.
     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
 ### 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
 ```python
 from extras.validators import CustomValidator
 from extras.validators import CustomValidator
 
 
 class MyValidator(CustomValidator):
 class MyValidator(CustomValidator):
 
 
-    def validate(self, instance):
+    def validate(self, instance, request):
         if instance.status == 'active' and not instance.description:
         if instance.status == 'active' and not instance.description:
             self.fail("Active sites must have a description set!", field='status')
             self.fail("Active sites must have a description set!", field='status')
 ```
 ```

+ 3 - 2
docs/development/internationalization.md

@@ -62,10 +62,11 @@ class Circuit(PrimaryModel):
 
 
 1. Import `gettext_lazy` as `_`.
 1. Import `gettext_lazy` as `_`.
 2. All form fields must specify a `label` wrapped with `gettext_lazy()`.
 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
 ```python
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
+from utilities.forms.rendering import FieldSet
 
 
 class CircuitBulkEditForm(NetBoxModelBulkEditForm):
 class CircuitBulkEditForm(NetBoxModelBulkEditForm):
     description = forms.CharField(
     description = forms.CharField(
@@ -74,7 +75,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
     )
     )
 
 
     fieldsets = (
     fieldsets = (
-        (_('Circuit'), ('provider', 'type', 'status', 'description')),
+        FieldSet('provider', 'type', 'status', 'description', name=_('Circuit')),
     )
     )
 ```
 ```
 
 

+ 4 - 0
docs/models/dcim/location.md

@@ -26,3 +26,7 @@ The location's operational status.
 
 
 !!! tip
 !!! tip
     Additional statuses may be defined by setting `Location.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
     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.

+ 27 - 12
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.
 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**
 **Example**
 
 
 ```python
 ```python
+from django.utils.translation import gettext_lazy as _
 from dcim.models import Site
 from dcim.models import Site
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from utilities.forms.fields import CommentField, DynamicModelChoiceField
 from utilities.forms.fields import CommentField, DynamicModelChoiceField
+from utilities.forms.rendering import FieldSet
 from .models import MyModel
 from .models import MyModel
 
 
 class MyModelForm(NetBoxModelForm):
 class MyModelForm(NetBoxModelForm):
@@ -33,8 +35,8 @@ class MyModelForm(NetBoxModelForm):
     )
     )
     comments = CommentField()
     comments = CommentField()
     fieldsets = (
     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:
     class Meta:
@@ -52,6 +54,7 @@ This form facilitates the bulk import of new objects from CSV, JSON, or YAML dat
 **Example**
 **Example**
 
 
 ```python
 ```python
+from django.utils.translation import gettext_lazy as _
 from dcim.models import Site
 from dcim.models import Site
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from utilities.forms import CSVModelChoiceField
 from utilities.forms import CSVModelChoiceField
@@ -62,7 +65,7 @@ class MyModelImportForm(NetBoxModelImportForm):
     site = CSVModelChoiceField(
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='name',
         to_field_name='name',
-        help_text='Assigned site'
+        help_text=_('Assigned site')
     )
     )
 
 
     class Meta:
     class Meta:
@@ -77,16 +80,18 @@ This form facilitates editing multiple objects in bulk. Unlike a model form, thi
 | Attribute         | Description                                                                                 |
 | Attribute         | Description                                                                                 |
 |-------------------|---------------------------------------------------------------------------------------------|
 |-------------------|---------------------------------------------------------------------------------------------|
 | `model`           | The model of object being edited                                                            |
 | `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) |
 | `nullable_fields` | A tuple of fields which can be nullified (set to empty) using the bulk edit form (optional) |
 
 
 **Example**
 **Example**
 
 
 ```python
 ```python
 from django import forms
 from django import forms
+from django.utils.translation import gettext_lazy as _
 from dcim.models import Site
 from dcim.models import Site
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from utilities.forms import CommentField, DynamicModelChoiceField
 from utilities.forms import CommentField, DynamicModelChoiceField
+from utilities.forms.rendering import FieldSet
 from .models import MyModel, MyModelStatusChoices
 from .models import MyModel, MyModelStatusChoices
 
 
 
 
@@ -106,7 +111,7 @@ class MyModelEditForm(NetBoxModelImportForm):
 
 
     model = MyModel
     model = MyModel
     fieldsets = (
     fieldsets = (
-        ('Model Stuff', ('name', 'status', 'site')),
+        FieldSet('name', 'status', 'site', name=_('Model Stuff')),
     )
     )
     nullable_fields = ('site', 'comments')
     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.
 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**
 **Example**
 
 
@@ -206,3 +211,13 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
 ::: utilities.forms.fields.CSVMultipleContentTypeField
 ::: utilities.forms.fields.CSVMultipleContentTypeField
     options:
     options:
       members: false
       members: false
+
+## Form Rendering
+
+::: utilities.forms.rendering.FieldSet
+
+::: utilities.forms.rendering.InlineFields
+
+::: utilities.forms.rendering.TabbedGroups
+
+::: utilities.forms.rendering.ObjectAttribute

+ 1 - 1
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.
 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"
 ```python title="navigation.py"
+from netbox.choices import ButtonColorChoices
 from netbox.plugins import PluginMenuButton, PluginMenuItem
 from netbox.plugins import PluginMenuButton, PluginMenuItem
-from utilities.choices import ButtonColorChoices
 
 
 item1 = PluginMenuItem(
 item1 = PluginMenuItem(
     link='plugins:myplugin:myview',
     link='plugins:myplugin:myview',

+ 10 - 0
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 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 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
 ### 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.
 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
 ### Enhancements
 
 
 * [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 ### 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
 * [#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
 * [#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
 * [#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
 * [#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)
 * [#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)
 
 

+ 8 - 7
netbox/circuits/forms/bulk_edit.py

@@ -8,6 +8,7 @@ from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice
 from utilities.forms import add_blank_choice
 from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import DatePicker, NumberWithOptions
 from utilities.forms.widgets import DatePicker, NumberWithOptions
 
 
 __all__ = (
 __all__ = (
@@ -34,7 +35,7 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Provider
     model = Provider
     fieldsets = (
     fieldsets = (
-        (None, ('asns', 'description')),
+        FieldSet('asns', 'description'),
     )
     )
     nullable_fields = (
     nullable_fields = (
         'asns', 'description', 'comments',
         'asns', 'description', 'comments',
@@ -56,7 +57,7 @@ class ProviderAccountBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = ProviderAccount
     model = ProviderAccount
     fieldsets = (
     fieldsets = (
-        (None, ('provider', 'description')),
+        FieldSet('provider', 'description'),
     )
     )
     nullable_fields = (
     nullable_fields = (
         'description', 'comments',
         'description', 'comments',
@@ -83,7 +84,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = ProviderNetwork
     model = ProviderNetwork
     fieldsets = (
     fieldsets = (
-        (None, ('provider', 'service_id', 'description')),
+        FieldSet('provider', 'service_id', 'description'),
     )
     )
     nullable_fields = (
     nullable_fields = (
         'service_id', 'description', 'comments',
         'service_id', 'description', 'comments',
@@ -103,7 +104,7 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = CircuitType
     model = CircuitType
     fieldsets = (
     fieldsets = (
-        (None, ('color', 'description')),
+        FieldSet('color', 'description'),
     )
     )
     nullable_fields = ('color', 'description')
     nullable_fields = ('color', 'description')
 
 
@@ -164,9 +165,9 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Circuit
     model = Circuit
     fieldsets = (
     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 = (
     nullable_fields = (
         'tenant', 'commit_rate', 'description', 'comments',
         'tenant', 'commit_rate', 'description', 'comments',

+ 17 - 16
netbox/circuits/forms/filtersets.py

@@ -8,6 +8,7 @@ from ipam.models import ASN
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
 from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
 from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
+from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import DatePicker, NumberWithOptions
 from utilities.forms.widgets import DatePicker, NumberWithOptions
 
 
 __all__ = (
 __all__ = (
@@ -22,10 +23,10 @@ __all__ = (
 class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Provider
     model = Provider
     fieldsets = (
     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(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -61,8 +62,8 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
 class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
     model = ProviderAccount
     model = ProviderAccount
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'filter_id', 'tag')),
-        (_('Attributes'), ('provider_id', 'account')),
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('provider_id', 'account', name=_('Attributes')),
     )
     )
     provider_id = DynamicModelMultipleChoiceField(
     provider_id = DynamicModelMultipleChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
@@ -79,8 +80,8 @@ class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
 class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
 class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
     model = ProviderNetwork
     model = ProviderNetwork
     fieldsets = (
     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(
     provider_id = DynamicModelMultipleChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
@@ -98,8 +99,8 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
 class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
 class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
     model = CircuitType
     model = CircuitType
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'filter_id', 'tag')),
-        (_('Attributes'), ('color',)),
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('color', name=_('Attributes')),
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
@@ -112,12 +113,12 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
 class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
 class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Circuit
     model = Circuit
     fieldsets = (
     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')
     selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
     type_id = DynamicModelMultipleChoiceField(
     type_id = DynamicModelMultipleChoiceField(

+ 19 - 8
netbox/circuits/forms/model_forms.py

@@ -7,6 +7,7 @@ from ipam.models import ASN
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
 from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
+from utilities.forms.rendering import FieldSet, TabbedGroups
 from utilities.forms.widgets import DatePicker, NumberWithOptions
 from utilities.forms.widgets import DatePicker, NumberWithOptions
 
 
 __all__ = (
 __all__ = (
@@ -29,7 +30,7 @@ class ProviderForm(NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('Provider'), ('name', 'slug', 'asns', 'description', 'tags')),
+        FieldSet('name', 'slug', 'asns', 'description', 'tags'),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -61,7 +62,7 @@ class ProviderNetworkForm(NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('Provider Network'), ('provider', 'name', 'service_id', 'description', 'tags')),
+        FieldSet('provider', 'name', 'service_id', 'description', 'tags'),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -75,9 +76,7 @@ class CircuitTypeForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('Circuit Type'), (
-            'name', 'slug', 'color', 'description', 'tags',
-        )),
+        FieldSet('name', 'slug', 'color', 'description', 'tags'),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -107,9 +106,9 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -146,6 +145,18 @@ class CircuitTerminationForm(NetBoxModelForm):
         selector=True
         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:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         fields = [

+ 1 - 2
netbox/circuits/views.py

@@ -6,7 +6,7 @@ from dcim.views import PathTraceView
 from netbox.views import generic
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from tenancy.views import ObjectContactsView
 from utilities.forms import ConfirmationForm
 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 utilities.views import register_model_view
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .models import *
 from .models import *
@@ -412,7 +412,6 @@ class CircuitContactsView(ObjectContactsView):
 class CircuitTerminationEditView(generic.ObjectEditView):
 class CircuitTerminationEditView(generic.ObjectEditView):
     queryset = CircuitTermination.objects.all()
     queryset = CircuitTermination.objects.all()
     form = forms.CircuitTerminationForm
     form = forms.CircuitTerminationForm
-    template_name = 'circuits/circuittermination_edit.html'
 
 
 
 
 @register_model_view(CircuitTermination, 'delete')
 @register_model_view(CircuitTermination, 'delete')

+ 2 - 1
netbox/core/forms/bulk_edit.py

@@ -5,6 +5,7 @@ from core.models import *
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.utils import get_data_backend_choices
 from netbox.utils import get_data_backend_choices
 from utilities.forms.fields import CommentField
 from utilities.forms.fields import CommentField
+from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 
 
 __all__ = (
 __all__ = (
@@ -41,7 +42,7 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = DataSource
     model = DataSource
     fieldsets = (
     fieldsets = (
-        (None, ('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules')),
+        FieldSet('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules'),
     )
     )
     nullable_fields = (
     nullable_fields = (
         'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules',
         'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules',

+ 12 - 11
netbox/core/forms/filtersets.py

@@ -9,7 +9,8 @@ from netbox.forms.mixins import SavedFiltersMixin
 from netbox.utils import get_data_backend_choices
 from netbox.utils import get_data_backend_choices
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
 from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
 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__ = (
 __all__ = (
     'ConfigRevisionFilterForm',
     'ConfigRevisionFilterForm',
@@ -22,8 +23,8 @@ __all__ = (
 class DataSourceFilterForm(NetBoxModelFilterSetForm):
 class DataSourceFilterForm(NetBoxModelFilterSetForm):
     model = DataSource
     model = DataSource
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'filter_id')),
-        (_('Data Source'), ('type', 'status')),
+        FieldSet('q', 'filter_id'),
+        FieldSet('type', 'status', name=_('Data Source')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -47,8 +48,8 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
 class DataFileFilterForm(NetBoxModelFilterSetForm):
 class DataFileFilterForm(NetBoxModelFilterSetForm):
     model = DataFile
     model = DataFile
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'filter_id')),
-        (_('File'), ('source_id',)),
+        FieldSet('q', 'filter_id'),
+        FieldSet('source_id', name=_('File')),
     )
     )
     source_id = DynamicModelMultipleChoiceField(
     source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
@@ -59,12 +60,12 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
 
 
 class JobFilterForm(SavedFiltersMixin, FilterForm):
 class JobFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     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',
             '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(
     object_type = ContentTypeChoiceField(
         label=_('Object Type'),
         label=_('Object Type'),
@@ -125,5 +126,5 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
 
 
 class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
 class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'filter_id')),
+        FieldSet('q', 'filter_id'),
     )
     )

+ 22 - 15
netbox/core/forms/model_forms.py

@@ -13,6 +13,7 @@ from netbox.registry import registry
 from netbox.utils import get_data_backend_choices
 from netbox.utils import get_data_backend_choices
 from utilities.forms import get_field_value
 from utilities.forms import get_field_value
 from utilities.forms.fields import CommentField
 from utilities.forms.fields import CommentField
+from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import HTMXSelect
 from utilities.forms.widgets import HTMXSelect
 
 
 __all__ = (
 __all__ = (
@@ -49,11 +50,11 @@ class DataSourceForm(NetBoxModelForm):
     @property
     @property
     def fieldsets(self):
     def fieldsets(self):
         fieldsets = [
         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:
         if self.backend_fields:
             fieldsets.append(
             fieldsets.append(
-                (_('Backend Parameters'), self.backend_fields)
+                FieldSet(*self.backend_fields, name=_('Backend Parameters'))
             )
             )
 
 
         return fieldsets
         return fieldsets
@@ -91,8 +92,8 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
     )
     )
 
 
     fieldsets = (
     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:
     class Meta:
@@ -144,18 +145,24 @@ class ConfigRevisionForm(forms.ModelForm, metaclass=ConfigFormMetaclass):
     """
     """
 
 
     fieldsets = (
     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',
             'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
-        )),
-        (_('Config Revision'), ('comment',))
+            name=_('Miscellaneous')
+        ),
+        FieldSet('comment', name=_('Config Revision'))
     )
     )
 
 
     class Meta:
     class Meta:

+ 3 - 2
netbox/core/models/data.py

@@ -1,3 +1,4 @@
+import hashlib
 import logging
 import logging
 import os
 import os
 import yaml
 import yaml
@@ -18,7 +19,6 @@ from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 from netbox.models.features import JobsMixin
 from netbox.models.features import JobsMixin
 from netbox.registry import registry
 from netbox.registry import registry
-from utilities.files import sha256_hash
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from ..choices import *
 from ..choices import *
 from ..exceptions import SyncError
 from ..exceptions import SyncError
@@ -357,7 +357,8 @@ class DataFile(models.Model):
         has changed.
         has changed.
         """
         """
         file_path = os.path.join(source_root, self.path)
         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
         # Update instance file attributes & data
         if is_modified := file_hash != self.hash:
         if is_modified := file_hash != self.hash:

+ 1 - 1
netbox/core/views.py

@@ -25,7 +25,7 @@ from netbox.views import generic
 from netbox.views.generic.base import BaseObjectView
 from netbox.views.generic.base import BaseObjectView
 from netbox.views.generic.mixins import TableMixin
 from netbox.views.generic.mixins import TableMixin
 from utilities.forms import ConfirmationForm
 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 utilities.views import ContentTypePermissionRequiredMixin, register_model_view
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .models import *
 from .models import *

+ 2 - 2
netbox/dcim/api/serializers_/sites.py

@@ -92,7 +92,7 @@ class LocationSerializer(NestedGroupModelSerializer):
     class Meta:
     class Meta:
         model = Location
         model = Location
         fields = [
         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')
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')

+ 3 - 2
netbox/dcim/filtersets.py

@@ -10,12 +10,12 @@ from extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
 from ipam.filtersets import PrimaryIPFilterSet
 from ipam.filtersets import PrimaryIPFilterSet
 from ipam.models import ASN, IPAddress, VRF
 from ipam.models import ASN, IPAddress, VRF
+from netbox.choices import ColorChoices
 from netbox.filtersets import (
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
 )
 )
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from tenancy.models import *
 from tenancy.models import *
-from utilities.choices import ColorChoices
 from utilities.filters import (
 from utilities.filters import (
     ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
     ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
     NumericArrayFilter, TreeNodeMultipleChoiceFilter,
     NumericArrayFilter, TreeNodeMultipleChoiceFilter,
@@ -270,13 +270,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
 
 
     class Meta:
     class Meta:
         model = Location
         model = Location
-        fields = ('id', 'name', 'slug', 'status', 'description')
+        fields = ('id', 'name', 'slug', 'status', 'facility', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
             Q(name__icontains=value) |
             Q(name__icontains=value) |
+            Q(facility__icontains=value) |
             Q(description__icontains=value)
             Q(description__icontains=value)
         )
         )
 
 

+ 58 - 55
netbox/dcim/forms/bulk_edit.py

@@ -13,6 +13,7 @@ from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
 from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
 from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
 from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
 from wireless.models import WirelessLAN, WirelessLANGroup
 from wireless.models import WirelessLAN, WirelessLANGroup
 from wireless.choices import WirelessRoleChoices
 from wireless.choices import WirelessRoleChoices
@@ -75,7 +76,7 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Region
     model = Region
     fieldsets = (
     fieldsets = (
-        (None, ('parent', 'description')),
+        FieldSet('parent', 'description'),
     )
     )
     nullable_fields = ('parent', 'description')
     nullable_fields = ('parent', 'description')
 
 
@@ -94,7 +95,7 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = SiteGroup
     model = SiteGroup
     fieldsets = (
     fieldsets = (
-        (None, ('parent', 'description')),
+        FieldSet('parent', 'description'),
     )
     )
     nullable_fields = ('parent', 'description')
     nullable_fields = ('parent', 'description')
 
 
@@ -154,7 +155,7 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Site
     model = Site
     fieldsets = (
     fieldsets = (
-        (None, ('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description')),
+        FieldSet('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description'),
     )
     )
     nullable_fields = (
     nullable_fields = (
         'region', 'group', 'tenant', 'asns', 'time_zone', 'description', 'comments',
         'region', 'group', 'tenant', 'asns', 'time_zone', 'description', 'comments',
@@ -194,7 +195,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Location
     model = Location
     fieldsets = (
     fieldsets = (
-        (None, ('site', 'parent', 'status', 'tenant', 'description')),
+        FieldSet('site', 'parent', 'status', 'tenant', 'description'),
     )
     )
     nullable_fields = ('parent', 'tenant', 'description')
     nullable_fields = ('parent', 'tenant', 'description')
 
 
@@ -212,7 +213,7 @@ class RackRoleBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = RackRole
     model = RackRole
     fieldsets = (
     fieldsets = (
-        (None, ('color', 'description')),
+        FieldSet('color', 'description'),
     )
     )
     nullable_fields = ('color', 'description')
     nullable_fields = ('color', 'description')
 
 
@@ -341,12 +342,13 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Rack
     model = Rack
     fieldsets = (
     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',
             '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 = (
     nullable_fields = (
         'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
         'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
@@ -376,7 +378,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = RackReservation
     model = RackReservation
     fieldsets = (
     fieldsets = (
-        (None, ('user', 'tenant', 'description')),
+        FieldSet('user', 'tenant', 'description'),
     )
     )
     nullable_fields = ('comments',)
     nullable_fields = ('comments',)
 
 
@@ -390,7 +392,7 @@ class ManufacturerBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Manufacturer
     model = Manufacturer
     fieldsets = (
     fieldsets = (
-        (None, ('description',)),
+        FieldSet('description'),
     )
     )
     nullable_fields = ('description',)
     nullable_fields = ('description',)
 
 
@@ -450,11 +452,11 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = DeviceType
     model = DeviceType
     fieldsets = (
     fieldsets = (
-        (_('Device Type'), (
+        FieldSet(
             'manufacturer', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
             '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')
     nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
 
 
@@ -489,8 +491,8 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = ModuleType
     model = ModuleType
     fieldsets = (
     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')
     nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments')
 
 
@@ -518,7 +520,7 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = DeviceRole
     model = DeviceRole
     fieldsets = (
     fieldsets = (
-        (None, ('color', 'vm_role', 'config_template', 'description')),
+        FieldSet('color', 'vm_role', 'config_template', 'description'),
     )
     )
     nullable_fields = ('color', 'config_template', 'description')
     nullable_fields = ('color', 'config_template', 'description')
 
 
@@ -542,7 +544,7 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Platform
     model = Platform
     fieldsets = (
     fieldsets = (
-        (None, ('manufacturer', 'config_template', 'description')),
+        FieldSet('manufacturer', 'config_template', 'description'),
     )
     )
     nullable_fields = ('manufacturer', 'config_template', 'description')
     nullable_fields = ('manufacturer', 'config_template', 'description')
 
 
@@ -621,10 +623,10 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Device
     model = Device
     fieldsets = (
     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 = (
     nullable_fields = (
         'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments',
         'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments',
@@ -668,7 +670,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Module
     model = Module
     fieldsets = (
     fieldsets = (
-        (None, ('manufacturer', 'module_type', 'status', 'serial', 'description')),
+        FieldSet('manufacturer', 'module_type', 'status', 'serial', 'description'),
     )
     )
     nullable_fields = ('serial', 'description', 'comments')
     nullable_fields = ('serial', 'description', 'comments')
 
 
@@ -720,8 +722,8 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Cable
     model = Cable
     fieldsets = (
     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 = (
     nullable_fields = (
         'type', 'status', 'tenant', 'label', 'color', 'length', 'description', 'comments',
         'type', 'status', 'tenant', 'label', 'color', 'length', 'description', 'comments',
@@ -743,7 +745,7 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = VirtualChassis
     model = VirtualChassis
     fieldsets = (
     fieldsets = (
-        (None, ('domain', 'description')),
+        FieldSet('domain', 'description'),
     )
     )
     nullable_fields = ('domain', 'description', 'comments')
     nullable_fields = ('domain', 'description', 'comments')
 
 
@@ -791,7 +793,7 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = PowerPanel
     model = PowerPanel
     fieldsets = (
     fieldsets = (
-        (None, ('region', 'site_group', 'site', 'location', 'description')),
+        FieldSet('region', 'site_group', 'site', 'location', 'description'),
     )
     )
     nullable_fields = ('location', 'description', 'comments')
     nullable_fields = ('location', 'description', 'comments')
 
 
@@ -861,8 +863,8 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = PowerFeed
     model = PowerFeed
     fieldsets = (
     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')
     nullable_fields = ('location', 'tenant', 'description', 'comments')
 
 
@@ -1210,7 +1212,7 @@ class ConsolePortBulkEditForm(
 
 
     model = ConsolePort
     model = ConsolePort
     fieldsets = (
     fieldsets = (
-        (None, ('module', 'type', 'label', 'speed', 'description', 'mark_connected')),
+        FieldSet('module', 'type', 'label', 'speed', 'description', 'mark_connected'),
     )
     )
     nullable_fields = ('module', 'label', 'description')
     nullable_fields = ('module', 'label', 'description')
 
 
@@ -1227,7 +1229,7 @@ class ConsoleServerPortBulkEditForm(
 
 
     model = ConsoleServerPort
     model = ConsoleServerPort
     fieldsets = (
     fieldsets = (
-        (None, ('module', 'type', 'label', 'speed', 'description', 'mark_connected')),
+        FieldSet('module', 'type', 'label', 'speed', 'description', 'mark_connected'),
     )
     )
     nullable_fields = ('module', 'label', 'description')
     nullable_fields = ('module', 'label', 'description')
 
 
@@ -1244,8 +1246,8 @@ class PowerPortBulkEditForm(
 
 
     model = PowerPort
     model = PowerPort
     fieldsets = (
     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')
     nullable_fields = ('module', 'label', 'description', 'maximum_draw', 'allocated_draw')
 
 
@@ -1262,8 +1264,8 @@ class PowerOutletBulkEditForm(
 
 
     model = PowerOutlet
     model = PowerOutlet
     fieldsets = (
     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')
     nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description')
 
 
@@ -1395,20 +1397,21 @@ class InterfaceBulkEditForm(
 
 
     model = Interface
     model = Interface
     fieldsets = (
     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',
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
-        )),
+            name=_('Wireless')
+        ),
     )
     )
     nullable_fields = (
     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):
     def __init__(self, *args, **kwargs):
@@ -1488,7 +1491,7 @@ class FrontPortBulkEditForm(
 
 
     model = FrontPort
     model = FrontPort
     fieldsets = (
     fieldsets = (
-        (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
+        FieldSet('module', 'type', 'label', 'color', 'description', 'mark_connected'),
     )
     )
     nullable_fields = ('module', 'label', 'description', 'color')
     nullable_fields = ('module', 'label', 'description', 'color')
 
 
@@ -1505,7 +1508,7 @@ class RearPortBulkEditForm(
 
 
     model = RearPort
     model = RearPort
     fieldsets = (
     fieldsets = (
-        (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
+        FieldSet('module', 'type', 'label', 'color', 'description', 'mark_connected'),
     )
     )
     nullable_fields = ('module', 'label', 'description', 'color')
     nullable_fields = ('module', 'label', 'description', 'color')
 
 
@@ -1516,7 +1519,7 @@ class ModuleBayBulkEditForm(
 ):
 ):
     model = ModuleBay
     model = ModuleBay
     fieldsets = (
     fieldsets = (
-        (None, ('label', 'position', 'description')),
+        FieldSet('label', 'position', 'description'),
     )
     )
     nullable_fields = ('label', 'position', 'description')
     nullable_fields = ('label', 'position', 'description')
 
 
@@ -1527,7 +1530,7 @@ class DeviceBayBulkEditForm(
 ):
 ):
     model = DeviceBay
     model = DeviceBay
     fieldsets = (
     fieldsets = (
-        (None, ('label', 'description')),
+        FieldSet('label', 'description'),
     )
     )
     nullable_fields = ('label', 'description')
     nullable_fields = ('label', 'description')
 
 
@@ -1554,7 +1557,7 @@ class InventoryItemBulkEditForm(
 
 
     model = InventoryItem
     model = InventoryItem
     fieldsets = (
     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')
     nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description')
 
 
@@ -1576,7 +1579,7 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = InventoryItemRole
     model = InventoryItemRole
     fieldsets = (
     fieldsets = (
-        (None, ('color', 'description')),
+        FieldSet('color', 'description'),
     )
     )
     nullable_fields = ('color', 'description')
     nullable_fields = ('color', 'description')
 
 
@@ -1599,6 +1602,6 @@ class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm):
     )
     )
     model = VirtualDeviceContext
     model = VirtualDeviceContext
     fieldsets = (
     fieldsets = (
-        (None, ('device', 'status', 'tenant')),
+        FieldSet('device', 'status', 'tenant'),
     )
     )
     nullable_fields = ('device', 'tenant', )
     nullable_fields = ('device', 'tenant', )

+ 1 - 1
netbox/dcim/forms/bulk_import.py

@@ -157,7 +157,7 @@ class LocationImportForm(NetBoxModelImportForm):
 
 
     class Meta:
     class Meta:
         model = Location
         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):
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
         super().__init__(data, *args, **kwargs)

+ 133 - 125
netbox/dcim/forms/filtersets.py

@@ -12,7 +12,8 @@ from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
 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 vpn.models import L2VPN
 from wireless.choices import *
 from wireless.choices import *
 
 
@@ -132,8 +133,8 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
 class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Region
     model = Region
     fieldsets = (
     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(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -146,8 +147,8 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = SiteGroup
     model = SiteGroup
     fieldsets = (
     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(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
@@ -160,10 +161,10 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
 class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Site
     model = Site
     fieldsets = (
     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')
     selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
     status = forms.MultipleChoiceField(
     status = forms.MultipleChoiceField(
@@ -192,10 +193,10 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
 class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
 class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Location
     model = Location
     fieldsets = (
     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(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -241,13 +242,13 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm):
 class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
 class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Rack
     model = Rack
     fieldsets = (
     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')
     selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
@@ -326,13 +327,13 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
 
 
 class RackElevationFilterForm(RackFilterForm):
 class RackElevationFilterForm(RackFilterForm):
     fieldsets = (
     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(
     id = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
@@ -348,10 +349,10 @@ class RackElevationFilterForm(RackFilterForm):
 class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = RackReservation
     model = RackReservation
     fieldsets = (
     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(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -401,8 +402,8 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Manufacturer
     model = Manufacturer
     fieldsets = (
     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)
     tag = TagFilterField(model)
 
 
@@ -410,14 +411,16 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
 class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
     model = DeviceType
     model = DeviceType
     fieldsets = (
     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',
             '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')
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
     manufacturer_id = DynamicModelMultipleChoiceField(
     manufacturer_id = DynamicModelMultipleChoiceField(
@@ -536,13 +539,13 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
 class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
 class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
     model = ModuleType
     model = ModuleType
     fieldsets = (
     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',
             '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')
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
     manufacturer_id = DynamicModelMultipleChoiceField(
     manufacturer_id = DynamicModelMultipleChoiceField(
@@ -642,18 +645,20 @@ class DeviceFilterForm(
 ):
 ):
     model = Device
     model = Device
     fieldsets = (
     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',
             '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',
             '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')
     selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
@@ -817,9 +822,9 @@ class VirtualDeviceContextFilterForm(
 ):
 ):
     model = VirtualDeviceContext
     model = VirtualDeviceContext
     fieldsets = (
     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(
     device = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -844,8 +849,8 @@ class VirtualDeviceContextFilterForm(
 class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
 class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Module
     model = Module
     fieldsets = (
     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(
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -879,9 +884,9 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
 class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VirtualChassis
     model = VirtualChassis
     fieldsets = (
     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(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -908,10 +913,10 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Cable
     model = Cable
     fieldsets = (
     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(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -992,9 +997,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = PowerPanel
     model = PowerPanel
     fieldsets = (
     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')
     selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
@@ -1031,10 +1036,10 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = PowerFeed
     model = PowerFeed
     fieldsets = (
     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(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -1141,11 +1146,11 @@ class PathEndpointFilterForm(CabledFilterForm):
 class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = ConsolePort
     model = ConsolePort
     fieldsets = (
     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(
     type = forms.MultipleChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -1163,11 +1168,11 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = ConsoleServerPort
     model = ConsoleServerPort
     fieldsets = (
     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(
     type = forms.MultipleChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -1185,11 +1190,11 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
 class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = PowerPort
     model = PowerPort
     fieldsets = (
     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(
     type = forms.MultipleChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -1202,11 +1207,11 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = PowerOutlet
     model = PowerOutlet
     fieldsets = (
     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(
     type = forms.MultipleChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -1219,14 +1224,14 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = Interface
     model = Interface
     fieldsets = (
     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')
     selector_fields = ('filter_id', 'q', 'device_id')
     vdc_id = DynamicModelMultipleChoiceField(
     vdc_id = DynamicModelMultipleChoiceField(
@@ -1330,11 +1335,11 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 
 
 class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
 class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     fieldsets = (
     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
     model = FrontPort
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1352,11 +1357,11 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
 class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
 class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     model = RearPort
     model = RearPort
     fieldsets = (
     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(
     type = forms.MultipleChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -1373,10 +1378,10 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
 class ModuleBayFilterForm(DeviceComponentFilterForm):
 class ModuleBayFilterForm(DeviceComponentFilterForm):
     model = ModuleBay
     model = ModuleBay
     fieldsets = (
     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)
     tag = TagFilterField(model)
     position = forms.CharField(
     position = forms.CharField(
@@ -1388,10 +1393,10 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
 class DeviceBayFilterForm(DeviceComponentFilterForm):
 class DeviceBayFilterForm(DeviceComponentFilterForm):
     model = DeviceBay
     model = DeviceBay
     fieldsets = (
     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)
     tag = TagFilterField(model)
 
 
@@ -1399,10 +1404,13 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
 class InventoryItemFilterForm(DeviceComponentFilterForm):
 class InventoryItemFilterForm(DeviceComponentFilterForm):
     model = InventoryItem
     model = InventoryItem
     fieldsets = (
     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(
     role_id = DynamicModelMultipleChoiceField(
         queryset=InventoryItemRole.objects.all(),
         queryset=InventoryItemRole.objects.all(),

+ 110 - 92
netbox/dcim/forms/model_forms.py

@@ -16,6 +16,7 @@ from utilities.forms.fields import (
     CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
     CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
     NumericArrayField, SlugField,
     NumericArrayField, SlugField,
 )
 )
+from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
 from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
 from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
 from virtualization.models import Cluster
 from virtualization.models import Cluster
 from wireless.models import WirelessLAN, WirelessLANGroup
 from wireless.models import WirelessLAN, WirelessLANGroup
@@ -77,9 +78,7 @@ class RegionForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('Region'), (
-            'parent', 'name', 'slug', 'description', 'tags',
-        )),
+        FieldSet('parent', 'name', 'slug', 'description', 'tags'),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -98,9 +97,7 @@ class SiteGroupForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('Site Group'), (
-            'parent', 'name', 'slug', 'description', 'tags',
-        )),
+        FieldSet('parent', 'name', 'slug', 'description', 'tags'),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -135,11 +132,12 @@ class SiteForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('Site'), (
+        FieldSet(
             'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags',
             '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:
     class Meta:
@@ -179,14 +177,14 @@ class LocationForm(TenancyForm, NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     fieldsets = (
     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:
     class Meta:
         model = Location
         model = Location
         fields = (
         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()
     slug = SlugField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('Rack Role'), (
-            'name', 'slug', 'color', 'description', 'tags',
-        )),
+        FieldSet('name', 'slug', 'color', 'description', 'tags', name=_('Rack Role')),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -227,6 +223,18 @@ class RackForm(TenancyForm, NetBoxModelForm):
     )
     )
     comments = CommentField()
     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:
     class Meta:
         model = Rack
         model = Rack
         fields = [
         fields = [
@@ -256,8 +264,8 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -271,9 +279,7 @@ class ManufacturerForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('Manufacturer'), (
-            'name', 'slug', 'description', 'tags',
-        )),
+        FieldSet('name', 'slug', 'description', 'tags', name=_('Manufacturer')),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -304,12 +310,12 @@ class DeviceTypeForm(NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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',
             '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:
     class Meta:
@@ -337,8 +343,8 @@ class ModuleTypeForm(NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -357,9 +363,9 @@ class DeviceRoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -386,7 +392,7 @@ class PlatformForm(NetBoxModelForm):
     )
     )
 
 
     fieldsets = (
     fieldsets = (
-        (_('Platform'), ('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags')),
+        FieldSet('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', name=_('Platform')),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -601,10 +607,8 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
     )
     )
 
 
     fieldsets = (
     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:
     class Meta:
@@ -658,7 +662,7 @@ class PowerPanelForm(NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     fieldsets = (
-        ('Power Panel', ('site', 'location', 'name', 'description', 'tags')),
+        FieldSet('site', 'location', 'name', 'description', 'tags', name=_('Power Panel')),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -683,9 +687,12 @@ class PowerFeedForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -832,7 +839,7 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
 
 
 class ConsolePortTemplateForm(ModularComponentTemplateForm):
 class ConsolePortTemplateForm(ModularComponentTemplateForm):
     fieldsets = (
     fieldsets = (
-        (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')),
+        FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -844,7 +851,7 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm):
 
 
 class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
 class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
     fieldsets = (
     fieldsets = (
-        (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')),
+        FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -856,9 +863,9 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
 
 
 class PowerPortTemplateForm(ModularComponentTemplateForm):
 class PowerPortTemplateForm(ModularComponentTemplateForm):
     fieldsets = (
     fieldsets = (
-        (None, (
+        FieldSet(
             'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
             'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
-        )),
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -879,7 +886,7 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
     )
     )
 
 
     fieldsets = (
     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:
     class Meta:
@@ -901,9 +908,11 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
     )
     )
 
 
     fieldsets = (
     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:
     class Meta:
@@ -925,10 +934,10 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
     )
     )
 
 
     fieldsets = (
     fieldsets = (
-        (None, (
+        FieldSet(
             'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
             'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
             'description',
             'description',
-        )),
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -941,7 +950,7 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
 
 
 class RearPortTemplateForm(ModularComponentTemplateForm):
 class RearPortTemplateForm(ModularComponentTemplateForm):
     fieldsets = (
     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:
     class Meta:
@@ -953,7 +962,7 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
 
 
 class ModuleBayTemplateForm(ComponentTemplateForm):
 class ModuleBayTemplateForm(ComponentTemplateForm):
     fieldsets = (
     fieldsets = (
-        (None, ('device_type', 'name', 'label', 'position', 'description')),
+        FieldSet('device_type', 'name', 'label', 'position', 'description'),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -965,7 +974,7 @@ class ModuleBayTemplateForm(ComponentTemplateForm):
 
 
 class DeviceBayTemplateForm(ComponentTemplateForm):
 class DeviceBayTemplateForm(ComponentTemplateForm):
     fieldsets = (
     fieldsets = (
-        (None, ('device_type', 'name', 'label', 'description')),
+        FieldSet('device_type', 'name', 'label', 'description'),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1006,10 +1015,10 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
     )
     )
 
 
     fieldsets = (
     fieldsets = (
-        (None, (
+        FieldSet(
             'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
             'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
             'component_type', 'component_id',
             'component_type', 'component_id',
-        )),
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1052,9 +1061,9 @@ class ModularDeviceComponentForm(DeviceComponentForm):
 
 
 class ConsolePortForm(ModularDeviceComponentForm):
 class ConsolePortForm(ModularDeviceComponentForm):
     fieldsets = (
     fieldsets = (
-        (None, (
+        FieldSet(
             'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
             'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
-        )),
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1065,11 +1074,10 @@ class ConsolePortForm(ModularDeviceComponentForm):
 
 
 
 
 class ConsoleServerPortForm(ModularDeviceComponentForm):
 class ConsoleServerPortForm(ModularDeviceComponentForm):
-
     fieldsets = (
     fieldsets = (
-        (None, (
+        FieldSet(
             'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
             'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
-        )),
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1080,12 +1088,11 @@ class ConsoleServerPortForm(ModularDeviceComponentForm):
 
 
 
 
 class PowerPortForm(ModularDeviceComponentForm):
 class PowerPortForm(ModularDeviceComponentForm):
-
     fieldsets = (
     fieldsets = (
-        (None, (
+        FieldSet(
             'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
             'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
             'description', 'tags',
             'description', 'tags',
-        )),
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1107,10 +1114,10 @@ class PowerOutletForm(ModularDeviceComponentForm):
     )
     )
 
 
     fieldsets = (
     fieldsets = (
-        (None, (
+        FieldSet(
             'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
             'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
             'tags',
             'tags',
-        )),
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1206,15 +1213,18 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     )
     )
 
 
     fieldsets = (
     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',
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
-        )),
+            name=_('Wireless')
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1245,10 +1255,10 @@ class FrontPortForm(ModularDeviceComponentForm):
     )
     )
 
 
     fieldsets = (
     fieldsets = (
-        (None, (
+        FieldSet(
             'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
             'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
             'description', 'tags',
             'description', 'tags',
-        )),
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1261,9 +1271,9 @@ class FrontPortForm(ModularDeviceComponentForm):
 
 
 class RearPortForm(ModularDeviceComponentForm):
 class RearPortForm(ModularDeviceComponentForm):
     fieldsets = (
     fieldsets = (
-        (None, (
+        FieldSet(
             'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
             'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
-        )),
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1275,7 +1285,7 @@ class RearPortForm(ModularDeviceComponentForm):
 
 
 class ModuleBayForm(DeviceComponentForm):
 class ModuleBayForm(DeviceComponentForm):
     fieldsets = (
     fieldsets = (
-        (None, ('device', 'name', 'label', 'position', 'description', 'tags',)),
+        FieldSet('device', 'name', 'label', 'position', 'description', 'tags',),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1287,7 +1297,7 @@ class ModuleBayForm(DeviceComponentForm):
 
 
 class DeviceBayForm(DeviceComponentForm):
 class DeviceBayForm(DeviceComponentForm):
     fieldsets = (
     fieldsets = (
-        (None, ('device', 'name', 'label', 'description', 'tags',)),
+        FieldSet('device', 'name', 'label', 'description', 'tags',),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1395,8 +1405,20 @@ class InventoryItemForm(DeviceComponentForm):
     )
     )
 
 
     fieldsets = (
     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:
     class Meta:
@@ -1412,22 +1434,17 @@ class InventoryItemForm(DeviceComponentForm):
         component_type = initial.get('component_type')
         component_type = initial.get('component_type')
         component_id = initial.get('component_id')
         component_id = initial.get('component_id')
 
 
-        # Used for picking the default active tab for component selection
-        self.no_component = True
-
         if instance:
         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):
             for component_model in ContentType.objects.filter(MODULAR_COMPONENT_MODELS):
                 if type(instance.component) is component_model.model_class():
                 if type(instance.component) is component_model.model_class():
                     initial[component_model.model] = instance.component
                     initial[component_model.model] = instance.component
-                    self.no_component = False
                     break
                     break
         elif component_type and component_id:
         elif component_type and component_id:
             # When adding the InventoryItem from a component page
             # When adding the InventoryItem from a component page
             if content_type := ContentType.objects.filter(MODULAR_COMPONENT_MODELS).filter(pk=component_type).first():
             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():
                 if component := content_type.model_class().objects.filter(pk=component_id).first():
                     initial[content_type.model] = component
                     initial[content_type.model] = component
-                    self.no_component = False
 
 
         kwargs['initial'] = initial
         kwargs['initial'] = initial
 
 
@@ -1461,9 +1478,7 @@ class InventoryItemRoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('Inventory Item Role'), (
-            'name', 'slug', 'color', 'description', 'tags',
-        )),
+        FieldSet('name', 'slug', 'color', 'description', 'tags', name=_('Inventory Item Role')),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1499,8 +1514,11 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
     )
     )
 
 
     fieldsets = (
     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:
     class Meta:

+ 4 - 3
netbox/dcim/forms/object_create.py

@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
 from dcim.models import *
 from dcim.models import *
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
+from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import APISelect
 from utilities.forms.widgets import APISelect
 from . import model_forms
 from . import model_forms
 
 
@@ -113,7 +114,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
 
 
     # Override fieldsets from FrontPortTemplateForm to omit rear_port_position
     # Override fieldsets from FrontPortTemplateForm to omit rear_port_position
     fieldsets = (
     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):
     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
     # Override fieldsets from FrontPortForm to omit rear_port_position
     fieldsets = (
     fieldsets = (
-        (None, (
+        FieldSet(
             'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags',
             'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags',
-        )),
+        ),
     )
     )
 
 
     class Meta(model_forms.FrontPortForm.Meta):
     class Meta(model_forms.FrontPortForm.Meta):

+ 18 - 0
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),
+        ),
+    ]

+ 1 - 1
netbox/dcim/models/cables.py

@@ -15,9 +15,9 @@ from dcim.constants import *
 from dcim.fields import PathField
 from dcim.fields import PathField
 from dcim.utils import decompile_path_node, object_to_path_node
 from dcim.utils import decompile_path_node, object_to_path_node
 from netbox.models import ChangeLoggedModel, PrimaryModel
 from netbox.models import ChangeLoggedModel, PrimaryModel
+from utilities.conversion import to_meters
 from utilities.fields import ColorField
 from utilities.fields import ColorField
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
-from utilities.utils import to_meters
 from wireless.models import WirelessLink
 from wireless.models import WirelessLink
 from .device_components import FrontPort, RearPort, PathEndpoint
 from .device_components import FrontPort, RearPort, PathEndpoint
 
 

+ 1 - 1
netbox/dcim/models/device_components.py

@@ -12,8 +12,8 @@ from mptt.models import MPTTModel, TreeForeignKey
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import MACAddressField, WWNField
 from dcim.fields import MACAddressField, WWNField
+from netbox.choices import ColorChoices
 from netbox.models import OrganizationalModel, NetBoxModel
 from netbox.models import OrganizationalModel, NetBoxModel
-from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.mptt import TreeManager
 from utilities.mptt import TreeManager
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface

+ 1 - 1
netbox/dcim/models/devices.py

@@ -18,10 +18,10 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from extras.models import ConfigContextModel, CustomField
 from extras.models import ConfigContextModel, CustomField
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
+from netbox.choices import ColorChoices
 from netbox.config import ConfigItem
 from netbox.config import ConfigItem
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
-from utilities.choices import ColorChoices
 from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
 from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
 from utilities.tracking import TrackingModelMixin
 from utilities.tracking import TrackingModelMixin
 from .device_components import *
 from .device_components import *

+ 1 - 1
netbox/dcim/models/mixins.py

@@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from dcim.choices import *
 from dcim.choices import *
-from utilities.utils import to_grams
+from utilities.conversion import to_grams
 
 
 __all__ = (
 __all__ = (
     'RenderConfigMixin',
     'RenderConfigMixin',

+ 3 - 2
netbox/dcim/models/racks.py

@@ -14,11 +14,12 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.svg import RackElevationSVG
 from dcim.svg import RackElevationSVG
+from netbox.choices import ColorChoices
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 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.fields import ColorField, NaturalOrderingField
-from utilities.utils import array_to_string, drange, to_grams
 from .device_components import PowerPort
 from .device_components import PowerPort
 from .devices import Device, Module
 from .devices import Device, Module
 from .mixins import WeightMixin
 from .mixins import WeightMixin

+ 7 - 1
netbox/dcim/models/sites.py

@@ -275,6 +275,12 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    facility = models.CharField(
+        verbose_name=_('facility'),
+        max_length=50,
+        blank=True,
+        help_text=_('Local facility ID or description')
+    )
 
 
     # Generic relations
     # Generic relations
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
@@ -284,7 +290,7 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
         related_query_name='location'
         related_query_name='location'
     )
     )
 
 
-    clone_fields = ('site', 'parent', 'status', 'tenant', 'description')
+    clone_fields = ('site', 'parent', 'status', 'tenant', 'facility', 'description')
     prerequisite_models = (
     prerequisite_models = (
         'dcim.Site',
         'dcim.Site',
     )
     )

+ 2 - 1
netbox/dcim/search.py

@@ -132,10 +132,11 @@ class LocationIndex(SearchIndex):
     model = models.Location
     model = models.Location
     fields = (
     fields = (
         ('name', 100),
         ('name', 100),
+        ('facility', 100),
         ('slug', 110),
         ('slug', 110),
         ('description', 500),
         ('description', 500),
     )
     )
-    display_attrs = ('site', 'status', 'tenant', 'description')
+    display_attrs = ('site', 'status', 'tenant', 'facility', 'description')
 
 
 
 
 @register_search
 @register_search

+ 1 - 1
netbox/dcim/svg/cables.py

@@ -6,7 +6,7 @@ from svgwrite.text import Text
 from django.conf import settings
 from django.conf import settings
 
 
 from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
-from utilities.utils import foreground_color
+from utilities.html import foreground_color
 
 
 
 
 __all__ = (
 __all__ = (

+ 2 - 1
netbox/dcim/svg/racks.py

@@ -14,7 +14,8 @@ from django.urls import reverse
 from django.utils.http import urlencode
 from django.utils.http import urlencode
 
 
 from netbox.config import get_config
 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
 from dcim.constants import RACK_ELEVATION_BORDER_WIDTH
 
 
 
 

+ 5 - 3
netbox/dcim/tables/sites.py

@@ -152,7 +152,9 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Location
         model = Location
         fields = (
         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')

+ 8 - 5
netbox/dcim/tests/test_filtersets.py

@@ -6,13 +6,12 @@ from dcim.choices import *
 from dcim.filtersets import *
 from dcim.filtersets import *
 from dcim.models import *
 from dcim.models import *
 from ipam.models import ASN, IPAddress, RIR, VRF
 from ipam.models import ASN, IPAddress, RIR, VRF
+from netbox.choices import ColorChoices
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from utilities.choices import ColorChoices
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
 from virtualization.models import Cluster, ClusterType
 from virtualization.models import Cluster, ClusterType
 from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
 from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
 
 
-
 User = get_user_model()
 User = get_user_model()
 
 
 
 
@@ -359,9 +358,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
             location.save()
             location.save()
 
 
         locations = (
         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:
         for location in locations:
             location.save()
             location.save()
@@ -390,6 +389,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]}
         params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_description(self):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 1 - 1
netbox/dcim/tests/test_models.py

@@ -7,7 +7,7 @@ from dcim.choices import *
 from dcim.models import *
 from dcim.models import *
 from extras.models import CustomField
 from extras.models import CustomField
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.utils import drange
+from utilities.data import drange
 
 
 
 
 class LocationTestCase(TestCase):
 class LocationTestCase(TestCase):

+ 2 - 2
netbox/dcim/tests/test_views.py

@@ -11,12 +11,11 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
 from ipam.models import ASN, RIR, VLAN, VRF
 from ipam.models import ASN, RIR, VLAN, VRF
+from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
 from wireless.models import WirelessLAN
 from wireless.models import WirelessLAN
 
 
-
 User = get_user_model()
 User = get_user_model()
 
 
 
 
@@ -213,6 +212,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             'slug': 'location-x',
             'slug': 'location-x',
             'site': site.pk,
             'site': site.pk,
             'status': LocationStatusChoices.STATUS_PLANNED,
             'status': LocationStatusChoices.STATUS_PLANNED,
+            'facility': 'Facility X',
             'tenant': tenant.pk,
             'tenant': tenant.pk,
             'description': 'A new location',
             'description': 'A new location',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],

+ 1 - 4
netbox/dcim/views.py

@@ -25,8 +25,8 @@ from tenancy.views import ObjectContactsView
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
+from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
-from utilities.utils import count_related
 from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
 from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
@@ -727,7 +727,6 @@ class RackNonRackedView(generic.ObjectChildrenView):
 class RackEditView(generic.ObjectEditView):
 class RackEditView(generic.ObjectEditView):
     queryset = Rack.objects.all()
     queryset = Rack.objects.all()
     form = forms.RackForm
     form = forms.RackForm
-    template_name = 'dcim/rack_edit.html'
 
 
 
 
 @register_model_view(Rack, 'delete')
 @register_model_view(Rack, 'delete')
@@ -2925,14 +2924,12 @@ class InventoryItemView(generic.ObjectView):
 class InventoryItemEditView(generic.ObjectEditView):
 class InventoryItemEditView(generic.ObjectEditView):
     queryset = InventoryItem.objects.all()
     queryset = InventoryItem.objects.all()
     form = forms.InventoryItemForm
     form = forms.InventoryItemForm
-    template_name = 'dcim/inventoryitem_edit.html'
 
 
 
 
 class InventoryItemCreateView(generic.ComponentCreateView):
 class InventoryItemCreateView(generic.ComponentCreateView):
     queryset = InventoryItem.objects.all()
     queryset = InventoryItem.objects.all()
     form = forms.InventoryItemCreateForm
     form = forms.InventoryItemCreateForm
     model_form = forms.InventoryItemForm
     model_form = forms.InventoryItemForm
-    template_name = 'dcim/inventoryitem_edit.html'
 
 
 
 
 @register_model_view(InventoryItem, 'delete')
 @register_model_view(InventoryItem, 'delete')

+ 1 - 1
netbox/extras/api/views.py

@@ -20,7 +20,7 @@ from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.renderers import TextRenderer
 from netbox.api.renderers import TextRenderer
 from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.api.viewsets import NetBoxModelViewSet
 from utilities.exceptions import RQWorkerNotRunningException
 from utilities.exceptions import RQWorkerNotRunningException
-from utilities.utils import copy_safe_request
+from utilities.request import copy_safe_request
 from . import serializers
 from . import serializers
 from .mixins import ConfigTemplateRenderMixin
 from .mixins import ConfigTemplateRenderMixin
 
 

+ 2 - 1
netbox/extras/choices.py

@@ -2,7 +2,8 @@ import logging
 
 
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from utilities.choices import ButtonColorChoices, ChoiceSet
+from netbox.choices import ButtonColorChoices
+from utilities.choices import ChoiceSet
 
 
 
 
 #
 #

+ 8 - 6
netbox/extras/dashboard/widgets.py

@@ -14,10 +14,12 @@ from django.utils.translation import gettext as _
 
 
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.choices import BookmarkOrderingChoices
 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.permissions import get_permission_for_model
+from utilities.querydict import dict_to_querydict
 from utilities.templatetags.builtins.filters import render_markdown
 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
 from .utils import register_widget
 
 
 __all__ = (
 __all__ = (
@@ -33,15 +35,15 @@ __all__ = (
 
 
 def get_object_type_choices():
 def get_object_type_choices():
     return [
     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():
 def get_bookmarks_object_type_choices():
     return [
     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')
     ]
     ]
 
 
 
 

+ 2 - 5
netbox/extras/events.py

@@ -1,9 +1,6 @@
-import logging
-
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
@@ -15,9 +12,9 @@ from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.registry import registry
 from netbox.registry import registry
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from utilities.rqworker import get_rq_retry
 from utilities.rqworker import get_rq_retry
-from utilities.utils import serialize_object
+from utilities.serialization import serialize_object
 from .choices import *
 from .choices import *
-from .models import EventRule, ScriptModule
+from .models import EventRule
 
 
 logger = logging.getLogger('netbox.events_processor')
 logger = logging.getLogger('netbox.events_processor')
 
 

+ 35 - 34
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 (
 from utilities.forms.fields import (
     ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
     ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
 )
 )
+from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import APISelectMultiple, DateTimePicker
 from utilities.forms.widgets import APISelectMultiple, DateTimePicker
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
@@ -36,11 +37,11 @@ __all__ = (
 
 
 class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
 class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     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',
             '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(
     related_object_type_id = ContentTypeMultipleChoiceField(
         queryset=ObjectType.objects.with_feature('custom_fields'),
         queryset=ObjectType.objects.with_feature('custom_fields'),
@@ -93,8 +94,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
 
 
 class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
 class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'filter_id')),
-        (_('Choices'), ('base_choices', 'choice')),
+        FieldSet('q', 'filter_id'),
+        FieldSet('base_choices', 'choice', name=_('Choices')),
     )
     )
     base_choices = forms.MultipleChoiceField(
     base_choices = forms.MultipleChoiceField(
         choices=CustomFieldChoiceSetBaseChoices,
         choices=CustomFieldChoiceSetBaseChoices,
@@ -107,8 +108,8 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
 
 
 class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
 class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     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(
     object_type = ContentTypeMultipleChoiceField(
         label=_('Object types'),
         label=_('Object types'),
@@ -137,9 +138,9 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
 
 
 class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
 class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     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(
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
@@ -178,8 +179,8 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
 
 
 class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
 class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     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(
     object_type_id = ContentTypeChoiceField(
         label=_('Object type'),
         label=_('Object type'),
@@ -194,8 +195,8 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
 
 
 class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
 class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     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(
     object_type = ContentTypeMultipleChoiceField(
         label=_('Object types'),
         label=_('Object types'),
@@ -225,8 +226,8 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
 class WebhookFilterForm(NetBoxModelFilterSetForm):
 class WebhookFilterForm(NetBoxModelFilterSetForm):
     model = Webhook
     model = Webhook
     fieldsets = (
     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(
     http_content_type = forms.CharField(
         label=_('HTTP content type'),
         label=_('HTTP content type'),
@@ -249,9 +250,9 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
     fieldsets = (
     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(
     object_type_id = ContentTypeMultipleChoiceField(
         queryset=ObjectType.objects.with_feature('event_rules'),
         queryset=ObjectType.objects.with_feature('event_rules'),
@@ -323,12 +324,12 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
 
 
 class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
 class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     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(
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
@@ -412,8 +413,8 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
 
 
 class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
 class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     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(
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
@@ -444,9 +445,9 @@ class LocalConfigContextFilterForm(forms.Form):
 class JournalEntryFilterForm(NetBoxModelFilterSetForm):
 class JournalEntryFilterForm(NetBoxModelFilterSetForm):
     model = JournalEntry
     model = JournalEntry
     fieldsets = (
     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(
     created_after = forms.DateTimeField(
         required=False,
         required=False,
@@ -482,9 +483,9 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
 class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
 class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
     model = ObjectChange
     model = ObjectChange
     fieldsets = (
     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(
     time_after = forms.DateTimeField(
         required=False,
         required=False,

+ 42 - 29
netbox/extras/forms/model_forms.py

@@ -17,6 +17,7 @@ from utilities.forms.fields import (
     CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
     CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
     DynamicModelMultipleChoiceField, JSONField, SlugField,
     DynamicModelMultipleChoiceField, JSONField, SlugField,
 )
 )
+from utilities.forms.rendering import FieldSet, ObjectAttribute
 from utilities.forms.widgets import ChoicesWidget, HTMXSelect
 from utilities.forms.widgets import ChoicesWidget, HTMXSelect
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
@@ -54,12 +55,15 @@ class CustomFieldForm(forms.ModelForm):
     )
     )
 
 
     fieldsets = (
     fieldsets = (
-        (_('Custom Field'), (
+        FieldSet(
             'object_types', 'name', 'label', 'group_name', 'type', 'related_object_type', 'required', 'description',
             'object_types', 'name', 'label', 'group_name', 'type', 'related_object_type', 'required', 'description',
-        )),
-        (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')),
-        (_('Values'), ('default', 'choice_set')),
-        (_('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:
     class Meta:
@@ -128,8 +132,11 @@ class CustomLinkForm(forms.ModelForm):
     )
     )
 
 
     fieldsets = (
     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:
     class Meta:
@@ -162,9 +169,9 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm):
     )
     )
 
 
     fieldsets = (
     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:
     class Meta:
@@ -199,8 +206,8 @@ class SavedFilterForm(forms.ModelForm):
     parameters = JSONField()
     parameters = JSONField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -231,11 +238,12 @@ class BookmarkForm(forms.ModelForm):
 class WebhookForm(NetBoxModelForm):
 class WebhookForm(NetBoxModelForm):
 
 
     fieldsets = (
     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',
             '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:
     class Meta:
@@ -266,12 +274,13 @@ class EventRuleForm(NetBoxModelForm):
     )
     )
 
 
     fieldsets = (
     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',
             'action_type', 'action_choice', 'action_object_type', 'action_object_id', 'action_data',
-        )),
+            name=_('Action')
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -360,7 +369,7 @@ class TagForm(forms.ModelForm):
     )
     )
 
 
     fieldsets = (
     fieldsets = (
-        ('Tag', ('name', 'slug', 'color', 'description', 'object_types')),
+        FieldSet('name', 'slug', 'color', 'description', 'object_types', name=_('Tag')),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -442,12 +451,13 @@ class ConfigContextForm(SyncedDataMixin, forms.ModelForm):
     )
     )
 
 
     fieldsets = (
     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',
             'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
             'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
             'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
-        )),
+            name=_('Assignment')
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -494,9 +504,9 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm):
     )
     )
 
 
     fieldsets = (
     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:
     class Meta:
@@ -526,6 +536,9 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm):
 
 
 
 
 class ImageAttachmentForm(forms.ModelForm):
 class ImageAttachmentForm(forms.ModelForm):
+    fieldsets = (
+        FieldSet(ObjectAttribute('parent'), 'name', 'image'),
+    )
 
 
     class Meta:
     class Meta:
         model = ImageAttachment
         model = ImageAttachment

+ 1 - 1
netbox/extras/forms/reports.py

@@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from extras.choices import DurationChoices
 from extras.choices import DurationChoices
 from utilities.forms.widgets import DateTimePicker, NumberWithOptions
 from utilities.forms.widgets import DateTimePicker, NumberWithOptions
-from utilities.utils import local_now
+from utilities.datetime import local_now
 
 
 __all__ = (
 __all__ = (
     'ReportForm',
     'ReportForm',

+ 1 - 1
netbox/extras/forms/scripts.py

@@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from extras.choices import DurationChoices
 from extras.choices import DurationChoices
 from utilities.forms.widgets import DateTimePicker, NumberWithOptions
 from utilities.forms.widgets import DateTimePicker, NumberWithOptions
-from utilities.utils import local_now
+from utilities.datetime import local_now
 
 
 __all__ = (
 __all__ = (
     'ScriptForm',
     'ScriptForm',

+ 1 - 1
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.scripts import get_module_and_script
 from extras.signals import clear_events
 from extras.signals import clear_events
 from utilities.exceptions import AbortTransaction
 from utilities.exceptions import AbortTransaction
-from utilities.utils import NetBoxFakeRequest
+from utilities.request import NetBoxFakeRequest
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):

+ 4 - 4
netbox/extras/models/configs.py

@@ -9,11 +9,11 @@ from jinja2.sandbox import SandboxedEnvironment
 
 
 from extras.querysets import ConfigContextQuerySet
 from extras.querysets import ConfigContextQuerySet
 from netbox.config import get_config
 from netbox.config import get_config
-from netbox.registry import registry
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
 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__ = (
 __all__ = (
     'ConfigContext',
     'ConfigContext',
@@ -290,7 +290,7 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
         """
         """
         # Initialize the template loader & cache the base template code (if applicable)
         # Initialize the template loader & cache the base template code (if applicable)
         if self.data_file:
         if self.data_file:
-            loader = ConfigTemplateLoader(data_source=self.data_source)
+            loader = DataFileLoader(data_source=self.data_source)
             loader.cache_templates({
             loader.cache_templates({
                 self.data_file.path: self.template_code
                 self.data_file.path: self.template_code
             })
             })

+ 3 - 1
netbox/extras/models/models.py

@@ -22,8 +22,10 @@ from netbox.models import ChangeLoggedModel
 from netbox.models.features import (
 from netbox.models.features import (
     CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
     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.querysets import RestrictedQuerySet
-from utilities.utils import clean_html, dict_to_querydict, render_jinja2
+from utilities.jinja2 import render_jinja2
 
 
 __all__ = (
 __all__ = (
     'Bookmark',
     'Bookmark',

+ 0 - 2
netbox/extras/models/search.py

@@ -4,9 +4,7 @@ from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from netbox.search.utils import get_indexer
 from netbox.search.utils import get_indexer
-from netbox.registry import registry
 from utilities.fields import RestrictedGenericForeignKey
 from utilities.fields import RestrictedGenericForeignKey
-from utilities.utils import content_type_identifier
 from ..fields import CachedValueField
 from ..fields import CachedValueField
 
 
 __all__ = (
 __all__ = (

+ 1 - 1
netbox/extras/models/staging.py

@@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
 from extras.choices import ChangeActionChoices
 from extras.choices import ChangeActionChoices
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import *
 from netbox.models.features import *
-from utilities.utils import deserialize_object
+from utilities.serialization import deserialize_object
 
 
 __all__ = (
 __all__ = (
     'Branch',
     'Branch',

+ 1 - 1
netbox/extras/models/tags.py

@@ -5,9 +5,9 @@ from django.utils.text import slugify
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from taggit.models import TagBase, GenericTaggedItemBase
 from taggit.models import TagBase, GenericTaggedItemBase
 
 
+from netbox.choices import ColorChoices
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
-from utilities.choices import ColorChoices
 from utilities.fields import ColorField
 from utilities.fields import ColorField
 
 
 __all__ = (
 __all__ = (

+ 26 - 2
netbox/extras/signals.py

@@ -1,7 +1,8 @@
+import importlib
 import logging
 import logging
 
 
 from django.contrib.contenttypes.models import ContentType
 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.fields.reverse_related import ManyToManyRel
 from django.db.models.signals import m2m_changed, post_save, pre_delete
 from django.db.models.signals import m2m_changed, post_save, pre_delete
 from django.dispatch import receiver, Signal
 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.constants import EVENT_JOB_END, EVENT_JOB_START
 from extras.events import process_event_rules
 from extras.events import process_event_rules
 from extras.models import EventRule
 from extras.models import EventRule
-from extras.validators import run_validators
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.context import current_request, events_queue
 from netbox.context import current_request, events_queue
 from netbox.models.features import ChangeLoggingMixin
 from netbox.models.features import ChangeLoggingMixin
@@ -22,6 +22,30 @@ from utilities.exceptions import AbortRequest
 from .choices import ObjectChangeActionChoices
 from .choices import ObjectChangeActionChoices
 from .events import enqueue_object, get_snapshots, serialize_for_event
 from .events import enqueue_object, get_snapshots, serialize_for_event
 from .models import CustomField, ObjectChange, TaggedItem
 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
 # Change logging/webhooks

+ 1 - 1
netbox/extras/tests/test_custom_validation.py

@@ -5,7 +5,7 @@ from circuits.api.serializers import ProviderSerializer
 from circuits.forms import ProviderForm
 from circuits.forms import ProviderForm
 from circuits.models import Provider
 from circuits.models import Provider
 from ipam.models import ASN, RIR
 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
 from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data
 
 
 
 

+ 1 - 1
netbox/extras/tests/test_customfields.py

@@ -12,7 +12,7 @@ from dcim.models import Manufacturer, Rack, Site
 from extras.choices import *
 from extras.choices import *
 from extras.models import CustomField, CustomFieldChoiceSet
 from extras.models import CustomField, CustomFieldChoiceSet
 from ipam.models import VLAN
 from ipam.models import VLAN
-from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
+from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from utilities.testing import APITestCase, TestCase
 from utilities.testing import APITestCase, TestCase
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 
 

+ 32 - 1
netbox/extras/tests/test_customvalidation.py

@@ -3,11 +3,13 @@ from django.core.exceptions import ValidationError
 from django.db import transaction
 from django.db import transaction
 from django.test import TestCase, override_settings
 from django.test import TestCase, override_settings
 
 
-from ipam.models import ASN, RIR
 from dcim.choices import SiteStatusChoices
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from dcim.models import Site
 from extras.validators import CustomValidator
 from extras.validators import CustomValidator
+from ipam.models import ASN, RIR
+from users.models import User
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
+from utilities.request import NetBoxFakeRequest
 
 
 
 
 class MyValidator(CustomValidator):
 class MyValidator(CustomValidator):
@@ -79,6 +81,13 @@ prohibited_validator = CustomValidator({
     }
     }
 })
 })
 
 
+
+request_validator = CustomValidator({
+    'request.user.username': {
+        'eq': 'Bob'
+    }
+})
+
 custom_validator = MyValidator()
 custom_validator = MyValidator()
 
 
 
 
@@ -154,6 +163,28 @@ class CustomValidatorTest(TestCase):
     def test_custom_valid(self):
     def test_custom_valid(self):
         Site(name='foo', slug='foo').clean()
         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):
 class CustomValidatorConfigTest(TestCase):
 
 

+ 45 - 29
netbox/extras/validators.py

@@ -1,4 +1,5 @@
-import importlib
+import inspect
+import operator
 
 
 from django.core import validators
 from django.core import validators
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
@@ -74,6 +75,8 @@ class CustomValidator:
 
 
     :param validation_rules: A dictionary mapping object attributes to validation rules
     :param validation_rules: A dictionary mapping object attributes to validation rules
     """
     """
+    REQUEST_TOKEN = 'request'
+
     VALIDATORS = {
     VALIDATORS = {
         'eq': IsEqualValidator,
         'eq': IsEqualValidator,
         'neq': IsNotEqualValidator,
         'neq': IsNotEqualValidator,
@@ -88,25 +91,56 @@ class CustomValidator:
 
 
     def __init__(self, validation_rules=None):
     def __init__(self, validation_rules=None):
         self.validation_rules = validation_rules or {}
         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():
             for descriptor, value in rules.items():
                 validator = self.get_validator(descriptor, value)
                 validator = self.get_validator(descriptor, value)
                 try:
                 try:
                     validator(attr)
                     validator(attr)
                 except ValidationError as exc:
                 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)
         # 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
     @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
         # Attempt to resolve many-to-many fields to their stored values
         m2m_fields = [f.name for f in instance._meta.local_many_to_many]
         m2m_fields = [f.name for f in instance._meta.local_many_to_many]
         if name in m2m_fields:
         if name in m2m_fields:
@@ -137,7 +171,7 @@ class CustomValidator:
         validator_cls = self.VALIDATORS.get(descriptor)
         validator_cls = self.VALIDATORS.get(descriptor)
         return validator_cls(value)
         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
         Custom validation method, to be overridden by the user. Validation failures should
         raise a ValidationError exception.
         raise a ValidationError exception.
@@ -151,21 +185,3 @@ class CustomValidator:
         if field is not None:
         if field is not None:
             raise ValidationError({field: message})
             raise ValidationError({field: message})
         raise ValidationError(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)

+ 5 - 3
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.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
 from netbox.views import generic
 from netbox.views.generic.mixins import TableMixin
 from netbox.views.generic.mixins import TableMixin
+from utilities.data import shallow_compare_dict
 from utilities.forms import ConfirmationForm, get_field_value
 from utilities.forms import ConfirmationForm, get_field_value
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 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.rqworker import get_workers_for_queue
 from utilities.templatetags.builtins.filters import render_markdown
 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 . import filtersets, forms, tables
 from .models import *
 from .models import *
 from .scripts import run_script
 from .scripts import run_script
@@ -759,7 +762,6 @@ class ImageAttachmentListView(generic.ObjectListView):
 class ImageAttachmentEditView(generic.ObjectEditView):
 class ImageAttachmentEditView(generic.ObjectEditView):
     queryset = ImageAttachment.objects.all()
     queryset = ImageAttachment.objects.all()
     form = forms.ImageAttachmentForm
     form = forms.ImageAttachmentForm
-    template_name = 'extras/imageattachment_edit.html'
 
 
     def alter_object(self, instance, request, args, kwargs):
     def alter_object(self, instance, request, args, kwargs):
         if not instance.pk:
         if not instance.pk:

+ 23 - 20
netbox/ipam/forms/bulk_edit.py

@@ -13,6 +13,7 @@ from utilities.forms import add_blank_choice
 from utilities.forms.fields import (
 from utilities.forms.fields import (
     CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
     CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
 )
 )
+from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 
 
@@ -55,7 +56,7 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = VRF
     model = VRF
     fieldsets = (
     fieldsets = (
-        (None, ('tenant', 'enforce_unique', 'description')),
+        FieldSet('tenant', 'enforce_unique', 'description'),
     )
     )
     nullable_fields = ('tenant', 'description', 'comments')
     nullable_fields = ('tenant', 'description', 'comments')
 
 
@@ -75,7 +76,7 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = RouteTarget
     model = RouteTarget
     fieldsets = (
     fieldsets = (
-        (None, ('tenant', 'description')),
+        FieldSet('tenant', 'description'),
     )
     )
     nullable_fields = ('tenant', 'description', 'comments')
     nullable_fields = ('tenant', 'description', 'comments')
 
 
@@ -94,7 +95,7 @@ class RIRBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = RIR
     model = RIR
     fieldsets = (
     fieldsets = (
-        (None, ('is_private', 'description')),
+        FieldSet('is_private', 'description'),
     )
     )
     nullable_fields = ('is_private', 'description')
     nullable_fields = ('is_private', 'description')
 
 
@@ -118,7 +119,7 @@ class ASNRangeBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = ASNRange
     model = ASNRange
     fieldsets = (
     fieldsets = (
-        (None, ('rir', 'tenant', 'description')),
+        FieldSet('rir', 'tenant', 'description'),
     )
     )
     nullable_fields = ('description',)
     nullable_fields = ('description',)
 
 
@@ -148,7 +149,7 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = ASN
     model = ASN
     fieldsets = (
     fieldsets = (
-        (None, ('sites', 'rir', 'tenant', 'description')),
+        FieldSet('sites', 'rir', 'tenant', 'description'),
     )
     )
     nullable_fields = ('tenant', 'description', 'comments')
     nullable_fields = ('tenant', 'description', 'comments')
 
 
@@ -177,7 +178,7 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Aggregate
     model = Aggregate
     fieldsets = (
     fieldsets = (
-        (None, ('rir', 'tenant', 'date_added', 'description')),
+        FieldSet('rir', 'tenant', 'date_added', 'description'),
     )
     )
     nullable_fields = ('date_added', 'description', 'comments')
     nullable_fields = ('date_added', 'description', 'comments')
 
 
@@ -195,7 +196,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Role
     model = Role
     fieldsets = (
     fieldsets = (
-        (None, ('weight', 'description')),
+        FieldSet('weight', 'description'),
     )
     )
     nullable_fields = ('description',)
     nullable_fields = ('description',)
 
 
@@ -265,9 +266,9 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Prefix
     model = Prefix
     fieldsets = (
     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 = (
     nullable_fields = (
         'site', 'vrf', 'tenant', 'role', 'description', 'comments',
         'site', 'vrf', 'tenant', 'role', 'description', 'comments',
@@ -309,7 +310,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = IPRange
     model = IPRange
     fieldsets = (
     fieldsets = (
-        (None, ('status', 'role', 'vrf', 'tenant', 'mark_utilized', 'description')),
+        FieldSet('status', 'role', 'vrf', 'tenant', 'mark_utilized', 'description'),
     )
     )
     nullable_fields = (
     nullable_fields = (
         'vrf', 'tenant', 'role', 'description', 'comments',
         'vrf', 'tenant', 'role', 'description', 'comments',
@@ -357,8 +358,8 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = IPAddress
     model = IPAddress
     fieldsets = (
     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 = (
     nullable_fields = (
         'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments',
         'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments',
@@ -400,8 +401,8 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = FHRPGroup
     model = FHRPGroup
     fieldsets = (
     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')
     nullable_fields = ('auth_type', 'auth_key', 'name', 'description', 'comments')
 
 
@@ -485,8 +486,10 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = VLANGroup
     model = VLANGroup
     fieldsets = (
     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',)
     nullable_fields = ('description',)
 
 
@@ -556,8 +559,8 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = VLAN
     model = VLAN
     fieldsets = (
     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 = (
     nullable_fields = (
         'site', 'group', 'tenant', 'role', 'description', 'comments',
         'site', 'group', 'tenant', 'role', 'description', 'comments',
@@ -587,7 +590,7 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = ServiceTemplate
     model = ServiceTemplate
     fieldsets = (
     fieldsets = (
-        (None, ('protocol', 'ports', 'description')),
+        FieldSet('protocol', 'ports', 'description'),
     )
     )
     nullable_fields = ('description', 'comments')
     nullable_fields = ('description', 'comments')
 
 

+ 50 - 43
netbox/ipam/forms/filtersets.py

@@ -9,6 +9,7 @@ from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
+from utilities.forms.rendering import FieldSet
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from vpn.models import L2VPN
 from vpn.models import L2VPN
 
 
@@ -42,9 +43,9 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
 class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VRF
     model = VRF
     fieldsets = (
     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(
     import_target_id = DynamicModelMultipleChoiceField(
         queryset=RouteTarget.objects.all(),
         queryset=RouteTarget.objects.all(),
@@ -62,9 +63,9 @@ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = RouteTarget
     model = RouteTarget
     fieldsets = (
     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(
     importing_vrf_id = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
@@ -94,9 +95,9 @@ class RIRFilterForm(NetBoxModelFilterSetForm):
 class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Aggregate
     model = Aggregate
     fieldsets = (
     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(
     family = forms.ChoiceField(
         required=False,
         required=False,
@@ -114,9 +115,9 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ASNRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ASNRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = ASNRange
     model = ASNRange
     fieldsets = (
     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(
     rir_id = DynamicModelMultipleChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
@@ -137,9 +138,9 @@ class ASNRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = ASN
     model = ASN
     fieldsets = (
     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(
     rir_id = DynamicModelMultipleChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
@@ -162,11 +163,14 @@ class RoleFilterForm(NetBoxModelFilterSetForm):
 class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Prefix
     model = Prefix
     fieldsets = (
     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(
     mask_length__lte = forms.IntegerField(
         widget=forms.HiddenInput()
         widget=forms.HiddenInput()
@@ -251,9 +255,9 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = IPRange
     model = IPRange
     fieldsets = (
     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(
     family = forms.ChoiceField(
         required=False,
         required=False,
@@ -290,11 +294,14 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = IPAddress
     model = IPAddress
     fieldsets = (
     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')
     selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
     parent = forms.CharField(
     parent = forms.CharField(
@@ -364,9 +371,9 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
 class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
     model = FHRPGroup
     model = FHRPGroup
     fieldsets = (
     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(
     name = forms.CharField(
         label=_('Name'),
         label=_('Name'),
@@ -396,9 +403,9 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
 
 
 class VLANGroupFilterForm(NetBoxModelFilterSetForm):
 class VLANGroupFilterForm(NetBoxModelFilterSetForm):
     fieldsets = (
     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
     model = VLANGroup
     region = DynamicModelMultipleChoiceField(
     region = DynamicModelMultipleChoiceField(
@@ -444,10 +451,10 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
 class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VLAN
     model = VLAN
     fieldsets = (
     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')
     selector_fields = ('filter_id', 'q', 'site_id')
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
@@ -504,8 +511,8 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
 class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
     model = ServiceTemplate
     model = ServiceTemplate
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'filter_id', 'tag')),
-        (_('Attributes'), ('protocol', 'port')),
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('protocol', 'port', name=_('Attributes')),
     )
     )
     protocol = forms.ChoiceField(
     protocol = forms.ChoiceField(
         label=_('Protocol'),
         label=_('Protocol'),
@@ -522,9 +529,9 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
 class ServiceFilterForm(ServiceTemplateFilterForm):
 class ServiceFilterForm(ServiceTemplateFilterForm):
     model = Service
     model = Service
     fieldsets = (
     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(
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),

+ 78 - 31
netbox/ipam/forms/model_forms.py

@@ -16,6 +16,7 @@ from utilities.forms.fields import (
     CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
     CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
     SlugField,
     SlugField,
 )
 )
+from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
 from utilities.forms.widgets import DatePicker
 from utilities.forms.widgets import DatePicker
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
 
 
@@ -56,9 +57,9 @@ class VRFForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -74,8 +75,8 @@ class VRFForm(TenancyForm, NetBoxModelForm):
 
 
 class RouteTargetForm(TenancyForm, NetBoxModelForm):
 class RouteTargetForm(TenancyForm, NetBoxModelForm):
     fieldsets = (
     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()
     comments = CommentField()
 
 
@@ -90,9 +91,7 @@ class RIRForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('RIR'), (
-            'name', 'slug', 'is_private', 'description', 'tags',
-        )),
+        FieldSet('name', 'slug', 'is_private', 'description', 'tags', name=_('RIR')),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -110,8 +109,8 @@ class AggregateForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -131,8 +130,8 @@ class ASNRangeForm(TenancyForm, NetBoxModelForm):
     )
     )
     slug = SlugField()
     slug = SlugField()
     fieldsets = (
     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:
     class Meta:
@@ -155,8 +154,8 @@ class ASNForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -184,9 +183,7 @@ class RoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('Role'), (
-            'name', 'slug', 'weight', 'description', 'tags',
-        )),
+        FieldSet('name', 'slug', 'weight', 'description', 'tags', name=_('Role')),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -226,9 +223,11 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -253,8 +252,11 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -307,6 +309,20 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
     )
     )
     comments = CommentField()
     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:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
@@ -443,9 +459,9 @@ class FHRPGroupForm(NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -502,6 +518,10 @@ class FHRPGroupAssignmentForm(forms.ModelForm):
         queryset=FHRPGroup.objects.all()
         queryset=FHRPGroup.objects.all()
     )
     )
 
 
+    fieldsets = (
+        FieldSet(ObjectAttribute('interface'), 'group', 'priority'),
+    )
+
     class Meta:
     class Meta:
         model = FHRPGroupAssignment
         model = FHRPGroupAssignment
         fields = ('group', 'priority')
         fields = ('group', 'priority')
@@ -587,9 +607,12 @@ class VLANGroupForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     fieldsets = (
     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:
     class Meta:
@@ -662,9 +685,7 @@ class ServiceTemplateForm(NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('Service Template'), (
-            'name', 'protocol', 'ports', 'description', 'tags',
-        )),
+        FieldSet('name', 'protocol', 'ports', 'description', 'tags', name=_('Service Template')),
     )
     )
 
 
     class Meta:
     class Meta:
@@ -704,6 +725,18 @@ class ServiceForm(NetBoxModelForm):
     )
     )
     comments = CommentField()
     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:
     class Meta:
         model = Service
         model = Service
         fields = [
         fields = [
@@ -718,6 +751,20 @@ class ServiceCreateForm(ServiceForm):
         required=False
         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):
     class Meta(ServiceForm.Meta):
         fields = [
         fields = [
             'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
             'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',

+ 1 - 2
netbox/ipam/models/services.py

@@ -8,8 +8,7 @@ from django.utils.translation import gettext_lazy as _
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
-from utilities.utils import array_to_string
-
+from utilities.data import array_to_string
 
 
 __all__ = (
 __all__ = (
     'Service',
     'Service',

+ 1 - 1
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.expressions import RawSQL
 from django.db.models.functions import Round
 from django.db.models.functions import Round
 
 
+from utilities.query import count_related
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
-from utilities.utils import count_related
 
 
 __all__ = (
 __all__ = (
     'ASNRangeQuerySet',
     'ASNRangeQuerySet',

+ 1 - 5
netbox/ipam/views.py

@@ -9,8 +9,8 @@ from circuits.models import Provider
 from dcim.filtersets import InterfaceFilterSet
 from dcim.filtersets import InterfaceFilterSet
 from dcim.models import Interface, Site
 from dcim.models import Interface, Site
 from netbox.views import generic
 from netbox.views import generic
+from utilities.query import count_related
 from utilities.tables import get_table_ordering
 from utilities.tables import get_table_ordering
-from utilities.utils import count_related
 from utilities.views import ViewTab, register_model_view
 from utilities.views import ViewTab, register_model_view
 from virtualization.filtersets import VMInterfaceFilterSet
 from virtualization.filtersets import VMInterfaceFilterSet
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
@@ -781,7 +781,6 @@ class IPAddressView(generic.ObjectView):
 class IPAddressEditView(generic.ObjectEditView):
 class IPAddressEditView(generic.ObjectEditView):
     queryset = IPAddress.objects.all()
     queryset = IPAddress.objects.all()
     form = forms.IPAddressForm
     form = forms.IPAddressForm
-    template_name = 'ipam/ipaddress_edit.html'
 
 
     def alter_object(self, obj, request, url_args, url_kwargs):
     def alter_object(self, obj, request, url_args, url_kwargs):
 
 
@@ -1059,7 +1058,6 @@ class FHRPGroupBulkDeleteView(generic.BulkDeleteView):
 class FHRPGroupAssignmentEditView(generic.ObjectEditView):
 class FHRPGroupAssignmentEditView(generic.ObjectEditView):
     queryset = FHRPGroupAssignment.objects.all()
     queryset = FHRPGroupAssignment.objects.all()
     form = forms.FHRPGroupAssignmentForm
     form = forms.FHRPGroupAssignmentForm
-    template_name = 'ipam/fhrpgroupassignment_edit.html'
 
 
     def alter_object(self, instance, request, args, kwargs):
     def alter_object(self, instance, request, args, kwargs):
         if not instance.pk:
         if not instance.pk:
@@ -1236,14 +1234,12 @@ class ServiceView(generic.ObjectView):
 class ServiceCreateView(generic.ObjectEditView):
 class ServiceCreateView(generic.ObjectEditView):
     queryset = Service.objects.all()
     queryset = Service.objects.all()
     form = forms.ServiceCreateForm
     form = forms.ServiceCreateForm
-    template_name = 'ipam/service_create.html'
 
 
 
 
 @register_model_view(Service, 'edit')
 @register_model_view(Service, 'edit')
 class ServiceEditView(generic.ObjectEditView):
 class ServiceEditView(generic.ObjectEditView):
     queryset = Service.objects.all()
     queryset = Service.objects.all()
     form = forms.ServiceForm
     form = forms.ServiceForm
-    template_name = 'ipam/service_edit.html'
 
 
 
 
 @register_model_view(Service, 'delete')
 @register_model_view(Service, 'delete')

+ 4 - 3
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 drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
+from core.models import ObjectType
 from netbox.api.fields import ContentTypeField
 from netbox.api.fields import ContentTypeField
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
-from utilities.utils import content_type_identifier
+from utilities.object_types import object_type_identifier
 
 
 __all__ = (
 __all__ = (
     'GenericObjectSerializer',
     'GenericObjectSerializer',
@@ -27,9 +28,9 @@ class GenericObjectSerializer(serializers.Serializer):
         return model.objects.get(pk=data['object_id'])
         return model.objects.get(pk=data['object_id'])
 
 
     def to_representation(self, instance):
     def to_representation(self, instance):
-        ct = ContentType.objects.get_for_model(instance)
+        object_type = ObjectType.objects.get_for_model(instance)
         data = {
         data = {
-            'object_type': content_type_identifier(ct),
+            'object_type': object_type_identifier(object_type),
             'object_id': instance.pk,
             'object_id': instance.pk,
         }
         }
         if 'request' in self.context:
         if 'request' in self.context:

+ 7 - 7
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.constants import CONSTRAINT_TOKEN_USER
 from users.models import Group, ObjectPermission
 from users.models import Group, ObjectPermission
 from utilities.permissions import (
 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()
 UserModel = get_user_model()
@@ -284,11 +284,9 @@ class RemoteUserBackend(_RemoteUserBackend):
             permissions_list = []
             permissions_list = []
             for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
             for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
                 try:
                 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.save()
                     obj_perm.users.add(user)
                     obj_perm.users.add(user)
                     obj_perm.object_types.add(object_type)
                     obj_perm.object_types.add(object_type)
@@ -303,7 +301,9 @@ class RemoteUserBackend(_RemoteUserBackend):
                     f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
                     f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
         else:
         else:
             logger.debug(
             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
         return user
 
 

+ 162 - 0
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')),
+    ]

+ 1 - 1
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.
     Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields.
 
 
     Attributes:
     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.
             the rendered form (optional). If not defined, the all fields will be rendered as a single section.
     """
     """
     fieldsets = ()
     fieldsets = ()

+ 4 - 3
netbox/netbox/middleware.py

@@ -13,7 +13,8 @@ from django.http import Http404, HttpResponseRedirect
 from extras.context_managers import event_tracking
 from extras.context_managers import event_tracking
 from netbox.config import clear_config, get_config
 from netbox.config import clear_config, get_config
 from netbox.views import handler_500
 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__ = (
 __all__ = (
     'CoreMiddleware',
     'CoreMiddleware',
@@ -71,7 +72,7 @@ class CoreMiddleware:
 
 
         # Cleanly handle exceptions that occur from REST API requests
         # Cleanly handle exceptions that occur from REST API requests
         if is_api_request(request):
         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)
         # Ignore Http404s (defer to Django's built-in 404 handling)
         if isinstance(exception, Http404):
         if isinstance(exception, Http404):
@@ -211,7 +212,7 @@ class MaintenanceModeMiddleware:
                             'operations. Please try again later.'
                             'operations. Please try again later.'
 
 
             if is_api_request(request):
             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)
             messages.error(request, error_message)
             return HttpResponseRedirect(request.path_info)
             return HttpResponseRedirect(request.path_info)

+ 1 - 1
netbox/netbox/models/features.py

@@ -17,7 +17,7 @@ from netbox.config import get_config
 from netbox.registry import registry
 from netbox.registry import registry
 from netbox.signals import post_clean
 from netbox.signals import post_clean
 from utilities.json import CustomFieldJSONEncoder
 from utilities.json import CustomFieldJSONEncoder
-from utilities.utils import serialize_object
+from utilities.serialization import serialize_object
 from utilities.views import register_model_view
 from utilities.views import register_model_view
 
 
 __all__ = (
 __all__ = (

+ 0 - 2
netbox/netbox/navigation/__init__.py

@@ -1,8 +1,6 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
 from typing import Sequence, Optional
 from typing import Sequence, Optional
 
 
-from utilities.choices import ButtonColorChoices
-
 
 
 __all__ = (
 __all__ = (
     'get_model_item',
     'get_model_item',

+ 0 - 1
netbox/netbox/navigation/menu.py

@@ -1,7 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from netbox.registry import registry
 from netbox.registry import registry
-from utilities.choices import ButtonColorChoices
 from . import *
 from . import *
 
 
 #
 #

+ 3 - 2
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.text import slugify
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
+from netbox.choices import ButtonColorChoices
+from netbox.navigation import MenuGroup
+
 __all__ = (
 __all__ = (
     'PluginMenu',
     'PluginMenu',
     'PluginMenuButton',
     'PluginMenuButton',

+ 3 - 2
netbox/netbox/search/backends.py

@@ -14,8 +14,9 @@ from netaddr.core import AddrFormatError
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.models import CachedValue, CustomField
 from extras.models import CachedValue, CustomField
 from netbox.registry import registry
 from netbox.registry import registry
+from utilities.object_types import object_type_identifier
 from utilities.querysets import RestrictedPrefetch
 from utilities.querysets import RestrictedPrefetch
-from utilities.utils import content_type_identifier, title
+from utilities.string import title
 from . import FieldTypes, LookupTypes, get_indexer
 from . import FieldTypes, LookupTypes, get_indexer
 
 
 DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
 DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
@@ -156,7 +157,7 @@ class CachedValueSearchBackend(SearchBackend):
         # related objects necessary to render the prescribed display attributes (display_attrs).
         # related objects necessary to render the prescribed display attributes (display_attrs).
         for object_type in object_types:
         for object_type in object_types:
             model = object_type.model_class()
             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)):
             if not (display_attrs := getattr(indexer, 'display_attrs', None)):
                 continue
                 continue
 
 

+ 4 - 4
netbox/netbox/search/utils.py

@@ -1,14 +1,14 @@
 from netbox.registry import registry
 from netbox.registry import registry
-from utilities.utils import content_type_identifier
+from utilities.object_types import object_type_identifier
 
 
 __all__ = (
 __all__ = (
     'get_indexer',
     'get_indexer',
 )
 )
 
 
 
 
-def get_indexer(content_type):
+def get_indexer(object_type):
     """
     """
     Return the registered search indexer for the given ContentType.
     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)

+ 1 - 1
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.choices import ChangeActionChoices
 from extras.models import StagedChange
 from extras.models import StagedChange
-from utilities.utils import serialize_object
+from utilities.serialization import serialize_object
 
 
 logger = logging.getLogger('netbox.staging')
 logger = logging.getLogger('netbox.staging')
 
 

+ 6 - 5
netbox/netbox/tables/columns.py

@@ -18,9 +18,10 @@ from django_tables2.columns import library
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
 from extras.choices import CustomFieldTypeChoices
 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.permissions import get_permission_for_model
 from utilities.templatetags.builtins.filters import render_markdown
 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__ = (
 __all__ = (
     'ActionsColumn',
     'ActionsColumn',
@@ -338,12 +339,12 @@ class ContentTypeColumn(tables.Column):
     def render(self, value):
     def render(self, value):
         if value is None:
         if value is None:
             return None
             return None
-        return content_type_name(value, include_app=False)
+        return object_type_name(value, include_app=False)
 
 
     def value(self, value):
     def value(self, value):
         if value is None:
         if value is None:
             return None
             return None
-        return content_type_identifier(value)
+        return object_type_identifier(value)
 
 
 
 
 class ContentTypesColumn(tables.ManyToManyColumn):
 class ContentTypesColumn(tables.ManyToManyColumn):
@@ -357,11 +358,11 @@ class ContentTypesColumn(tables.ManyToManyColumn):
         super().__init__(separator=separator, *args, **kwargs)
         super().__init__(separator=separator, *args, **kwargs)
 
 
     def transform(self, obj):
     def transform(self, obj):
-        return content_type_name(obj, include_app=False)
+        return object_type_name(obj, include_app=False)
 
 
     def value(self, value):
     def value(self, value):
         return ','.join([
         return ','.join([
-            content_type_identifier(ct) for ct in self.filter(value)
+            object_type_identifier(ot) for ot in self.filter(value)
         ])
         ])
 
 
 
 

+ 4 - 2
netbox/netbox/tables/tables.py

@@ -17,7 +17,9 @@ from extras.models import CustomField, CustomLink
 from netbox.registry import registry
 from netbox.registry import registry
 from netbox.tables import columns
 from netbox.tables import columns
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 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 *
 from .template_code import *
 
 
 __all__ = (
 __all__ = (
@@ -273,6 +275,6 @@ class SearchTable(tables.Table):
         if not self.highlight:
         if not self.highlight:
             return value
             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)
         return mark_safe(value)

+ 1 - 1
netbox/netbox/tests/test_import.py

@@ -2,8 +2,8 @@ from django.test import override_settings
 
 
 from core.models import ObjectType
 from core.models import ObjectType
 from dcim.models import *
 from dcim.models import *
+from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from users.models import ObjectPermission
 from users.models import ObjectPermission
-from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
 from utilities.testing import ModelViewTestCase, create_tags
 from utilities.testing import ModelViewTestCase, create_tags
 
 
 
 

+ 1 - 2
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 import BulkRenameForm, ConfirmationForm, restrict_form_fields
 from utilities.forms.bulk_import import BulkImportForm
 from utilities.forms.bulk_import import BulkImportForm
 from utilities.permissions import get_permission_for_model
 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 .base import BaseMultiObjectView
 from .mixins import ActionsMixin, TableMixin
 from .mixins import ActionsMixin, TableMixin
 from .utils import get_prerequisite_model
 from .utils import get_prerequisite_model

+ 2 - 2
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.exceptions import AbortRequest, PermissionsViolation
 from utilities.forms import ConfirmationForm, restrict_form_fields
 from utilities.forms import ConfirmationForm, restrict_form_fields
 from utilities.permissions import get_permission_for_model
 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 .base import BaseObjectView
 from .mixins import ActionsMixin, TableMixin
 from .mixins import ActionsMixin, TableMixin
 from .utils import get_prerequisite_model
 from .utils import get_prerequisite_model

+ 3 - 2
netbox/project-static/js/setmode.js

@@ -5,10 +5,11 @@
  * @param inferred {boolean} Value is inferred from browser/system preference.
  * @param inferred {boolean} Value is inferred from browser/system preference.
  */
  */
 function setMode(mode, inferred) {
 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", mode);
     localStorage.setItem("netbox-color-mode-inferred", inferred);
     localStorage.setItem("netbox-color-mode-inferred", inferred);
 }
 }
+
 /**
 /**
  * Determine the best initial color mode to use prior to rendering.
  * Determine the best initial color mode to use prior to rendering.
  */
  */
@@ -69,4 +70,4 @@ function initMode() {
         console.error(error);
         console.error(error);
     }
     }
     return setMode("light", true);
     return setMode("light", true);
-};
+}

+ 2 - 9
netbox/templates/account/preferences.html

@@ -10,15 +10,8 @@
     {% csrf_token %}
     {% csrf_token %}
 
 
     {# Built-in preferences #}
     {# Built-in preferences #}
-    {% for group, fields in form.fieldsets %}
-      <div class="field-group my-5">
-        <div class="row">
-          <h5 class="col-9 offset-3">{{ group }}</h5>
-        </div>
-        {% for name in fields %}
-          {% render_field form|getfield:name %}
-        {% endfor %}
-      </div>
+    {% for fieldset in form.fieldsets %}
+      {% render_fieldset form fieldset %}
     {% endfor %}
     {% endfor %}
 
 
     {# Plugin preferences #}
     {# Plugin preferences #}

+ 12 - 13
netbox/templates/base/base.html

@@ -9,13 +9,7 @@
   data-netbox-url-name="{{ request.resolver_match.url_name }}"
   data-netbox-url-name="{{ request.resolver_match.url_name }}"
   data-netbox-base-path="{{ settings.BASE_PATH }}"
   data-netbox-base-path="{{ settings.BASE_PATH }}"
   {% with preferences|get_key:'ui.colormode' as color_mode %}
   {% 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 %}
   {% endwith %}
   >
   >
   <head>
   <head>
@@ -25,7 +19,16 @@
     {# Page title #}
     {# Page title #}
     <title>{% block title %}{% trans "Home" %}{% endblock %} | NetBox</title>
     <title>{% block title %}{% trans "Home" %}{% endblock %} | NetBox</title>
 
 
+    {# Initialize color mode #}
+    <script
+      type="text/javascript"
+      src="{% static 'setmode.js' %}"
+      onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'">
+    </script>
     <script type="text/javascript">
     <script type="text/javascript">
+      (function () {
+        initMode()
+      })();
       window.CSRF_TOKEN = "{{ csrf_token }}";
       window.CSRF_TOKEN = "{{ csrf_token }}";
     </script>
     </script>
 
 
@@ -53,13 +56,9 @@
 
 
     {# Additional <head> content #}
     {# Additional <head> content #}
     {% block head %}{% endblock %}
     {% block head %}{% endblock %}
-  </head>
 
 
-  <body
-    {% if preferences|get_key:'ui.colormode' == 'dark' %}
-      data-bs-theme="dark"
-    {% endif %}
-  >
+  </head>
+  <body>
 
 
     {# Page layout #}
     {# Page layout #}
     {% block layout %}{% endblock %}
     {% block layout %}{% endblock %}

+ 3 - 3
netbox/templates/base/layout.html

@@ -41,7 +41,7 @@ Blocks:
 
 
     {# Top menu #}
     {# Top menu #}
     <header class="navbar navbar-expand-md d-none d-lg-flex d-print-none">
     <header class="navbar navbar-expand-md d-none d-lg-flex d-print-none">
-      <div class="container-xl">
+      <div class="container-fluid">
 
 
         {# Nav menu toggle #}
         {# Nav menu toggle #}
         <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
         <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
@@ -105,7 +105,7 @@ Blocks:
       {# Page body #}
       {# Page body #}
       {% block page %}
       {% block page %}
         <div class="page-body my-1">
         <div class="page-body my-1">
-          <div class="container-xl tab-content py-3">
+          <div class="container-fluid tab-content py-3">
 
 
             {# Page content #}
             {# Page content #}
             {% block content %}{% endblock %}
             {% block content %}{% endblock %}
@@ -124,7 +124,7 @@ Blocks:
 
 
       {# Page footer #}
       {# Page footer #}
       <footer class="footer footer-transparent d-print-none py-2">
       <footer class="footer footer-transparent d-print-none py-2">
-        <div class="container-xl d-flex justify-content-between align-items-center">
+        <div class="container-fluid d-flex justify-content-between align-items-center">
           {% block footer %}
           {% block footer %}
 
 
             {# Footer links #}
             {# Footer links #}

+ 0 - 58
netbox/templates/circuits/circuittermination_edit.html

@@ -1,58 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load static %}
-{% load form_helpers %}
-{% load i18n %}
-
-{% block form %}
-  <div class="field-group my-5">
-    <div class="row">
-      <h5 class="col-9 offset-3">{% trans "Circuit Termination" %}</h5>
-    </div>
-    {% render_field form.circuit %}
-    {% render_field form.term_side %}
-    {% render_field form.tags %}
-    {% render_field form.mark_connected %}
-    {% with providernetwork_tab_active=form.initial.provider_network %}
-      <div class="row">
-        <div class="col-9 offset-3">
-          <ul class="nav nav-pills mb-1" role="tablist">
-            <li class="nav-item" role="presentation">
-              <button class="nav-link{% if not providernetwork_tab_active %} active{% endif %}" role="tab" type="button" data-bs-target="#site" data-bs-toggle="tab">{% trans "Site" %}</button>
-            </li>
-            <li class="nav-item" role="presentation">
-              <button class="nav-link{% if providernetwork_tab_active %} active{% endif %}" role="tab" type="button" data-bs-toggle="tab" data-bs-target="#providernetwork">{% trans "Provider Network" %}</button>
-            </li>
-          </ul>
-        </div>
-      </div>
-      <div class="tab-content p-0 border-0">
-        <div class="tab-pane{% if not providernetwork_tab_active %} active{% endif %}" id="site">
-          {% render_field form.site %}
-        </div>
-        <div class="tab-pane{% if providernetwork_tab_active %} active{% endif %}" id="providernetwork">
-          {% render_field form.provider_network %}
-        </div>
-      </div>
-    {% endwith %}
-  </div>
-
-  <div class="field-group my-5">
-    <div class="row">
-      <h5 class="col-9 offset-3">{% trans "Termination Details" %}</h5>
-    </div>
-    {% render_field form.port_speed %}
-    {% render_field form.upstream_speed %}
-    {% render_field form.xconnect_id %}
-    {% render_field form.pp_info %}
-    {% render_field form.description %}
-  </div>
-
-  {% if form.custom_fields %}
-    <div class="field-group mb-5">
-      <div class="row">
-        <h5 class="col-9 offset-3">{% trans "Custom Fields" %}</h5>
-      </div>
-      {% render_custom_fields form %}
-    </div>
-  {% endif %}
-{% endblock %}

+ 1 - 1
netbox/templates/core/rq_task_list.html

@@ -5,7 +5,7 @@
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}
 
 
 {% block page-header %}
 {% block page-header %}
-  <div class="container-xl">
+  <div class="container-fluid">
     <div class="d-flex justify-content-between align-items-center mt-2">
     <div class="d-flex justify-content-between align-items-center mt-2">
       {# Breadcrumbs #}
       {# Breadcrumbs #}
       <nav class="breadcrumb-container" aria-label="breadcrumb">
       <nav class="breadcrumb-container" aria-label="breadcrumb">

+ 1 - 1
netbox/templates/core/rq_worker_list.html

@@ -4,7 +4,7 @@
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}
 
 
 {% block page-header %}
 {% block page-header %}
-  <div class="container-xl">
+  <div class="container-fluid">
     <div class="d-flex justify-content-between align-items-center mt-2">
     <div class="d-flex justify-content-between align-items-center mt-2">
       {# Breadcrumbs #}
       {# Breadcrumbs #}
       <nav class="breadcrumb-container" aria-label="breadcrumb">
       <nav class="breadcrumb-container" aria-label="breadcrumb">

+ 0 - 107
netbox/templates/dcim/inventoryitem_edit.html

@@ -1,107 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load static %}
-{% load form_helpers %}
-{% load helpers %}
-{% load i18n %}
-
-{% block form %}
-    <div class="field-group my-5">
-      <div class="row">
-        <h5 class="col-9 offset-3">{% trans "Inventory Item" %}</h5>
-      </div>
-      {% render_field form.device %}
-      {% render_field form.parent %}
-      {% render_field form.name %}
-      {% render_field form.label %}
-      {% render_field form.role %}
-      {% render_field form.description %}
-      {% render_field form.tags %}
-    </div>
-
-    <div class="field-group my-5">
-      <div class="row">
-        <h5 class="col-9 offset-3">{% trans "Hardware" %}</h5>
-      </div>
-      {% render_field form.manufacturer %}
-      {% render_field form.part_id %}
-      {% render_field form.serial %}
-      {% render_field form.asset_tag %}
-    </div>
-
-    <div class="field-group my-5">
-      <div class="row">
-        <h5 class="col-9 offset-3">{% trans "Component Assignment" %}</h5>
-      </div>
-      <div class="row offset-sm-3">
-        <ul class="nav nav-pills mb-1" role="tablist">
-          <li role="presentation" class="nav-item">
-              <button role="tab" type="button" id="consoleport_tab" data-bs-toggle="tab" aria-controls="consoleport" data-bs-target="#consoleport" class="nav-link {% if form.initial.consoleport or form.no_component %}active{% endif %}">
-                {% trans "Console Port" %}
-              </button>
-            </li>
-            <li role="presentation" class="nav-item">
-              <button role="tab" type="button" id="consoleserverport_tab" data-bs-toggle="tab" aria-controls="consoleserverport" data-bs-target="#consoleserverport" class="nav-link {% if form.initial.consoleserverport %}active{% endif %}">
-                {% trans "Console Server Port" %}
-              </button>
-            </li>
-            <li role="presentation" class="nav-item">
-              <button role="tab" type="button" id="frontport_tab" data-bs-toggle="tab" aria-controls="frontport" data-bs-target="#frontport" class="nav-link {% if form.initial.frontport %}active{% endif %}">
-                {% trans "Front Port" %}
-              </button>
-            </li>
-            <li role="presentation" class="nav-item">
-              <button role="tab" type="button" id="interface_tab" data-bs-toggle="tab" aria-controls="interface" data-bs-target="#interface" class="nav-link {% if form.initial.interface %}active{% endif %}">
-                {% trans "Interface" %}
-              </button>
-            </li>
-            <li role="presentation" class="nav-item">
-              <button role="tab" type="button" id="poweroutlet_tab" data-bs-toggle="tab" aria-controls="poweroutlet" data-bs-target="#poweroutlet" class="nav-link {% if form.initial.poweroutlet %}active{% endif %}">
-                {% trans "Power Outlet" %}
-              </button>
-            </li>
-            <li role="presentation" class="nav-item">
-              <button role="tab" type="button" id="powerport_tab" data-bs-toggle="tab" aria-controls="powerport" data-bs-target="#powerport" class="nav-link {% if form.initial.powerport %}active{% endif %}">
-                {% trans "Power Port" %}
-              </button>
-            </li>
-            <li role="presentation" class="nav-item">
-              <button role="tab" type="button" id="rearport_tab" data-bs-toggle="tab" aria-controls="rearport" data-bs-target="#rearport" class="nav-link {% if form.initial.rearport %}active{% endif %}">
-                {% trans "Rear Port" %}
-              </button>
-            </li>
-        </ul>
-      </div>
-      <div class="tab-content p-0 border-0">
-        <div class="tab-pane {% if form.initial.consoleport or form.no_component %}active{% endif %}" id="consoleport" role="tabpanel" aria-labeled-by="consoleport_tab">
-            {% render_field form.consoleport %}
-          </div>
-          <div class="tab-pane {% if form.initial.consoleserverport %}active{% endif %}" id="consoleserverport" role="tabpanel" aria-labeled-by="consoleserverport_tab">
-            {% render_field form.consoleserverport %}
-          </div>
-          <div class="tab-pane {% if form.initial.frontport %}active{% endif %}" id="frontport" role="tabpanel" aria-labeled-by="frontport_tab">
-            {% render_field form.frontport %}
-          </div>
-          <div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">
-            {% render_field form.interface %}
-          </div>
-          <div class="tab-pane {% if form.initial.poweroutlet %}active{% endif %}" id="poweroutlet" role="tabpanel" aria-labeled-by="poweroutlet_tab">
-            {% render_field form.poweroutlet %}
-          </div>
-          <div class="tab-pane {% if form.initial.powerport %}active{% endif %}" id="powerport" role="tabpanel" aria-labeled-by="powerport_tab">
-            {% render_field form.powerport %}
-          </div>
-          <div class="tab-pane {% if form.initial.rearport %}active{% endif %}" id="rearport" role="tabpanel" aria-labeled-by="rearport_tab">
-            {% render_field form.rearport %}
-          </div>
-      </div>
-    </div>
-
-    {% if form.custom_fields %}
-      <div class="field-group my-5">
-        <div class="row">
-          <h5 class="col-9 offset-3">{% trans "Custom Fields" %}</h5>
-        </div>
-        {% render_custom_fields form %}
-      </div>
-    {% endif %}
-{% endblock %}

+ 4 - 0
netbox/templates/dcim/location.html

@@ -54,6 +54,10 @@
             {{ object.tenant|linkify|placeholder }}
             {{ object.tenant|linkify|placeholder }}
           </td>
           </td>
         </tr>
         </tr>
+        <tr>
+          <th scope="row">{% trans "Facility" %}</th>
+          <td>{{ object.facility|placeholder }}</td>
+        </tr>
       </table>
       </table>
     </div>
     </div>
     {% include 'inc/panels/tags.html' %}
     {% include 'inc/panels/tags.html' %}

+ 0 - 90
netbox/templates/dcim/rack_edit.html

@@ -1,90 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load form_helpers %}
-{% load i18n %}
-
-{% block form %}
-    <div class="field-group my-5">
-        <div class="row">
-          <h5 class="col-9 offset-3">{% trans "Rack" %}</h5>
-        </div>
-        {% render_field form.site %}
-        {% render_field form.location %}
-        {% render_field form.name %}
-        {% render_field form.status %}
-        {% render_field form.role %}
-        {% render_field form.description %}
-        {% render_field form.tags %}
-    </div>
-
-    <div class="field-group my-5">
-        <div class="row">
-          <h5 class="col-9 offset-3">{% trans "Inventory Control" %}</h5>
-        </div>
-        {% render_field form.facility_id %}
-        {% render_field form.serial %}
-        {% render_field form.asset_tag %}
-    </div>
-
-    <div class="field-group my-5">
-        <div class="row">
-          <h5 class="col-9 offset-3">{% trans "Tenancy" %}</h5>
-        </div>
-        {% render_field form.tenant_group %}
-        {% render_field form.tenant %}
-    </div>
-
-    <div class="field-group my-5">
-        <div class="row">
-          <h5 class="col-9 offset-3">{% trans "Dimensions" %}</h5>
-        </div>
-        {% render_field form.type %}
-        {% render_field form.width %}
-        {% render_field form.starting_unit %}
-        {% render_field form.u_height %}
-        <div class="row mb-3">
-            <label class="col col-md-3 col-form-label text-lg-end">{% trans "Outer Dimensions" %}</label>
-            <div class="col col-md-3 mb-1">
-                {{ form.outer_width }}
-                <div class="form-text">{% trans "Width" %}</div>
-            </div>
-            <div class="col col-md-3 mb-1">
-                {{ form.outer_depth }}
-                <div class="form-text">{% trans "Depth" %}</div>
-            </div>
-            <div class="col col-md-3 mb-1">
-                {{ form.outer_unit }}
-                <div class="form-text">{% trans "Unit" %}</div>
-            </div>
-        </div>
-        <div class="row mb-3">
-            <label class="col col-md-3 col-form-label text-lg-end">{% trans "Weight" %}</label>
-            <div class="col col-md-3 mb-1">
-                {{ form.weight }}
-                <div class="form-text">{% trans "Weight" %}</div>
-            </div>
-            <div class="col col-md-3 mb-1">
-                {{ form.max_weight }}
-                <div class="form-text">{% trans "Maximum Weight" %}</div>
-            </div>
-            <div class="col col-md-3 mb-1">
-                {{ form.weight_unit }}
-                <div class="form-text">{% trans "Unit" %}</div>
-            </div>
-        </div>
-        {% render_field form.mounting_depth %}
-        {% render_field form.desc_units %}
-    </div>
-
-    {% if form.custom_fields %}
-      <div class="field-group my-5">
-        <div class="row">
-          <h5 class="col-9 offset-3">{% trans "Custom Fields" %}</h5>
-        </div>
-          {% render_custom_fields form %}
-      </div>
-    {% endif %}
-
-    <div class="field-group my-5">
-      {% render_field form.comments %}
-    </div>
-{% endblock %}

+ 0 - 19
netbox/templates/extras/imageattachment_edit.html

@@ -1,19 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load helpers %}
-{% load form_helpers %}
-
-{% block form %}
-  <div class="field-group mb-5">
-    <div class="row mb-3">
-      <label class="col-sm-3 col-form-label text-lg-end required">
-        {{ object.parent|meta:"verbose_name"|bettertitle }}
-      </label>
-      <div class="col-sm-9">
-        <div class="form-control-plaintext">
-          {{ object.parent|linkify }}
-        </div>
-      </div>
-    </div>
-    {% render_form form %}
-  </div>
-{% endblock form %}

+ 1 - 1
netbox/templates/extras/script_result.html

@@ -11,7 +11,7 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block page-header %}
 {% block page-header %}
-  <div class="container-xl mt-2">
+  <div class="container-fluid mt-2">
     <nav class="breadcrumb-container" aria-label="breadcrumb">
     <nav class="breadcrumb-container" aria-label="breadcrumb">
       <ol class="breadcrumb">
       <ol class="breadcrumb">
         <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">{% trans "Scripts" %}</a></li>
         <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">{% trans "Scripts" %}</a></li>

+ 2 - 2
netbox/templates/generic/_base.html

@@ -5,7 +5,7 @@
     {{ block.super }}
     {{ block.super }}
 
 
     {% block page-header %}
     {% block page-header %}
-      <div class="container-xl mt-2 d-print-none">
+      <div class="container-fluid mt-2 d-print-none">
         <div class="d-flex justify-content-between">
         <div class="d-flex justify-content-between">
 
 
           {# Title #}
           {# Title #}
@@ -29,7 +29,7 @@
 
 
     {# Tabs #}
     {# Tabs #}
     <div class="page-tabs mt-3">
     <div class="page-tabs mt-3">
-      <div class="container-xl">
+      <div class="container-fluid">
         {% block tabs %}{% endblock %}
         {% block tabs %}{% endblock %}
       </div>
       </div>
     </div>
     </div>

+ 1 - 1
netbox/templates/generic/bulk_delete.html

@@ -71,7 +71,7 @@ Context:
   {# Selected objects list #}
   {# Selected objects list #}
   <div class="tab-pane" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
   <div class="tab-pane" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
     <div class="card">
     <div class="card">
-      <div class="card-body table-responsive">
+      <div class="table-responsive">
         {% render_table table 'inc/table.html' %}
         {% render_table table 'inc/table.html' %}
       </div>
       </div>
     </div>
     </div>

+ 2 - 17
netbox/templates/generic/bulk_edit.html

@@ -49,23 +49,8 @@ Context:
       {% if form.fieldsets %}
       {% if form.fieldsets %}
 
 
         {# Render grouped fields according to declared fieldsets #}
         {# Render grouped fields according to declared fieldsets #}
-        {% for group, fields in form.fieldsets %}
-          <div class="field-group mb-5">
-            <div class="row">
-              <h5 class="col-9 offset-3">
-                {% if group %}{{ group }}{% else %}{{ model|meta:"verbose_name"|bettertitle }}{% endif %}
-              </h5>
-            </div>
-            {% for name in fields %}
-              {% with field=form|getfield:name %}
-                {% if field.name in form.nullable_fields %}
-                  {% render_field field bulk_nullable=True %}
-                {% else %}
-                  {% render_field field %}
-                {% endif %}
-              {% endwith %}
-            {% endfor %}
-          </div>
+        {% for fieldset in form.fieldsets %}
+          {% render_fieldset form fieldset %}
         {% endfor %}
         {% endfor %}
 
 
         {# Render tag add/remove fields #}
         {# Render tag add/remove fields #}

+ 5 - 3
netbox/templates/generic/bulk_remove.html

@@ -33,9 +33,11 @@
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>
-    <div class="container-xl px-0">
-      <div class="table-responsive">
-        {% render_table table 'inc/table.html' %}
+    <div class="container-fluid px-0">
+      <div class="card">
+        <div class="table-responsive">
+          {% render_table table 'inc/table.html' %}
+        </div>
       </div>
       </div>
       <form action="." method="post" class="form">
       <form action="." method="post" class="form">
         {% csrf_token %}
         {% csrf_token %}

+ 1 - 1
netbox/templates/generic/object.html

@@ -19,7 +19,7 @@ Context:
 {% endcomment %}
 {% endcomment %}
 
 
 {% block page-header %}
 {% block page-header %}
-  <div class="container-xl">
+  <div class="container-fluid">
     <div class="d-flex justify-content-between align-items-center mt-2">
     <div class="d-flex justify-content-between align-items-center mt-2">
 
 
       {# Breadcrumbs #}
       {# Breadcrumbs #}

+ 2 - 15
netbox/templates/htmx/form.html

@@ -9,21 +9,8 @@
   {% endfor %}
   {% endfor %}
 
 
   {# Render grouped fields according to Form #}
   {# Render grouped fields according to Form #}
-  {% for group, fields in form.fieldsets %}
-    <div class="field-group mb-5">
-      {% if group %}
-        <div class="row">
-          <h5 class="col-9 offset-3">{{ group }}</h5>
-        </div>
-      {% endif %}
-      {% for name in fields %}
-        {% with field=form|getfield:name %}
-          {% if field and not field.field.widget.is_hidden %}
-            {% render_field field %}
-          {% endif %}
-        {% endwith %}
-      {% endfor %}
-    </div>
+  {% for fieldset in form.fieldsets %}
+    {% render_fieldset form fieldset %}
   {% endfor %}
   {% endfor %}
 
 
   {% if form.custom_fields %}
   {% if form.custom_fields %}

+ 2 - 11
netbox/templates/inc/filter_list.html

@@ -9,18 +9,9 @@
         {{ field }}
         {{ field }}
       {% endfor %}
       {% endfor %}
       {# List filters by group #}
       {# List filters by group #}
-      {% for heading, fields in filter_form.fieldsets %}
+      {% for fieldset in filter_form.fieldsets %}
         <div class="col col-12">
         <div class="col col-12">
-          {% if heading %}
-            <div class="hr-text">
-              <span>{{ heading }}</span>
-            </div>
-          {% endif %}
-          {% for name in fields %}
-            {% with field=filter_form|get_item:name %}
-              {% render_field field %}
-            {% endwith %}
-          {% endfor %}
+          {% render_fieldset filter_form fieldset %}
         </div>
         </div>
       {% empty %}
       {% empty %}
         {# List all non-customfield filters as declared in the form class #}
         {# List all non-customfield filters as declared in the form class #}

+ 0 - 19
netbox/templates/ipam/fhrpgroupassignment_edit.html

@@ -1,19 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load form_helpers %}
-{% load i18n %}
-
-{% block form %}
-  <div class="field-group my-5">
-    <div class="row">
-      <h5 class="col-9 offset-3">{% trans "FHRP Group Assignment" %}</h5>
-    </div>
-    <div class="row mb-3">
-        <label class="col-sm-3 col-form-label text-lg-end">{% trans "Interface" %}</label>
-        <div class="col">
-            <input class="form-control" value="{{ form.instance.interface }}" disabled />
-        </div>
-    </div>
-    {% render_field form.group %}
-    {% render_field form.priority %}
-  </div>
-{% endblock %}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini