Просмотр исходного кода

Closes #12246: General cleanup of utilities modules

* Clean up base modules

* Clean up forms modules

* Clean up templatetags modules

* Replace custom simplify_decimal filter with floatformat

* Misc cleanup

* Merge ReturnURLForm into ConfirmationForm

* Clean up import statements for utilities.forms

* Fix field class references in docs
Jeremy Stretch 2 лет назад
Родитель
Сommit
d470848b29
87 измененных файлов с 586 добавлено и 407 удалено
  1. 16 16
      docs/plugins/development/forms.md
  2. 3 3
      netbox/circuits/forms/bulk_edit.py
  3. 2 1
      netbox/circuits/forms/bulk_import.py
  4. 2 1
      netbox/circuits/forms/filtersets.py
  5. 2 3
      netbox/circuits/forms/model_forms.py
  6. 3 1
      netbox/core/forms/bulk_edit.py
  7. 3 4
      netbox/core/forms/filtersets.py
  8. 2 1
      netbox/core/forms/model_forms.py
  9. 2 1
      netbox/dcim/forms/bulk_create.py
  10. 3 4
      netbox/dcim/forms/bulk_edit.py
  11. 3 2
      netbox/dcim/forms/bulk_import.py
  12. 1 1
      netbox/dcim/forms/common.py
  13. 2 2
      netbox/dcim/forms/connections.py
  14. 3 4
      netbox/dcim/forms/filtersets.py
  15. 5 4
      netbox/dcim/forms/model_forms.py
  16. 1 1
      netbox/dcim/forms/object_create.py
  17. 2 2
      netbox/dcim/tables/template_code.py
  18. 3 3
      netbox/extras/forms/bulk_edit.py
  19. 2 1
      netbox/extras/forms/bulk_import.py
  20. 3 4
      netbox/extras/forms/filtersets.py
  21. 4 3
      netbox/extras/forms/model_forms.py
  22. 2 2
      netbox/extras/forms/reports.py
  23. 2 1
      netbox/extras/forms/scripts.py
  24. 2 1
      netbox/extras/scripts.py
  25. 2 2
      netbox/ipam/api/views.py
  26. 2 1
      netbox/ipam/forms/bulk_create.py
  27. 4 3
      netbox/ipam/forms/bulk_edit.py
  28. 1 1
      netbox/ipam/forms/bulk_import.py
  29. 3 3
      netbox/ipam/forms/filtersets.py
  30. 5 3
      netbox/ipam/forms/model_forms.py
  31. 11 0
      netbox/netbox/constants.py
  32. 1 1
      netbox/netbox/filtersets.py
  33. 4 4
      netbox/templates/dcim/interface.html
  34. 2 2
      netbox/templates/wireless/inc/wirelesslink_interface.html
  35. 2 1
      netbox/tenancy/forms/bulk_edit.py
  36. 1 1
      netbox/tenancy/forms/bulk_import.py
  37. 1 1
      netbox/tenancy/forms/forms.py
  38. 2 3
      netbox/tenancy/forms/model_forms.py
  39. 2 1
      netbox/users/forms.py
  40. 8 0
      netbox/utilities/api.py
  41. 0 15
      netbox/utilities/constants.py
  42. 1 0
      netbox/utilities/exceptions.py
  43. 9 7
      netbox/utilities/fields.py
  44. 4 0
      netbox/utilities/files.py
  45. 16 0
      netbox/utilities/filters.py
  46. 1 2
      netbox/utilities/forms/__init__.py
  47. 1 0
      netbox/utilities/forms/fields/__init__.py
  48. 24 0
      netbox/utilities/forms/fields/array.py
  49. 0 1
      netbox/utilities/forms/fields/content_types.py
  50. 0 5
      netbox/utilities/forms/fields/csv.py
  51. 12 77
      netbox/utilities/forms/forms.py
  52. 62 0
      netbox/utilities/forms/mixins.py
  53. 4 0
      netbox/utilities/forms/widgets/__init__.py
  54. 8 160
      netbox/utilities/forms/widgets/apiselect.py
  55. 37 0
      netbox/utilities/forms/widgets/datetime.py
  56. 28 0
      netbox/utilities/forms/widgets/misc.py
  57. 79 0
      netbox/utilities/forms/widgets/select.py
  58. 6 3
      netbox/utilities/graphql_optimizer.py
  59. 5 0
      netbox/utilities/htmx.py
  60. 4 0
      netbox/utilities/markdown.py
  61. 4 0
      netbox/utilities/migration.py
  62. 5 0
      netbox/utilities/mptt.py
  63. 5 0
      netbox/utilities/ordering.py
  64. 6 0
      netbox/utilities/paginator.py
  65. 6 1
      netbox/utilities/query_functions.py
  66. 5 0
      netbox/utilities/querysets.py
  67. 5 0
      netbox/utilities/tables.py
  68. 15 0
      netbox/utilities/templatetags/builtins/filters.py
  69. 7 0
      netbox/utilities/templatetags/builtins/tags.py
  70. 12 0
      netbox/utilities/templatetags/buttons.py
  71. 9 0
      netbox/utilities/templatetags/form_helpers.py
  72. 23 22
      netbox/utilities/templatetags/helpers.py
  73. 4 0
      netbox/utilities/templatetags/navigation.py
  74. 8 0
      netbox/utilities/templatetags/perms.py
  75. 4 0
      netbox/utilities/templatetags/tabs.py
  76. 4 0
      netbox/utilities/urls.py
  77. 15 1
      netbox/utilities/validators.py
  78. 2 1
      netbox/virtualization/forms/bulk_create.py
  79. 3 4
      netbox/virtualization/forms/bulk_edit.py
  80. 3 2
      netbox/virtualization/forms/bulk_import.py
  81. 2 3
      netbox/virtualization/forms/filtersets.py
  82. 3 3
      netbox/virtualization/forms/model_forms.py
  83. 1 1
      netbox/virtualization/forms/object_create.py
  84. 2 1
      netbox/wireless/forms/bulk_edit.py
  85. 2 1
      netbox/wireless/forms/bulk_import.py
  86. 2 1
      netbox/wireless/forms/filtersets.py
  87. 4 3
      netbox/wireless/forms/model_forms.py

+ 16 - 16
docs/plugins/development/forms.md

@@ -145,23 +145,23 @@ class MyModelFilterForm(NetBoxModelFilterSetForm):
 
 
 In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
 In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
 
 
-::: utilities.forms.ColorField
+::: utilities.forms.fields.ColorField
     options:
     options:
       members: false
       members: false
 
 
-::: utilities.forms.CommentField
+::: utilities.forms.fields.CommentField
     options:
     options:
       members: false
       members: false
 
 
-::: utilities.forms.JSONField
+::: utilities.forms.fields.JSONField
     options:
     options:
       members: false
       members: false
 
 
-::: utilities.forms.MACAddressField
+::: utilities.forms.fields.MACAddressField
     options:
     options:
       members: false
       members: false
 
 
-::: utilities.forms.SlugField
+::: utilities.forms.fields.SlugField
     options:
     options:
       members: false
       members: false
 
 
@@ -170,52 +170,52 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
 !!! warning "Obsolete Fields"
 !!! warning "Obsolete Fields"
     NetBox's custom `ChoiceField` and `MultipleChoiceField` classes are no longer necessary thanks to improvements made to the user interface. Django's native form fields can be used instead. These custom field classes will be removed in NetBox v3.6.
     NetBox's custom `ChoiceField` and `MultipleChoiceField` classes are no longer necessary thanks to improvements made to the user interface. Django's native form fields can be used instead. These custom field classes will be removed in NetBox v3.6.
 
 
-::: utilities.forms.ChoiceField
+::: utilities.forms.fields.ChoiceField
     options:
     options:
       members: false
       members: false
 
 
-::: utilities.forms.MultipleChoiceField
+::: utilities.forms.fields.MultipleChoiceField
     options:
     options:
       members: false
       members: false
 
 
 ## Dynamic Object Fields
 ## Dynamic Object Fields
 
 
-::: utilities.forms.DynamicModelChoiceField
+::: utilities.forms.fields.DynamicModelChoiceField
     options:
     options:
       members: false
       members: false
 
 
-::: utilities.forms.DynamicModelMultipleChoiceField
+::: utilities.forms.fields.DynamicModelMultipleChoiceField
     options:
     options:
       members: false
       members: false
 
 
 ## Content Type Fields
 ## Content Type Fields
 
 
-::: utilities.forms.ContentTypeChoiceField
+::: utilities.forms.fields.ContentTypeChoiceField
     options:
     options:
       members: false
       members: false
 
 
-::: utilities.forms.ContentTypeMultipleChoiceField
+::: utilities.forms.fields.ContentTypeMultipleChoiceField
     options:
     options:
       members: false
       members: false
 
 
 ## CSV Import Fields
 ## CSV Import Fields
 
 
-::: utilities.forms.CSVChoiceField
+::: utilities.forms.fields.CSVChoiceField
     options:
     options:
       members: false
       members: false
 
 
-::: utilities.forms.CSVMultipleChoiceField
+::: utilities.forms.fields.CSVMultipleChoiceField
     options:
     options:
       members: false
       members: false
 
 
-::: utilities.forms.CSVModelChoiceField
+::: utilities.forms.fields.CSVModelChoiceField
     options:
     options:
       members: false
       members: false
 
 
-::: utilities.forms.CSVContentTypeField
+::: utilities.forms.fields.CSVContentTypeField
     options:
     options:
       members: false
       members: false
 
 
-::: utilities.forms.CSVMultipleContentTypeField
+::: utilities.forms.fields.CSVMultipleContentTypeField
     options:
     options:
       members: false
       members: false

+ 3 - 3
netbox/circuits/forms/bulk_edit.py

@@ -6,9 +6,9 @@ from circuits.models import *
 from ipam.models import ASN
 from ipam.models import ASN
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms import (
-    add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-)
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.widgets import DatePicker
 
 
 __all__ = (
 __all__ = (
     'CircuitBulkEditForm',
     'CircuitBulkEditForm',

+ 2 - 1
netbox/circuits/forms/bulk_import.py

@@ -6,7 +6,8 @@ from dcim.models import Site
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms import BootstrapMixin, CSVChoiceField, CSVModelChoiceField, SlugField
+from utilities.forms import BootstrapMixin
+from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
 
 
 __all__ = (
 __all__ = (
     'CircuitImportForm',
     'CircuitImportForm',

+ 2 - 1
netbox/circuits/forms/filtersets.py

@@ -7,7 +7,8 @@ from dcim.models import Region, Site, SiteGroup
 from ipam.models import ASN
 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 import DatePicker, DynamicModelMultipleChoiceField, TagFilterField
+from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
+from utilities.forms.widgets import DatePicker
 
 
 __all__ = (
 __all__ = (
     'CircuitFilterForm',
     'CircuitFilterForm',

+ 2 - 3
netbox/circuits/forms/model_forms.py

@@ -5,9 +5,8 @@ from dcim.models import Site
 from ipam.models import ASN
 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 import (
-    CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SlugField,
-)
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
+from utilities.forms.widgets import DatePicker, SelectSpeedWidget
 
 
 __all__ = (
 __all__ = (
     'CircuitForm',
     'CircuitForm',

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

@@ -4,7 +4,9 @@ from django.utils.translation import gettext as _
 from core.choices import DataSourceTypeChoices
 from core.choices import DataSourceTypeChoices
 from core.models import *
 from core.models import *
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
-from utilities.forms import add_blank_choice, BulkEditNullBooleanSelect, CommentField
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import CommentField
+from utilities.forms.widgets import BulkEditNullBooleanSelect
 
 
 __all__ = (
 __all__ = (
     'DataSourceBulkEditForm',
     'DataSourceBulkEditForm',

+ 3 - 4
netbox/core/forms/filtersets.py

@@ -8,10 +8,9 @@ from core.models import *
 from extras.forms.mixins import SavedFiltersMixin
 from extras.forms.mixins import SavedFiltersMixin
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
-from utilities.forms import (
-    APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeChoiceField, DateTimePicker,
-    DynamicModelMultipleChoiceField, FilterForm,
-)
+from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
+from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.widgets import APISelectMultiple, DateTimePicker
 
 
 __all__ = (
 __all__ = (
     'DataFileFilterForm',
     'DataFileFilterForm',

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

@@ -6,7 +6,8 @@ from core.models import *
 from extras.forms.mixins import SyncedDataMixin
 from extras.forms.mixins import SyncedDataMixin
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from netbox.registry import registry
 from netbox.registry import registry
-from utilities.forms import CommentField, get_field_value
+from utilities.forms import get_field_value
+from utilities.forms.fields import CommentField
 from utilities.forms.widgets import HTMXSelect
 from utilities.forms.widgets import HTMXSelect
 
 
 __all__ = (
 __all__ = (

+ 2 - 1
netbox/dcim/forms/bulk_create.py

@@ -4,7 +4,8 @@ from dcim.models import *
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from extras.forms import CustomFieldsMixin
 from extras.forms import CustomFieldsMixin
 from extras.models import Tag
 from extras.models import Tag
-from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model
+from utilities.forms import BootstrapMixin, form_from_model
+from utilities.forms.fields import DynamicModelMultipleChoiceField, ExpandableNameField
 from .object_create import ComponentCreateForm
 from .object_create import ComponentCreateForm
 
 
 __all__ = (
 __all__ = (

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

@@ -10,10 +10,9 @@ from extras.models import ConfigTemplate
 from ipam.models import ASN, VLAN, VLANGroup, VRF
 from ipam.models import ASN, VLAN, VLANGroup, VRF
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms import (
-    add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
-    DynamicModelMultipleChoiceField, form_from_model, SelectSpeedWidget,
-)
+from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
+from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.widgets import BulkEditNullBooleanSelect, SelectSpeedWidget
 
 
 __all__ = (
 __all__ = (
     'CableBulkEditForm',
     'CableBulkEditForm',

+ 3 - 2
netbox/dcim/forms/bulk_import.py

@@ -12,8 +12,9 @@ from extras.models import ConfigTemplate
 from ipam.models import VRF
 from ipam.models import VRF
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms import (
-    CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField, CSVModelMultipleChoiceField
+from utilities.forms.fields import (
+    CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVTypedChoiceField,
+    SlugField,
 )
 )
 from virtualization.models import Cluster
 from virtualization.models import Cluster
 from wireless.choices import WirelessRoleChoices
 from wireless.choices import WirelessRoleChoices

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

@@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
-from utilities.forms.utils import get_field_value
+from utilities.forms import get_field_value
 
 
 __all__ = (
 __all__ = (
     'InterfaceCommonForm',
     'InterfaceCommonForm',

+ 2 - 2
netbox/dcim/forms/connections.py

@@ -1,9 +1,9 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from circuits.models import Circuit, CircuitTermination, Provider
+from circuits.models import Circuit, CircuitTermination
 from dcim.models import *
 from dcim.models import *
-from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from .model_forms import CableForm
 from .model_forms import CableForm
 
 
 
 

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

@@ -10,10 +10,9 @@ from extras.models import ConfigTemplate
 from ipam.models import ASN, L2VPN, VRF
 from ipam.models import ASN, L2VPN, VRF
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
-from utilities.forms import (
-    APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm,
-    TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget,
-)
+from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
+from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
+from utilities.forms.widgets import APISelectMultiple, SelectSpeedWidget
 from wireless.choices import *
 from wireless.choices import *
 
 
 __all__ = (
 __all__ = (

+ 5 - 4
netbox/dcim/forms/model_forms.py

@@ -11,11 +11,12 @@ from extras.models import ConfigTemplate
 from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
 from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
-from utilities.forms import (
-    add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
-    DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
+from utilities.forms import BootstrapMixin, add_blank_choice
+from utilities.forms.fields import (
+    CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
+    NumericArrayField, SlugField,
 )
 )
-from utilities.forms.widgets import APISelect, HTMXSelect, SelectSpeedWidget, SelectWithPK
+from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, SelectSpeedWidget, SelectWithPK
 from virtualization.models import Cluster
 from virtualization.models import Cluster
 from wireless.models import WirelessLAN, WirelessLANGroup
 from wireless.models import WirelessLAN, WirelessLANGroup
 from .common import InterfaceCommonForm, ModuleCommonForm
 from .common import InterfaceCommonForm, ModuleCommonForm

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

@@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
 
 
 from dcim.models import *
 from dcim.models import *
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
-from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
+from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
 from . import model_forms
 from . import model_forms
 
 
 __all__ = (
 __all__ = (

+ 2 - 2
netbox/dcim/tables/template_code.py

@@ -12,12 +12,12 @@ LINKTERMINATION = """
 
 
 CABLE_LENGTH = """
 CABLE_LENGTH = """
 {% load helpers %}
 {% load helpers %}
-{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
+{% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %}
 """
 """
 
 
 WEIGHT = """
 WEIGHT = """
 {% load helpers %}
 {% load helpers %}
-{% if value %}{{ value|simplify_decimal }} {{ record.weight_unit }}{% endif %}
+{% if value %}{{ value|floatformat:"-2" }} {{ record.weight_unit }}{% endif %}
 """
 """
 
 
 DEVICE_LINK = """
 DEVICE_LINK = """

+ 3 - 3
netbox/extras/forms/bulk_edit.py

@@ -3,9 +3,9 @@ from django.utils.translation import gettext as _
 
 
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
-from utilities.forms import (
-    add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
-)
+from utilities.forms import BulkEditForm, add_blank_choice
+from utilities.forms.fields import ColorField
+from utilities.forms.widgets import BulkEditNullBooleanSelect
 
 
 __all__ = (
 __all__ = (
     'ConfigContextBulkEditForm',
     'ConfigContextBulkEditForm',

+ 2 - 1
netbox/extras/forms/bulk_import.py

@@ -7,7 +7,8 @@ from django.utils.translation import gettext as _
 from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices
 from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices
 from extras.models import *
 from extras.models import *
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
-from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
+from utilities.forms import CSVModelForm
+from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
 
 
 __all__ = (
 __all__ = (
     'ConfigTemplateImportForm',
     'ConfigTemplateImportForm',

+ 3 - 4
netbox/extras/forms/filtersets.py

@@ -10,10 +10,9 @@ from extras.models import *
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
 from netbox.forms.base import NetBoxModelFilterSetForm
 from netbox.forms.base import NetBoxModelFilterSetForm
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from utilities.forms import (
-    add_blank_choice, APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeMultipleChoiceField, DateTimePicker,
-    DynamicModelMultipleChoiceField, FilterForm, TagFilterField,
-)
+from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
+from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField
+from utilities.forms.widgets import APISelectMultiple, DateTimePicker
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from .mixins import SavedFiltersMixin
 from .mixins import SavedFiltersMixin
 
 

+ 4 - 3
netbox/extras/forms/model_forms.py

@@ -12,9 +12,10 @@ from extras.models import *
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from utilities.forms import (
-    add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
-    DynamicModelMultipleChoiceField, JSONField, SlugField,
+from utilities.forms import BootstrapMixin, add_blank_choice
+from utilities.forms.fields import (
+    CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
+    SlugField,
 )
 )
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 

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

@@ -1,8 +1,8 @@
 from django import forms
 from django import forms
-from django.utils import timezone
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
+from utilities.forms import BootstrapMixin
+from utilities.forms.widgets import DateTimePicker, SelectDurationWidget
 from utilities.utils import local_now
 from utilities.utils import local_now
 
 
 __all__ = (
 __all__ = (

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

@@ -1,7 +1,8 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
+from utilities.forms import BootstrapMixin
+from utilities.forms.widgets import DateTimePicker, SelectDurationWidget
 from utilities.utils import local_now
 from utilities.utils import local_now
 
 
 __all__ = (
 __all__ = (

+ 2 - 1
netbox/extras/scripts.py

@@ -21,7 +21,8 @@ from extras.signals import clear_webhooks
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
 from utilities.exceptions import AbortScript, AbortTransaction
 from utilities.exceptions import AbortScript, AbortTransaction
-from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from .context_managers import change_logging
 from .context_managers import change_logging
 from .forms import ScriptForm
 from .forms import ScriptForm
 
 

+ 2 - 2
netbox/ipam/api/views.py

@@ -2,7 +2,7 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import transaction
 from django.db import transaction
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from django_pglocks import advisory_lock
 from django_pglocks import advisory_lock
-from drf_spectacular.utils import extend_schema, extend_schema_view
+from drf_spectacular.utils import extend_schema
 from rest_framework import status
 from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.routers import APIRootView
@@ -15,7 +15,7 @@ from ipam.models import *
 from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.api.viewsets.mixins import ObjectValidationMixin
 from netbox.api.viewsets.mixins import ObjectValidationMixin
 from netbox.config import get_config
 from netbox.config import get_config
-from utilities.constants import ADVISORY_LOCK_KEYS
+from netbox.constants import ADVISORY_LOCK_KEYS
 from utilities.utils import count_related
 from utilities.utils import count_related
 from . import serializers
 from . import serializers
 from ipam.models import L2VPN, L2VPNTermination
 from ipam.models import L2VPN, L2VPNTermination

+ 2 - 1
netbox/ipam/forms/bulk_create.py

@@ -1,7 +1,8 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from utilities.forms import BootstrapMixin, ExpandableIPAddressField
+from utilities.forms import BootstrapMixin
+from utilities.forms.fields import ExpandableIPAddressField
 
 
 __all__ = (
 __all__ = (
     'IPAddressBulkCreateForm',
     'IPAddressBulkCreateForm',

+ 4 - 3
netbox/ipam/forms/bulk_edit.py

@@ -8,10 +8,11 @@ from ipam.models import *
 from ipam.models import ASN
 from ipam.models import ASN
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms import (
-    add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-    NumericArrayField,
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import (
+    CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
 )
 )
+from utilities.forms.widgets import BulkEditNullBooleanSelect
 
 
 __all__ = (
 __all__ = (
     'AggregateBulkEditForm',
     'AggregateBulkEditForm',

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

@@ -9,7 +9,7 @@ from ipam.constants import *
 from ipam.models import *
 from ipam.models import *
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
+from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
 
 
 __all__ = (
 __all__ = (

+ 3 - 3
netbox/ipam/forms/filtersets.py

@@ -8,9 +8,9 @@ from ipam.constants import *
 from ipam.models import *
 from ipam.models import *
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
-from utilities.forms import (
-    add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-    TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
+from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
+from utilities.forms.fields import (
+    ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
 )
 )
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 
 

+ 5 - 3
netbox/ipam/forms/model_forms.py

@@ -11,10 +11,12 @@ from ipam.models import *
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from utilities.exceptions import PermissionsViolation
 from utilities.exceptions import PermissionsViolation
-from utilities.forms import (
-    add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
-    DynamicModelMultipleChoiceField, NumericArrayField, SlugField,
+from utilities.forms import BootstrapMixin, add_blank_choice
+from utilities.forms.fields import (
+    CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
+    SlugField,
 )
 )
+from utilities.forms.widgets import DatePicker
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
 
 
 __all__ = (
 __all__ = (

+ 11 - 0
netbox/netbox/constants.py

@@ -5,3 +5,14 @@ NESTED_SERIALIZER_PREFIX = 'Nested'
 RQ_QUEUE_DEFAULT = 'default'
 RQ_QUEUE_DEFAULT = 'default'
 RQ_QUEUE_HIGH = 'high'
 RQ_QUEUE_HIGH = 'high'
 RQ_QUEUE_LOW = 'low'
 RQ_QUEUE_LOW = 'low'
+
+# Keys for PostgreSQL advisory locks. These are arbitrary bigints used by the advisory_lock
+# context manager. When a lock is acquired, one of these keys will be used to identify said lock.
+# When adding a new key, pick something arbitrary and unique so that it is easily searchable in
+# query logs.
+ADVISORY_LOCK_KEYS = {
+    'available-prefixes': 100100,
+    'available-ips': 100200,
+    'available-vlans': 100300,
+    'available-asns': 100400,
+}

+ 1 - 1
netbox/netbox/filtersets.py

@@ -14,7 +14,7 @@ from utilities.constants import (
     FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
     FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
     FILTER_NUMERIC_BASED_LOOKUP_MAP
     FILTER_NUMERIC_BASED_LOOKUP_MAP
 )
 )
-from utilities.forms import MACAddressField
+from utilities.forms.fields import MACAddressField
 from utilities import filters
 from utilities import filters
 
 
 __all__ = (
 __all__ = (

+ 4 - 4
netbox/templates/dcim/interface.html

@@ -242,7 +242,7 @@
                   <th scope="row">Channel Frequency</th>
                   <th scope="row">Channel Frequency</th>
                   <td>
                   <td>
                     {% if object.rf_channel_frequency %}
                     {% if object.rf_channel_frequency %}
-                      {{ object.rf_channel_frequency|simplify_decimal }} MHz
+                      {{ object.rf_channel_frequency|floatformat:"-2" }} MHz
                     {% else %}
                     {% else %}
                       {{ ''|placeholder }}
                       {{ ''|placeholder }}
                     {% endif %}
                     {% endif %}
@@ -250,7 +250,7 @@
                   {% if peer %}
                   {% if peer %}
                     <td{% if peer.rf_channel_frequency != object.rf_channel_frequency %} class="text-danger"{% endif %}>
                     <td{% if peer.rf_channel_frequency != object.rf_channel_frequency %} class="text-danger"{% endif %}>
                       {% if peer.rf_channel_frequency %}
                       {% if peer.rf_channel_frequency %}
-                        {{ peer.rf_channel_frequency|simplify_decimal }} MHz
+                        {{ peer.rf_channel_frequency|floatformat:"-2" }} MHz
                       {% else %}
                       {% else %}
                         {{ ''|placeholder }}
                         {{ ''|placeholder }}
                       {% endif %}
                       {% endif %}
@@ -261,7 +261,7 @@
                   <th scope="row">Channel Width</th>
                   <th scope="row">Channel Width</th>
                   <td>
                   <td>
                     {% if object.rf_channel_width %}
                     {% if object.rf_channel_width %}
-                      {{ object.rf_channel_width|simplify_decimal }} MHz
+                      {{ object.rf_channel_width|floatformat:"-3" }} MHz
                     {% else %}
                     {% else %}
                       {{ ''|placeholder }}
                       {{ ''|placeholder }}
                     {% endif %}
                     {% endif %}
@@ -269,7 +269,7 @@
                   {% if peer %}
                   {% if peer %}
                     <td{% if peer.rf_channel_width != object.rf_channel_width %} class="text-danger"{% endif %}>
                     <td{% if peer.rf_channel_width != object.rf_channel_width %} class="text-danger"{% endif %}>
                       {% if peer.rf_channel_width %}
                       {% if peer.rf_channel_width %}
-                        {{ peer.rf_channel_width|simplify_decimal }} MHz
+                        {{ peer.rf_channel_width|floatformat:"-3" }} MHz
                       {% else %}
                       {% else %}
                         {{ ''|placeholder }}
                         {{ ''|placeholder }}
                       {% endif %}
                       {% endif %}

+ 2 - 2
netbox/templates/wireless/inc/wirelesslink_interface.html

@@ -31,7 +31,7 @@
       <th scope="row">Channel Frequency</th>
       <th scope="row">Channel Frequency</th>
       <td>
       <td>
         {% if interface.rf_channel_frequency %}
         {% if interface.rf_channel_frequency %}
-          {{ interface.rf_channel_frequency|simplify_decimal }} MHz
+          {{ interface.rf_channel_frequency|floatformat:"-2" }} MHz
         {% else %}
         {% else %}
           {{ ''|placeholder }}
           {{ ''|placeholder }}
         {% endif %}
         {% endif %}
@@ -41,7 +41,7 @@
       <th scope="row">Channel Width</th>
       <th scope="row">Channel Width</th>
       <td>
       <td>
         {% if interface.rf_channel_width %}
         {% if interface.rf_channel_width %}
-          {{ interface.rf_channel_width|simplify_decimal }} MHz
+          {{ interface.rf_channel_width|floatformat:"-3" }} MHz
         {% else %}
         {% else %}
           {{ ''|placeholder }}
           {{ ''|placeholder }}
         {% endif %}
         {% endif %}

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

@@ -3,7 +3,8 @@ from django import forms
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.choices import ContactPriorityChoices
 from tenancy.choices import ContactPriorityChoices
 from tenancy.models import *
 from tenancy.models import *
-from utilities.forms import CommentField, DynamicModelChoiceField, add_blank_choice
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import CommentField, DynamicModelChoiceField
 
 
 __all__ = (
 __all__ = (
     'ContactAssignmentBulkEditForm',
     'ContactAssignmentBulkEditForm',

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

@@ -1,7 +1,7 @@
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import *
 from tenancy.models import *
-from utilities.forms import CSVModelChoiceField, SlugField
+from utilities.forms.fields import CSVModelChoiceField, SlugField
 
 
 __all__ = (
 __all__ = (
     'ContactImportForm',
     'ContactImportForm',

+ 1 - 1
netbox/tenancy/forms/forms.py

@@ -2,7 +2,7 @@ from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from tenancy.models import *
 from tenancy.models import *
-from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 
 
 __all__ = (
 __all__ = (
     'ContactModelFilterForm',
     'ContactModelFilterForm',

+ 2 - 3
netbox/tenancy/forms/model_forms.py

@@ -2,9 +2,8 @@ from django import forms
 
 
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.models import *
 from tenancy.models import *
-from utilities.forms import (
-    BootstrapMixin, CommentField, DynamicModelChoiceField, SlugField,
-)
+from utilities.forms import BootstrapMixin
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField
 
 
 __all__ = (
 __all__ = (
     'ContactAssignmentForm',
     'ContactAssignmentForm',

+ 2 - 1
netbox/users/forms.py

@@ -7,7 +7,8 @@ from django.utils.translation import gettext as _
 
 
 from ipam.formfields import IPNetworkFormField
 from ipam.formfields import IPNetworkFormField
 from netbox.preferences import PREFERENCES
 from netbox.preferences import PREFERENCES
-from utilities.forms import BootstrapMixin, DateTimePicker
+from utilities.forms import BootstrapMixin
+from utilities.forms.widgets import DateTimePicker
 from utilities.utils import flatten_dict
 from utilities.utils import flatten_dict
 from .models import Token, UserConfig
 from .models import Token, UserConfig
 
 

+ 8 - 0
netbox/utilities/api.py

@@ -10,6 +10,14 @@ from rest_framework.utils import formatting
 from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound
 from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound
 from .utils import dynamic_import
 from .utils import dynamic_import
 
 
+__all__ = (
+    'get_graphql_type_for_model',
+    'get_serializer_for_model',
+    'get_view_name',
+    'is_api_request',
+    'rest_api_server_error',
+)
+
 
 
 def get_serializer_for_model(model, prefix=''):
 def get_serializer_for_model(model, prefix=''):
     """
     """

+ 0 - 15
netbox/utilities/constants.py

@@ -31,21 +31,6 @@ FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict(
     n='in'
     n='in'
 )
 )
 
 
-
-# Keys for PostgreSQL advisory locks. These are arbitrary bigints used by
-# the advisory_lock contextmanager. When a lock is acquired,
-# one of these keys will be used to identify said lock.
-#
-# When adding a new key, pick something arbitrary and unique so
-# that it is easily searchable in query logs.
-
-ADVISORY_LOCK_KEYS = {
-    'available-prefixes': 100100,
-    'available-ips': 100200,
-    'available-vlans': 100300,
-    'available-asns': 100400,
-}
-
 #
 #
 # HTTP Request META safe copy
 # HTTP Request META safe copy
 #
 #

+ 1 - 0
netbox/utilities/exceptions.py

@@ -3,6 +3,7 @@ from rest_framework.exceptions import APIException
 
 
 __all__ = (
 __all__ = (
     'AbortRequest',
     'AbortRequest',
+    'AbortScript',
     'AbortTransaction',
     'AbortTransaction',
     'PermissionsViolation',
     'PermissionsViolation',
     'RQWorkerNotRunningException',
     'RQWorkerNotRunningException',

+ 9 - 7
netbox/utilities/fields.py

@@ -1,21 +1,23 @@
 from collections import defaultdict
 from collections import defaultdict
 
 
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.core.validators import RegexValidator
 from django.db import models
 from django.db import models
 
 
 from utilities.ordering import naturalize
 from utilities.ordering import naturalize
-from .forms import ColorSelect
-
-ColorValidator = RegexValidator(
-    regex='^[0-9a-f]{6}$',
-    message='Enter a valid hexadecimal RGB color code.',
-    code='invalid'
+from .forms.widgets import ColorSelect
+from .validators import ColorValidator
+
+__all__ = (
+    'ColorField',
+    'NaturalOrderingField',
+    'NullableCharField',
+    'RestrictedGenericForeignKey',
 )
 )
 
 
 
 
 # Deprecated: Retained only to ensure successful migration from early releases
 # Deprecated: Retained only to ensure successful migration from early releases
 # Use models.CharField(null=True) instead
 # Use models.CharField(null=True) instead
+# TODO: Remove in v4.0
 class NullableCharField(models.CharField):
 class NullableCharField(models.CharField):
     description = "Stores empty values as NULL rather than ''"
     description = "Stores empty values as NULL rather than ''"
 
 

+ 4 - 0
netbox/utilities/files.py

@@ -1,5 +1,9 @@
 import hashlib
 import hashlib
 
 
+__all__ = (
+    'sha256_hash',
+)
+
 
 
 def sha256_hash(filepath):
 def sha256_hash(filepath):
     """
     """

+ 16 - 0
netbox/utilities/filters.py

@@ -6,6 +6,22 @@ from django_filters.constants import EMPTY_VALUES
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 
 
+__all__ = (
+    'ContentTypeFilter',
+    'MACAddressFilter',
+    'MultiValueCharFilter',
+    'MultiValueDateFilter',
+    'MultiValueDateTimeFilter',
+    'MultiValueDecimalFilter',
+    'MultiValueMACAddressFilter',
+    'MultiValueNumberFilter',
+    'MultiValueTimeFilter',
+    'MultiValueWWNFilter',
+    'NullableCharFieldFilter',
+    'NumericArrayFilter',
+    'TreeNodeMultipleChoiceFilter',
+)
+
 
 
 def multivalue_field_factory(field_class):
 def multivalue_field_factory(field_class):
     """
     """

+ 1 - 2
netbox/utilities/forms/__init__.py

@@ -1,5 +1,4 @@
 from .constants import *
 from .constants import *
-from .fields import *
 from .forms import *
 from .forms import *
+from .mixins import *
 from .utils import *
 from .utils import *
-from .widgets import *

+ 1 - 0
netbox/utilities/forms/fields/__init__.py

@@ -1,3 +1,4 @@
+from .array import *
 from .content_types import *
 from .content_types import *
 from .csv import *
 from .csv import *
 from .dynamic import *
 from .dynamic import *

+ 24 - 0
netbox/utilities/forms/fields/array.py

@@ -0,0 +1,24 @@
+from django import forms
+from django.contrib.postgres.forms import SimpleArrayField
+
+from ..utils import parse_numeric_range
+
+__all__ = (
+    'NumericArrayField',
+)
+
+
+class NumericArrayField(SimpleArrayField):
+
+    def clean(self, value):
+        if value and not self.to_python(value):
+            raise forms.ValidationError(f'Invalid list ({value}). '
+                                        f'Must be numeric and ranges must be in ascending order')
+        return super().clean(value)
+
+    def to_python(self, value):
+        if not value:
+            return []
+        if isinstance(value, str):
+            value = ','.join([str(n) for n in parse_numeric_range(value)])
+        return super().to_python(value)

+ 0 - 1
netbox/utilities/forms/fields/content_types.py

@@ -1,6 +1,5 @@
 from django import forms
 from django import forms
 
 
-from utilities.forms import widgets
 from utilities.utils import content_type_name
 from utilities.utils import content_type_name
 
 
 __all__ = (
 __all__ = (

+ 0 - 5
netbox/utilities/forms/fields/csv.py

@@ -1,14 +1,9 @@
-import csv
-from io import StringIO
-
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
 from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
 from django.db.models import Q
 from django.db.models import Q
-from django.utils.translation import gettext as _
 
 
 from utilities.choices import unpack_grouped_choices
 from utilities.choices import unpack_grouped_choices
-from utilities.forms.utils import parse_csv, validate_csv
 from utilities.utils import content_type_identifier
 from utilities.utils import content_type_identifier
 
 
 __all__ = (
 __all__ = (

+ 12 - 77
netbox/utilities/forms/forms.py

@@ -2,96 +2,31 @@ import re
 
 
 from django import forms
 from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
-
-from .widgets import APISelect, APISelectMultiple, ClearableFileInput
+from .mixins import BootstrapMixin
 
 
 __all__ = (
 __all__ = (
-    'BootstrapMixin',
     'BulkEditForm',
     'BulkEditForm',
     'BulkRenameForm',
     'BulkRenameForm',
     'ConfirmationForm',
     'ConfirmationForm',
     'CSVModelForm',
     'CSVModelForm',
     'FilterForm',
     'FilterForm',
-    'ReturnURLForm',
     'TableConfigForm',
     'TableConfigForm',
 )
 )
 
 
 
 
-#
-# Mixins
-#
-
-class BootstrapMixin:
-    """
-    Add the base Bootstrap CSS classes to form elements.
-    """
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        exempt_widgets = [
-            forms.FileInput,
-            forms.RadioSelect,
-            APISelect,
-            APISelectMultiple,
-            ClearableFileInput,
-        ]
-
-        for field_name, field in self.fields.items():
-            css = field.widget.attrs.get('class', '')
-
-            if field.widget.__class__ in exempt_widgets:
-                continue
-
-            elif isinstance(field.widget, forms.CheckboxInput):
-                field.widget.attrs['class'] = f'{css} form-check-input'
-
-            elif isinstance(field.widget, forms.SelectMultiple):
-                if 'size' not in field.widget.attrs:
-                    field.widget.attrs['class'] = f'{css} netbox-static-select'
-
-            elif isinstance(field.widget, forms.Select):
-                field.widget.attrs['class'] = f'{css} netbox-static-select'
-
-            else:
-                field.widget.attrs['class'] = f'{css} form-control'
-
-            if field.required and not isinstance(field.widget, forms.FileInput):
-                field.widget.attrs['required'] = 'required'
-
-            if 'placeholder' not in field.widget.attrs and field.label is not None:
-                field.widget.attrs['placeholder'] = field.label
-
-    def is_valid(self):
-        is_valid = super().is_valid()
-
-        # Apply is-invalid CSS class to fields with errors
-        if not is_valid:
-            for field_name in self.errors:
-                # Ignore e.g. __all__
-                if field := self.fields.get(field_name):
-                    css = field.widget.attrs.get('class', '')
-                    field.widget.attrs['class'] = f'{css} is-invalid'
-
-        return is_valid
-
-
-#
-# Form classes
-#
-
-class ReturnURLForm(forms.Form):
+class ConfirmationForm(BootstrapMixin, forms.Form):
     """
     """
-    Provides a hidden return URL field to control where the user is directed after the form is submitted.
+    A generic confirmation form. The form is not valid unless the `confirm` field is checked.
     """
     """
-    return_url = forms.CharField(required=False, widget=forms.HiddenInput())
-
-
-class ConfirmationForm(BootstrapMixin, ReturnURLForm):
-    """
-    A generic confirmation form. The form is not valid unless the confirm field is checked.
-    """
-    confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
+    return_url = forms.CharField(
+        required=False,
+        widget=forms.HiddenInput()
+    )
+    confirm = forms.BooleanField(
+        required=True,
+        widget=forms.HiddenInput(),
+        initial=True
+    )
 
 
 
 
 class BulkEditForm(BootstrapMixin, forms.Form):
 class BulkEditForm(BootstrapMixin, forms.Form):

+ 62 - 0
netbox/utilities/forms/mixins.py

@@ -0,0 +1,62 @@
+from django import forms
+
+from .widgets import APISelect, APISelectMultiple, ClearableFileInput
+
+__all__ = (
+    'BootstrapMixin',
+)
+
+
+class BootstrapMixin:
+    """
+    Add the base Bootstrap CSS classes to form elements.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        exempt_widgets = [
+            forms.FileInput,
+            forms.RadioSelect,
+            APISelect,
+            APISelectMultiple,
+            ClearableFileInput,
+        ]
+
+        for field_name, field in self.fields.items():
+            css = field.widget.attrs.get('class', '')
+
+            if field.widget.__class__ in exempt_widgets:
+                continue
+
+            elif isinstance(field.widget, forms.CheckboxInput):
+                field.widget.attrs['class'] = f'{css} form-check-input'
+
+            elif isinstance(field.widget, forms.SelectMultiple):
+                if 'size' not in field.widget.attrs:
+                    field.widget.attrs['class'] = f'{css} netbox-static-select'
+
+            elif isinstance(field.widget, forms.Select):
+                field.widget.attrs['class'] = f'{css} netbox-static-select'
+
+            else:
+                field.widget.attrs['class'] = f'{css} form-control'
+
+            if field.required and not isinstance(field.widget, forms.FileInput):
+                field.widget.attrs['required'] = 'required'
+
+            if 'placeholder' not in field.widget.attrs and field.label is not None:
+                field.widget.attrs['placeholder'] = field.label
+
+    def is_valid(self):
+        is_valid = super().is_valid()
+
+        # Apply is-invalid CSS class to fields with errors
+        if not is_valid:
+            for field_name in self.errors:
+                # Ignore e.g. __all__
+                if field := self.fields.get(field_name):
+                    css = field.widget.attrs.get('class', '')
+                    field.widget.attrs['class'] = f'{css} is-invalid'
+
+        return is_valid

+ 4 - 0
netbox/utilities/forms/widgets/__init__.py

@@ -0,0 +1,4 @@
+from .apiselect import *
+from .datetime import *
+from .misc import *
+from .select import *

+ 8 - 160
netbox/utilities/forms/widgets.py → netbox/utilities/forms/widgets/apiselect.py

@@ -1,120 +1,14 @@
 import json
 import json
-from typing import Dict, Sequence, List, Tuple, Union
+from typing import Dict, List, Tuple
 
 
 from django import forms
 from django import forms
 from django.conf import settings
 from django.conf import settings
-from django.contrib.postgres.forms import SimpleArrayField
-
-from utilities.choices import ColorChoices
-from .utils import add_blank_choice, parse_numeric_range
 
 
 __all__ = (
 __all__ = (
     'APISelect',
     'APISelect',
     'APISelectMultiple',
     'APISelectMultiple',
-    'BulkEditNullBooleanSelect',
-    'ClearableFileInput',
-    'ColorSelect',
-    'DatePicker',
-    'DateTimePicker',
-    'HTMXSelect',
-    'MarkdownWidget',
-    'NumericArrayField',
-    'SelectDurationWidget',
-    'SelectSpeedWidget',
-    'SelectWithPK',
-    'SlugWidget',
-    'TimePicker',
 )
 )
 
 
-JSONPrimitive = Union[str, bool, int, float, None]
-QueryParamValue = Union[JSONPrimitive, Sequence[JSONPrimitive]]
-QueryParam = Dict[str, QueryParamValue]
-ProcessedParams = Sequence[Dict[str, Sequence[JSONPrimitive]]]
-
-
-class SlugWidget(forms.TextInput):
-    """
-    Subclass TextInput and add a slug regeneration button next to the form field.
-    """
-    template_name = 'widgets/sluginput.html'
-
-
-class ColorSelect(forms.Select):
-    """
-    Extends the built-in Select widget to colorize each <option>.
-    """
-    option_template_name = 'widgets/colorselect_option.html'
-
-    def __init__(self, *args, **kwargs):
-        kwargs['choices'] = add_blank_choice(ColorChoices)
-        super().__init__(*args, **kwargs)
-        self.attrs['class'] = 'netbox-color-select'
-
-
-class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
-    """
-    A Select widget for NullBooleanFields
-    """
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Override the built-in choice labels
-        self.choices = (
-            ('1', '---------'),
-            ('2', 'Yes'),
-            ('3', 'No'),
-        )
-        self.attrs['class'] = 'netbox-static-select'
-
-
-class SelectWithPK(forms.Select):
-    """
-    Include the primary key of each option in the option label (e.g. "Router7 (4721)").
-    """
-    option_template_name = 'widgets/select_option_with_pk.html'
-
-
-class SelectSpeedWidget(forms.NumberInput):
-    """
-    Speed field with dropdown selections for convenience.
-    """
-    template_name = 'widgets/select_speed.html'
-
-
-class SelectDurationWidget(forms.NumberInput):
-    """
-    Dropdown to select one of several common options for a time duration (in minutes).
-    """
-    template_name = 'widgets/select_duration.html'
-
-
-class MarkdownWidget(forms.Textarea):
-    template_name = 'widgets/markdown_input.html'
-
-
-class NumericArrayField(SimpleArrayField):
-
-    def clean(self, value):
-        if value and not self.to_python(value):
-            raise forms.ValidationError(f'Invalid list ({value}). '
-                                        f'Must be numeric and ranges must be in ascending order')
-        return super().clean(value)
-
-    def to_python(self, value):
-        if not value:
-            return []
-        if isinstance(value, str):
-            value = ','.join([str(n) for n in parse_numeric_range(value)])
-        return super().to_python(value)
-
-
-class ClearableFileInput(forms.ClearableFileInput):
-    """
-    Override Django's stock ClearableFileInput with a custom template.
-    """
-    template_name = 'widgets/clearable_file_input.html'
-
 
 
 class APISelect(forms.Select):
 class APISelect(forms.Select):
     """
     """
@@ -144,7 +38,7 @@ class APISelect(forms.Select):
         result.static_params = {}
         result.static_params = {}
         return result
         return result
 
 
-    def _process_query_param(self, key: str, value: JSONPrimitive) -> None:
+    def _process_query_param(self, key, value) -> None:
         """
         """
         Based on query param value's type and value, update instance's dynamic/static params.
         Based on query param value's type and value, update instance's dynamic/static params.
         """
         """
@@ -187,7 +81,7 @@ class APISelect(forms.Select):
             else:
             else:
                 self.static_params[key] = [value]
                 self.static_params[key] = [value]
 
 
-    def _process_query_params(self, query_params: QueryParam) -> None:
+    def _process_query_params(self, query_params):
         """
         """
         Process an entire query_params dictionary, and handle primitive or list values.
         Process an entire query_params dictionary, and handle primitive or list values.
         """
         """
@@ -199,7 +93,7 @@ class APISelect(forms.Select):
             else:
             else:
                 self._process_query_param(key, value)
                 self._process_query_param(key, value)
 
 
-    def _serialize_params(self, key: str, params: ProcessedParams) -> None:
+    def _serialize_params(self, key, params):
         """
         """
         Serialize dynamic or static query params to JSON and add the serialized value to
         Serialize dynamic or static query params to JSON and add the serialized value to
         the widget attributes by `key`.
         the widget attributes by `key`.
@@ -214,7 +108,7 @@ class APISelect(forms.Select):
         # attributes to HTML elements and parsed on the client.
         # attributes to HTML elements and parsed on the client.
         self.attrs[key] = json.dumps([*current, *params], separators=(',', ':'))
         self.attrs[key] = json.dumps([*current, *params], separators=(',', ':'))
 
 
-    def _add_dynamic_params(self) -> None:
+    def _add_dynamic_params(self):
         """
         """
         Convert post-processed dynamic query params to data structure expected by front-
         Convert post-processed dynamic query params to data structure expected by front-
         end, serialize the value to JSON, and add it to the widget attributes.
         end, serialize the value to JSON, and add it to the widget attributes.
@@ -227,7 +121,7 @@ class APISelect(forms.Select):
             except IndexError as error:
             except IndexError as error:
                 raise RuntimeError(f"Missing required value for dynamic query param: '{self.dynamic_params}'") from error
                 raise RuntimeError(f"Missing required value for dynamic query param: '{self.dynamic_params}'") from error
 
 
-    def _add_static_params(self) -> None:
+    def _add_static_params(self):
         """
         """
         Convert post-processed static query params to data structure expected by front-
         Convert post-processed static query params to data structure expected by front-
         end, serialize the value to JSON, and add it to the widget attributes.
         end, serialize the value to JSON, and add it to the widget attributes.
@@ -240,7 +134,7 @@ class APISelect(forms.Select):
             except IndexError as error:
             except IndexError as error:
                 raise RuntimeError(f"Missing required value for static query param: '{self.static_params}'") from error
                 raise RuntimeError(f"Missing required value for static query param: '{self.static_params}'") from error
 
 
-    def add_query_params(self, query_params: QueryParam) -> None:
+    def add_query_params(self, query_params):
         """
         """
         Proccess & add a dictionary of URL query parameters to the widget attributes.
         Proccess & add a dictionary of URL query parameters to the widget attributes.
         """
         """
@@ -251,7 +145,7 @@ class APISelect(forms.Select):
         # Add processed static parameters to widget attributes.
         # Add processed static parameters to widget attributes.
         self._add_static_params()
         self._add_static_params()
 
 
-    def add_query_param(self, key: str, value: QueryParamValue) -> None:
+    def add_query_param(self, key, value) -> None:
         """
         """
         Process & add a key/value pair of URL query parameters to the widget attributes.
         Process & add a key/value pair of URL query parameters to the widget attributes.
         """
         """
@@ -264,49 +158,3 @@ class APISelectMultiple(APISelect, forms.SelectMultiple):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         self.attrs['data-multiple'] = 1
         self.attrs['data-multiple'] = 1
-
-
-class DatePicker(forms.TextInput):
-    """
-    Date picker using Flatpickr.
-    """
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.attrs['class'] = 'date-picker'
-        self.attrs['placeholder'] = 'YYYY-MM-DD'
-
-
-class DateTimePicker(forms.TextInput):
-    """
-    DateTime picker using Flatpickr.
-    """
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.attrs['class'] = 'datetime-picker'
-        self.attrs['placeholder'] = 'YYYY-MM-DD hh:mm:ss'
-
-
-class TimePicker(forms.TextInput):
-    """
-    Time picker using Flatpickr.
-    """
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.attrs['class'] = 'time-picker'
-        self.attrs['placeholder'] = 'hh:mm:ss'
-
-
-class HTMXSelect(forms.Select):
-    """
-    Selection widget that will re-generate the HTML form upon the selection of a new option.
-    """
-    def __init__(self, hx_url='.', hx_target_id='form_fields', attrs=None, **kwargs):
-        _attrs = {
-            'hx-get': hx_url,
-            'hx-include': f'#{hx_target_id}',
-            'hx-target': f'#{hx_target_id}',
-        }
-        if attrs:
-            _attrs.update(attrs)
-
-        super().__init__(attrs=_attrs, **kwargs)

+ 37 - 0
netbox/utilities/forms/widgets/datetime.py

@@ -0,0 +1,37 @@
+from django import forms
+
+__all__ = (
+    'DatePicker',
+    'DateTimePicker',
+    'TimePicker',
+)
+
+
+class DatePicker(forms.TextInput):
+    """
+    Date picker using Flatpickr.
+    """
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.attrs['class'] = 'date-picker'
+        self.attrs['placeholder'] = 'YYYY-MM-DD'
+
+
+class DateTimePicker(forms.TextInput):
+    """
+    DateTime picker using Flatpickr.
+    """
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.attrs['class'] = 'datetime-picker'
+        self.attrs['placeholder'] = 'YYYY-MM-DD hh:mm:ss'
+
+
+class TimePicker(forms.TextInput):
+    """
+    Time picker using Flatpickr.
+    """
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.attrs['class'] = 'time-picker'
+        self.attrs['placeholder'] = 'hh:mm:ss'

+ 28 - 0
netbox/utilities/forms/widgets/misc.py

@@ -0,0 +1,28 @@
+from django import forms
+
+__all__ = (
+    'ClearableFileInput',
+    'MarkdownWidget',
+    'SlugWidget',
+)
+
+
+class ClearableFileInput(forms.ClearableFileInput):
+    """
+    Override Django's stock ClearableFileInput with a custom template.
+    """
+    template_name = 'widgets/clearable_file_input.html'
+
+
+class MarkdownWidget(forms.Textarea):
+    """
+    Provide a live preview for Markdown-formatted content.
+    """
+    template_name = 'widgets/markdown_input.html'
+
+
+class SlugWidget(forms.TextInput):
+    """
+    Subclass TextInput and add a slug regeneration button next to the form field.
+    """
+    template_name = 'widgets/sluginput.html'

+ 79 - 0
netbox/utilities/forms/widgets/select.py

@@ -0,0 +1,79 @@
+from django import forms
+
+from utilities.choices import ColorChoices
+from ..utils import add_blank_choice
+
+__all__ = (
+    'BulkEditNullBooleanSelect',
+    'ColorSelect',
+    'HTMXSelect',
+    'SelectDurationWidget',
+    'SelectSpeedWidget',
+    'SelectWithPK',
+)
+
+
+class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
+    """
+    A Select widget for NullBooleanFields
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Override the built-in choice labels
+        self.choices = (
+            ('1', '---------'),
+            ('2', 'Yes'),
+            ('3', 'No'),
+        )
+        self.attrs['class'] = 'netbox-static-select'
+
+
+class ColorSelect(forms.Select):
+    """
+    Extends the built-in Select widget to colorize each <option>.
+    """
+    option_template_name = 'widgets/colorselect_option.html'
+
+    def __init__(self, *args, **kwargs):
+        kwargs['choices'] = add_blank_choice(ColorChoices)
+        super().__init__(*args, **kwargs)
+        self.attrs['class'] = 'netbox-color-select'
+
+
+class HTMXSelect(forms.Select):
+    """
+    Selection widget that will re-generate the HTML form upon the selection of a new option.
+    """
+    def __init__(self, hx_url='.', hx_target_id='form_fields', attrs=None, **kwargs):
+        _attrs = {
+            'hx-get': hx_url,
+            'hx-include': f'#{hx_target_id}',
+            'hx-target': f'#{hx_target_id}',
+        }
+        if attrs:
+            _attrs.update(attrs)
+
+        super().__init__(attrs=_attrs, **kwargs)
+
+
+class SelectWithPK(forms.Select):
+    """
+    Include the primary key of each option in the option label (e.g. "Router7 (4721)").
+    """
+    option_template_name = 'widgets/select_option_with_pk.html'
+
+
+class SelectDurationWidget(forms.NumberInput):
+    """
+    Dropdown to select one of several common options for a time duration (in minutes).
+    """
+    template_name = 'widgets/select_duration.html'
+
+
+class SelectSpeedWidget(forms.NumberInput):
+    """
+    Speed field with dropdown selections for convenience.
+    """
+    template_name = 'widgets/select_speed.html'

+ 6 - 3
netbox/utilities/graphql_optimizer.py

@@ -1,20 +1,23 @@
 import functools
 import functools
 
 
-import graphql
 from django.core.exceptions import FieldDoesNotExist
 from django.core.exceptions import FieldDoesNotExist
-from django.db.models import ForeignKey, Prefetch
+from django.db.models import ForeignKey
 from django.db.models.constants import LOOKUP_SEP
 from django.db.models.constants import LOOKUP_SEP
 from django.db.models.fields.reverse_related import ManyToOneRel
 from django.db.models.fields.reverse_related import ManyToOneRel
 from graphene import InputObjectType
 from graphene import InputObjectType
 from graphene.types.generic import GenericScalar
 from graphene.types.generic import GenericScalar
 from graphene.types.resolver import default_resolver
 from graphene.types.resolver import default_resolver
 from graphene_django import DjangoObjectType
 from graphene_django import DjangoObjectType
-from graphql import FieldNode, GraphQLObjectType, GraphQLResolveInfo, GraphQLSchema
+from graphql import GraphQLResolveInfo, GraphQLSchema
 from graphql.execution.execute import get_field_def
 from graphql.execution.execute import get_field_def
 from graphql.language.ast import FragmentSpreadNode, InlineFragmentNode, VariableNode
 from graphql.language.ast import FragmentSpreadNode, InlineFragmentNode, VariableNode
 from graphql.pyutils import Path
 from graphql.pyutils import Path
 from graphql.type.definition import GraphQLInterfaceType, GraphQLUnionType
 from graphql.type.definition import GraphQLInterfaceType, GraphQLUnionType
 
 
+__all__ = (
+    'gql_query_optimizer',
+)
+
 
 
 def gql_query_optimizer(queryset, info, **options):
 def gql_query_optimizer(queryset, info, **options):
     return QueryOptimizer(info).optimize(queryset)
     return QueryOptimizer(info).optimize(queryset)

+ 5 - 0
netbox/utilities/htmx.py

@@ -1,5 +1,10 @@
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
+__all__ = (
+    'is_embedded',
+    'is_htmx',
+)
+
 
 
 def is_htmx(request):
 def is_htmx(request):
     """
     """

+ 4 - 0
netbox/utilities/markdown.py

@@ -1,6 +1,10 @@
 import markdown
 import markdown
 from markdown.inlinepatterns import SimpleTagPattern
 from markdown.inlinepatterns import SimpleTagPattern
 
 
+__all__ = (
+    'StrikethroughExtension',
+)
+
 STRIKE_RE = r'(~{2})(.+?)(~{2})'
 STRIKE_RE = r'(~{2})(.+?)(~{2})'
 
 
 
 

+ 4 - 0
netbox/utilities/migration.py

@@ -3,6 +3,10 @@ from timezone_field import TimeZoneField
 
 
 from netbox.config import ConfigItem
 from netbox.config import ConfigItem
 
 
+__all__ = (
+    'custom_deconstruct',
+)
+
 
 
 SKIP_FIELDS = (
 SKIP_FIELDS = (
     TimeZoneField,
     TimeZoneField,

+ 5 - 0
netbox/utilities/mptt.py

@@ -4,6 +4,11 @@ from mptt.querysets import TreeQuerySet as TreeQuerySet_
 from django.db.models import Manager
 from django.db.models import Manager
 from .querysets import RestrictedQuerySet
 from .querysets import RestrictedQuerySet
 
 
+__all__ = (
+    'TreeManager',
+    'TreeQuerySet',
+)
+
 
 
 class TreeQuerySet(TreeQuerySet_, RestrictedQuerySet):
 class TreeQuerySet(TreeQuerySet_, RestrictedQuerySet):
     """
     """

+ 5 - 0
netbox/utilities/ordering.py

@@ -1,5 +1,10 @@
 import re
 import re
 
 
+__all__ = (
+    'naturalize',
+    'naturalize_interface',
+)
+
 INTERFACE_NAME_REGEX = r'(^(?P<type>[^\d\.:]+)?)' \
 INTERFACE_NAME_REGEX = r'(^(?P<type>[^\d\.:]+)?)' \
                        r'((?P<slot>\d+)/)?' \
                        r'((?P<slot>\d+)/)?' \
                        r'((?P<subslot>\d+)/)?' \
                        r'((?P<subslot>\d+)/)?' \

+ 6 - 0
netbox/utilities/paginator.py

@@ -2,6 +2,12 @@ from django.core.paginator import Paginator, Page
 
 
 from netbox.config import get_config
 from netbox.config import get_config
 
 
+__all__ = (
+    'EnhancedPage',
+    'EnhancedPaginator',
+    'get_paginate_count',
+)
+
 
 
 class EnhancedPaginator(Paginator):
 class EnhancedPaginator(Paginator):
     default_page_lengths = (
     default_page_lengths = (

+ 6 - 1
netbox/utilities/query_functions.py

@@ -1,5 +1,10 @@
 from django.contrib.postgres.aggregates import JSONBAgg
 from django.contrib.postgres.aggregates import JSONBAgg
-from django.db.models import F, Func
+from django.db.models import Func
+
+__all__ = (
+    'CollateAsChar',
+    'EmptyGroupByJSONBAgg',
+)
 
 
 
 
 class CollateAsChar(Func):
 class CollateAsChar(Func):

+ 5 - 0
netbox/utilities/querysets.py

@@ -3,6 +3,11 @@ from django.db.models import Prefetch, QuerySet
 from users.constants import CONSTRAINT_TOKEN_USER
 from users.constants import CONSTRAINT_TOKEN_USER
 from utilities.permissions import permission_is_exempt, qs_filter_from_constraints
 from utilities.permissions import permission_is_exempt, qs_filter_from_constraints
 
 
+__all__ = (
+    'RestrictedPrefetch',
+    'RestrictedQuerySet',
+)
+
 
 
 class RestrictedPrefetch(Prefetch):
 class RestrictedPrefetch(Prefetch):
     """
     """

+ 5 - 0
netbox/utilities/tables.py

@@ -1,3 +1,8 @@
+__all__ = (
+    'linkify_phone',
+)
+
+
 def linkify_phone(value):
 def linkify_phone(value):
     """
     """
     Render a telephone number as a hyperlink.
     Render a telephone number as a hyperlink.

+ 15 - 0
netbox/utilities/templatetags/builtins/filters.py

@@ -13,6 +13,21 @@ from netbox.config import get_config
 from utilities.markdown import StrikethroughExtension
 from utilities.markdown import StrikethroughExtension
 from utilities.utils import clean_html, foreground_color, title
 from utilities.utils import clean_html, foreground_color, title
 
 
+__all__ = (
+    'bettertitle',
+    'content_type',
+    'content_type_id',
+    'fgcolor',
+    'linkify',
+    'meta',
+    'placeholder',
+    'render_json',
+    'render_markdown',
+    'render_yaml',
+    'split',
+    'tzoffset',
+)
+
 register = template.Library()
 register = template.Library()
 
 
 
 

+ 7 - 0
netbox/utilities/templatetags/builtins/tags.py

@@ -1,5 +1,12 @@
 from django import template
 from django import template
 
 
+__all__ = (
+    'badge',
+    'checkmark',
+    'customfield_value',
+    'tag',
+)
+
 register = template.Library()
 register = template.Library()
 
 
 
 

+ 12 - 0
netbox/utilities/templatetags/buttons.py

@@ -5,6 +5,18 @@ from django.urls import NoReverseMatch, reverse
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
 from utilities.utils import get_viewname, prepare_cloned_fields
 from utilities.utils import get_viewname, prepare_cloned_fields
 
 
+__all__ = (
+    'add_button',
+    'bulk_delete_button',
+    'bulk_edit_button',
+    'clone_button',
+    'delete_button',
+    'edit_button',
+    'export_button',
+    'import_button',
+    'sync_button',
+)
+
 register = template.Library()
 register = template.Library()
 
 
 
 

+ 9 - 0
netbox/utilities/templatetags/form_helpers.py

@@ -1,5 +1,14 @@
 from django import template
 from django import template
 
 
+__all__ = (
+    'getfield',
+    'render_custom_fields',
+    'render_errors',
+    'render_field',
+    'render_form',
+    'widget_type',
+)
+
 
 
 register = template.Library()
 register = template.Library()
 
 

+ 23 - 22
netbox/utilities/templatetags/helpers.py

@@ -1,5 +1,4 @@
 import datetime
 import datetime
-import decimal
 import json
 import json
 from urllib.parse import quote
 from urllib.parse import quote
 from typing import Dict, Any
 from typing import Dict, Any
@@ -15,6 +14,29 @@ from django.utils.safestring import mark_safe
 from utilities.forms import get_selected_values, TableConfigForm
 from utilities.forms import get_selected_values, TableConfigForm
 from utilities.utils import get_viewname
 from utilities.utils import get_viewname
 
 
+__all__ = (
+    'annotated_date',
+    'annotated_now',
+    'applied_filters',
+    'as_range',
+    'divide',
+    'get_item',
+    'get_key',
+    'humanize_megabytes',
+    'humanize_speed',
+    'icon_from_status',
+    'kg_to_pounds',
+    'meters_to_feet',
+    'percentage',
+    'querystring',
+    'startswith',
+    'status_from_tag',
+    'table_config_form',
+    'utilization_graph',
+    'validated_viewname',
+    'viewname',
+)
+
 register = template.Library()
 register = template.Library()
 
 
 
 
@@ -83,19 +105,6 @@ def humanize_megabytes(mb):
     return f'{mb} MB'
     return f'{mb} MB'
 
 
 
 
-@register.filter()
-def simplify_decimal(value):
-    """
-    Return the simplest expression of a decimal value. Examples:
-      1.00 => '1'
-      1.20 => '1.2'
-      1.23 => '1.23'
-    """
-    if type(value) is not decimal.Decimal:
-        return value
-    return str(value).rstrip('0').rstrip('.')
-
-
 @register.filter(expects_localtime=True)
 @register.filter(expects_localtime=True)
 def annotated_date(date_value):
 def annotated_date(date_value):
     """
     """
@@ -145,14 +154,6 @@ def percentage(x, y):
     return round(x / y * 100, 1)
     return round(x / y * 100, 1)
 
 
 
 
-@register.filter()
-def has_perms(user, permissions_list):
-    """
-    Return True if the user has *all* permissions in the list.
-    """
-    return user.has_perms(permissions_list)
-
-
 @register.filter()
 @register.filter()
 def as_range(n):
 def as_range(n):
     """
     """

+ 4 - 0
netbox/utilities/templatetags/navigation.py

@@ -4,6 +4,10 @@ from django.template import Context
 
 
 from netbox.navigation.menu import MENUS
 from netbox.navigation.menu import MENUS
 
 
+__all__ = (
+    'nav',
+)
+
 
 
 register = template.Library()
 register = template.Library()
 
 

+ 8 - 0
netbox/utilities/templatetags/perms.py

@@ -1,5 +1,13 @@
 from django import template
 from django import template
 
 
+__all__ = (
+    'can_add',
+    'can_change',
+    'can_delete',
+    'can_sync',
+    'can_view',
+)
+
 register = template.Library()
 register = template.Library()
 
 
 
 

+ 4 - 0
netbox/utilities/templatetags/tabs.py

@@ -6,6 +6,10 @@ from django.utils.module_loading import import_string
 from netbox.registry import registry
 from netbox.registry import registry
 from utilities.utils import get_viewname
 from utilities.utils import get_viewname
 
 
+__all__ = (
+    'model_view_tabs',
+)
+
 register = template.Library()
 register = template.Library()
 
 
 
 

+ 4 - 0
netbox/utilities/urls.py

@@ -4,6 +4,10 @@ from django.views.generic import View
 
 
 from netbox.registry import registry
 from netbox.registry import registry
 
 
+__all__ = (
+    'get_model_urls',
+)
+
 
 
 def get_model_urls(app_label, model_name):
 def get_model_urls(app_label, model_name):
     """
     """

+ 15 - 1
netbox/utilities/validators.py

@@ -1,10 +1,24 @@
 import re
 import re
 
 
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
-from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
+from django.core.validators import BaseValidator, RegexValidator, URLValidator, _lazy_re_compile
 
 
 from netbox.config import get_config
 from netbox.config import get_config
 
 
+__all__ = (
+    'ColorValidator',
+    'EnhancedURLValidator',
+    'ExclusionValidator',
+    'validate_regex',
+)
+
+
+ColorValidator = RegexValidator(
+    regex='^[0-9a-f]{6}$',
+    message='Enter a valid hexadecimal RGB color code.',
+    code='invalid'
+)
+
 
 
 class EnhancedURLValidator(URLValidator):
 class EnhancedURLValidator(URLValidator):
     """
     """

+ 2 - 1
netbox/virtualization/forms/bulk_create.py

@@ -1,7 +1,8 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from utilities.forms import BootstrapMixin, ExpandableNameField, form_from_model
+from utilities.forms import BootstrapMixin, form_from_model
+from utilities.forms.fields import ExpandableNameField
 from virtualization.models import VMInterface, VirtualMachine
 from virtualization.models import VMInterface, VirtualMachine
 
 
 __all__ = (
 __all__ = (

+ 3 - 4
netbox/virtualization/forms/bulk_edit.py

@@ -7,10 +7,9 @@ from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from ipam.models import VLAN, VLANGroup, VRF
 from ipam.models import VLAN, VLANGroup, VRF
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms import (
-    add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField,
-    DynamicModelMultipleChoiceField
-)
+from utilities.forms import BulkRenameForm, add_blank_choice
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.widgets import BulkEditNullBooleanSelect
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import *
 from virtualization.models import *
 
 

+ 3 - 2
netbox/virtualization/forms/bulk_import.py

@@ -1,10 +1,11 @@
+from django.utils.translation import gettext as _
+
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from dcim.models import Device, DeviceRole, Platform, Site
 from dcim.models import Device, DeviceRole, Platform, Site
-from django.utils.translation import gettext as _
 from ipam.models import VRF
 from ipam.models import VRF
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
+from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import *
 from virtualization.models import *
 
 

+ 2 - 3
netbox/virtualization/forms/filtersets.py

@@ -6,9 +6,8 @@ from extras.forms import LocalConfigContextFilterForm
 from ipam.models import L2VPN, VRF
 from ipam.models import L2VPN, VRF
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
-from utilities.forms import (
-    DynamicModelMultipleChoiceField, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
-)
+from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
+from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import *
 from virtualization.models import *
 
 

+ 3 - 3
netbox/virtualization/forms/model_forms.py

@@ -8,9 +8,9 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGr
 from ipam.models import IPAddress, VLAN, VLANGroup, VRF
 from ipam.models import IPAddress, VLAN, VLANGroup, VRF
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
-from utilities.forms import (
-    BootstrapMixin, CommentField, ConfirmationForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-    JSONField, SlugField,
+from utilities.forms import BootstrapMixin, ConfirmationForm
+from utilities.forms.fields import (
+    CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField,
 )
 )
 from utilities.forms.widgets import HTMXSelect
 from utilities.forms.widgets import HTMXSelect
 from virtualization.models import *
 from virtualization.models import *

+ 1 - 1
netbox/virtualization/forms/object_create.py

@@ -1,4 +1,4 @@
-from utilities.forms import ExpandableNameField
+from utilities.forms.fields import ExpandableNameField
 from .model_forms import VMInterfaceForm
 from .model_forms import VMInterfaceForm
 
 
 __all__ = (
 __all__ = (

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

@@ -5,7 +5,8 @@ from dcim.choices import LinkStatusChoices
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import CommentField, DynamicModelChoiceField
 from wireless.choices import *
 from wireless.choices import *
 from wireless.constants import SSID_MAX_LENGTH
 from wireless.constants import SSID_MAX_LENGTH
 from wireless.models import *
 from wireless.models import *

+ 2 - 1
netbox/wireless/forms/bulk_import.py

@@ -1,10 +1,11 @@
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
+
 from dcim.choices import LinkStatusChoices
 from dcim.choices import LinkStatusChoices
 from dcim.models import Interface
 from dcim.models import Interface
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
+from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
 from wireless.choices import *
 from wireless.choices import *
 from wireless.models import *
 from wireless.models import *
 
 

+ 2 - 1
netbox/wireless/forms/filtersets.py

@@ -4,7 +4,8 @@ from django.utils.translation import gettext as _
 from dcim.choices import LinkStatusChoices
 from dcim.choices import LinkStatusChoices
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
-from utilities.forms import add_blank_choice, DynamicModelMultipleChoiceField, TagFilterField
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
 from wireless.choices import *
 from wireless.choices import *
 from wireless.models import *
 from wireless.models import *
 
 

+ 4 - 3
netbox/wireless/forms/model_forms.py

@@ -1,9 +1,10 @@
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
-from dcim.models import Device, Interface, Location, Region, Site, SiteGroup
-from ipam.models import VLAN, VLANGroup
+
+from dcim.models import Device, Interface, Location, Site
+from ipam.models import VLAN
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
-from utilities.forms import CommentField, DynamicModelChoiceField, SlugField
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField
 from wireless.models import *
 from wireless.models import *
 
 
 __all__ = (
 __all__ = (