Przeglądaj źródła

Closes #21712: Support description annotations for static choice form fields

Jeremy Stretch 4 dni temu
rodzic
commit
a7be755e01
47 zmienionych plików z 2433 dodań i 1222 usunięć
  1. 14 0
      docs/configuration/data-validation.md
  2. 29 0
      docs/plugins/development/forms.md
  3. 44 38
      netbox/circuits/choices.py
  4. 6 5
      netbox/circuits/forms/bulk_edit.py
  5. 32 3
      netbox/circuits/forms/model_forms.py
  6. 26 26
      netbox/core/choices.py
  7. 3 2
      netbox/core/forms/bulk_edit.py
  8. 2 2
      netbox/core/forms/model_forms.py
  9. 542 520
      netbox/dcim/choices.py
  10. 45 44
      netbox/dcim/forms/bulk_edit.py
  11. 289 4
      netbox/dcim/forms/model_forms.py
  12. 82 74
      netbox/extras/choices.py
  13. 3 2
      netbox/extras/dashboard/widgets.py
  14. 7 7
      netbox/extras/forms/bulk_edit.py
  15. 56 4
      netbox/extras/forms/model_forms.py
  16. 2 1
      netbox/extras/models/notifications.py
  17. 45 40
      netbox/ipam/choices.py
  18. 10 9
      netbox/ipam/forms/bulk_edit.py
  19. 68 1
      netbox/ipam/forms/model_forms.py
  20. 62 62
      netbox/netbox/choices.py
  21. 2 1
      netbox/netbox/events.py
  22. 3 2
      netbox/netbox/preferences.py
  23. 2 1
      netbox/netbox/utils.py
  24. 0 0
      netbox/project-static/dist/netbox.js
  25. 0 0
      netbox/project-static/dist/netbox.js.map
  26. 16 0
      netbox/project-static/src/select/static.ts
  27. 5 5
      netbox/tenancy/choices.py
  28. 2 2
      netbox/tenancy/forms/bulk_edit.py
  29. 13 1
      netbox/tenancy/forms/model_forms.py
  30. 3 3
      netbox/users/choices.py
  31. 2 1
      netbox/users/forms/model_forms.py
  32. 60 12
      netbox/utilities/choices.py
  33. 1 0
      netbox/utilities/forms/fields/__init__.py
  34. 100 0
      netbox/utilities/forms/fields/choices.py
  35. 3 2
      netbox/utilities/forms/utils.py
  36. 33 0
      netbox/utilities/forms/widgets/select.py
  37. 125 1
      netbox/utilities/tests/test_choices.py
  38. 111 1
      netbox/utilities/tests/test_forms.py
  39. 61 16
      netbox/virtualization/choices.py
  40. 5 5
      netbox/virtualization/forms/bulk_edit.py
  41. 36 2
      netbox/virtualization/forms/model_forms.py
  42. 119 81
      netbox/vpn/choices.py
  43. 16 16
      netbox/vpn/forms/bulk_edit.py
  44. 67 6
      netbox/vpn/forms/model_forms.py
  45. 229 211
      netbox/wireless/choices.py
  46. 8 8
      netbox/wireless/forms/bulk_edit.py
  47. 44 1
      netbox/wireless/forms/model_forms.py

+ 14 - 0
docs/configuration/data-validation.md

@@ -56,6 +56,20 @@ FIELD_CHOICES = {
 }
 ```
 
+In addition to plain tuples, each choice may be defined as a dictionary, which allows specifying a description (shown as a subtitle beneath the option) alongside the value, label, and color. `value` and `label` are required; `color` and `description` are optional:
+
+```python
+FIELD_CHOICES = {
+    'dcim.Site.status': (
+        {'value': 'foo', 'label': 'Foo', 'color': 'red', 'description': 'The foo status'},
+        {'value': 'bar', 'label': 'Bar', 'color': 'green'},
+    )
+}
+```
+
+!!! info "New in NetBox v4.7"
+    The dictionary-based format for declaring choices was introduced in NetBox v4.7. The tuple-based format remains supported, but will be deprecated in a future release and support for it will eventually be removed.
+
 !!! info "Case-Insensitive Field Identifiers"
     Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent.
 

+ 29 - 0
docs/plugins/development/forms.md

@@ -210,6 +210,35 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
     options:
       members: false
 
+## Static Choice Fields
+
+These fields render a standard HTML `<select>` element (as opposed to the API-backed widgets used by the dynamic object fields below). They extend Django's built-in choice fields to optionally display a short **description** beneath each option's label.
+
+For choice set-backed fields, descriptions are defined per choice using a `Choice` object in the `ChoiceSet` and are rendered automatically. Pass `show_descriptions=False` to suppress them for a particular field.
+
+```python
+from utilities.choices import Choice, ChoiceSet
+from utilities.forms.fields import ChoiceField
+
+class StatusChoices(ChoiceSet):
+    ACTIVE = 'active'
+    RETIRED = 'retired'
+    CHOICES = (
+        Choice(ACTIVE, 'Active', description='Currently in service'),
+        Choice(RETIRED, 'Retired', description='No longer in service'),
+    )
+
+status = ChoiceField(choices=StatusChoices)
+```
+
+::: utilities.forms.fields.ChoiceField
+    options:
+      members: false
+
+::: utilities.forms.fields.MultipleChoiceField
+    options:
+      members: false
+
 ## Dynamic Object Fields
 
 ::: utilities.forms.fields.DynamicModelChoiceField

+ 44 - 38
netbox/circuits/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 #
 # Circuits
@@ -18,12 +18,18 @@ class CircuitStatusChoices(ChoiceSet):
     STATUS_DECOMMISSIONED = 'decommissioned'
 
     CHOICES = [
-        (STATUS_PLANNED, _('Planned'), 'cyan'),
-        (STATUS_PROVISIONING, _('Provisioning'), 'blue'),
-        (STATUS_ACTIVE, _('Active'), 'green'),
-        (STATUS_OFFLINE, _('Offline'), 'red'),
-        (STATUS_DEPROVISIONING, _('Deprovisioning'), 'yellow'),
-        (STATUS_DECOMMISSIONED, _('Decommissioned'), 'gray'),
+        Choice(
+            STATUS_PLANNED, _('Planned'), color='cyan',
+            description=_('Designated for future use but not yet installed')
+        ),
+        Choice(STATUS_PROVISIONING, _('Provisioning'), color='blue', description=_('Being configured for service')),
+        Choice(STATUS_ACTIVE, _('Active'), color='green', description=_('Fully operational and in service')),
+        Choice(STATUS_OFFLINE, _('Offline'), color='red', description=_('Installed but not currently in service')),
+        Choice(STATUS_DEPROVISIONING, _('Deprovisioning'), color='yellow', description=_('Being removed from service')),
+        Choice(
+            STATUS_DECOMMISSIONED, _('Decommissioned'), color='gray',
+            description=_('Retired and no longer in service')
+        ),
     ]
 
 
@@ -31,17 +37,17 @@ class CircuitCommitRateChoices(ChoiceSet):
     key = 'Circuit.commit_rate'
 
     CHOICES = [
-        (10000, '10 Mbps'),
-        (100000, '100 Mbps'),
-        (1000000, '1 Gbps'),
-        (10000000, '10 Gbps'),
-        (25000000, '25 Gbps'),
-        (40000000, '40 Gbps'),
-        (100000000, '100 Gbps'),
-        (200000000, '200 Gbps'),
-        (400000000, '400 Gbps'),
-        (1544, 'T1 (1.544 Mbps)'),
-        (2048, 'E1 (2.048 Mbps)'),
+        Choice(10000, '10 Mbps'),
+        Choice(100000, '100 Mbps'),
+        Choice(1000000, '1 Gbps'),
+        Choice(10000000, '10 Gbps'),
+        Choice(25000000, '25 Gbps'),
+        Choice(40000000, '40 Gbps'),
+        Choice(100000000, '100 Gbps'),
+        Choice(200000000, '200 Gbps'),
+        Choice(400000000, '400 Gbps'),
+        Choice(1544, 'T1 (1.544 Mbps)'),
+        Choice(2048, 'E1 (2.048 Mbps)'),
     ]
 
 
@@ -55,8 +61,8 @@ class CircuitTerminationSideChoices(ChoiceSet):
     SIDE_Z = 'Z'
 
     CHOICES = (
-        (SIDE_A, 'A'),
-        (SIDE_Z, 'Z')
+        Choice(SIDE_A, 'A'),
+        Choice(SIDE_Z, 'Z')
     )
 
 
@@ -64,17 +70,17 @@ class CircuitTerminationPortSpeedChoices(ChoiceSet):
     key = 'CircuitTermination.port_speed'
 
     CHOICES = [
-        (10000, '10 Mbps'),
-        (100000, '100 Mbps'),
-        (1000000, '1 Gbps'),
-        (10000000, '10 Gbps'),
-        (25000000, '25 Gbps'),
-        (40000000, '40 Gbps'),
-        (100000000, '100 Gbps'),
-        (200000000, '200 Gbps'),
-        (400000000, '400 Gbps'),
-        (1544, 'T1 (1.544 Mbps)'),
-        (2048, 'E1 (2.048 Mbps)'),
+        Choice(10000, '10 Mbps'),
+        Choice(100000, '100 Mbps'),
+        Choice(1000000, '1 Gbps'),
+        Choice(10000000, '10 Gbps'),
+        Choice(25000000, '25 Gbps'),
+        Choice(40000000, '40 Gbps'),
+        Choice(100000000, '100 Gbps'),
+        Choice(200000000, '200 Gbps'),
+        Choice(400000000, '400 Gbps'),
+        Choice(1544, 'T1 (1.544 Mbps)'),
+        Choice(2048, 'E1 (2.048 Mbps)'),
     ]
 
 
@@ -87,10 +93,10 @@ class CircuitPriorityChoices(ChoiceSet):
     PRIORITY_INACTIVE = 'inactive'
 
     CHOICES = [
-        (PRIORITY_PRIMARY, _('Primary')),
-        (PRIORITY_SECONDARY, _('Secondary')),
-        (PRIORITY_TERTIARY, _('Tertiary')),
-        (PRIORITY_INACTIVE, _('Inactive')),
+        Choice(PRIORITY_PRIMARY, _('Primary')),
+        Choice(PRIORITY_SECONDARY, _('Secondary')),
+        Choice(PRIORITY_TERTIARY, _('Tertiary')),
+        Choice(PRIORITY_INACTIVE, _('Inactive')),
     ]
 
 
@@ -104,7 +110,7 @@ class VirtualCircuitTerminationRoleChoices(ChoiceSet):
     ROLE_SPOKE = 'spoke'
 
     CHOICES = [
-        (ROLE_PEER, _('Peer'), 'green'),
-        (ROLE_HUB, _('Hub'), 'blue'),
-        (ROLE_SPOKE, _('Spoke'), 'orange'),
+        Choice(ROLE_PEER, _('Peer'), color='green', description=_('Connects to other peers as an equal endpoint')),
+        Choice(ROLE_HUB, _('Hub'), color='blue', description=_('Central endpoint to which spokes connect')),
+        Choice(ROLE_SPOKE, _('Spoke'), color='orange', description=_('Remote endpoint connecting to a hub')),
     ]

+ 6 - 5
netbox/circuits/forms/bulk_edit.py

@@ -16,6 +16,7 @@ from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditFor
 from tenancy.models import Tenant
 from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
+    ChoiceField,
     ColorField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
@@ -124,7 +125,7 @@ class CircuitBulkEditForm(PrimaryModelBulkEditForm):
             'provider': '$provider'
         }
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(CircuitStatusChoices),
         required=False,
@@ -157,7 +158,7 @@ class CircuitBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         required=False
     )
-    distance_unit = forms.ChoiceField(
+    distance_unit = ChoiceField(
         label=_('Distance unit'),
         choices=add_blank_choice(DistanceUnitChoices),
         required=False,
@@ -232,7 +233,7 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
         queryset=Circuit.objects.all(),
         required=False
     )
-    priority = forms.ChoiceField(
+    priority = ChoiceField(
         label=_('Priority'),
         choices=add_blank_choice(CircuitPriorityChoices),
         required=False
@@ -274,7 +275,7 @@ class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
         queryset=VirtualCircuitType.objects.all(),
         required=False
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(CircuitStatusChoices),
         required=False,
@@ -297,7 +298,7 @@ class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
 
 
 class VirtualCircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
-    role = forms.ChoiceField(
+    role = ChoiceField(
         label=_('Role'),
         choices=add_blank_choice(VirtualCircuitTerminationRoleChoices),
         required=False,

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

@@ -1,24 +1,29 @@
-from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 
 from circuits.choices import (
     CircuitCommitRateChoices,
+    CircuitPriorityChoices,
+    CircuitStatusChoices,
     CircuitTerminationPortSpeedChoices,
+    CircuitTerminationSideChoices,
     VirtualCircuitTerminationRoleChoices,
 )
 from circuits.constants import *
 from circuits.models import *
 from dcim.models import Interface
 from ipam.models import ASN
+from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from tenancy.forms import TenancyForm
-from utilities.forms import GenericObjectFormMixin
+from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
+    ChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     GenericObjectChoiceField,
     SlugField,
+    TypedChoiceField,
 )
 from utilities.forms.mixins import DistanceValidationMixin
 from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
@@ -134,6 +139,16 @@ class CircuitTypeForm(OrganizationalModelForm):
 
 
 class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=CircuitStatusChoices,
+        initial=CircuitStatusChoices.STATUS_ACTIVE,
+    )
+    distance_unit = TypedChoiceField(
+        label=_('Distance unit'),
+        choices=add_blank_choice(DistanceUnitChoices),
+        required=False,
+    )
     provider = DynamicModelChoiceField(
         label=_('Provider'),
         queryset=Provider.objects.all(),
@@ -185,6 +200,10 @@ class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
 
 
 class CircuitTerminationForm(GenericObjectFormMixin, NetBoxModelForm):
+    term_side = ChoiceField(
+        label=_('Termination side'),
+        choices=CircuitTerminationSideChoices,
+    )
     circuit = DynamicModelChoiceField(
         label=_('Circuit'),
         queryset=Circuit.objects.all(),
@@ -236,6 +255,11 @@ class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
 
 
 class CircuitGroupAssignmentForm(GenericObjectFormMixin, NetBoxModelForm):
+    priority = TypedChoiceField(
+        label=_('Priority'),
+        choices=add_blank_choice(CircuitPriorityChoices),
+        required=False,
+    )
     group = DynamicModelChoiceField(
         label=_('Group'),
         queryset=CircuitGroup.objects.all(),
@@ -275,6 +299,11 @@ class VirtualCircuitTypeForm(OrganizationalModelForm):
 
 
 class VirtualCircuitForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=CircuitStatusChoices,
+        initial=CircuitStatusChoices.STATUS_ACTIVE,
+    )
     provider_network = DynamicModelChoiceField(
         label=_('Provider network'),
         queryset=ProviderNetwork.objects.all(),
@@ -312,7 +341,7 @@ class VirtualCircuitTerminationForm(NetBoxModelForm):
         queryset=VirtualCircuit.objects.all(),
         selector=True
     )
-    role = forms.ChoiceField(
+    role = ChoiceField(
         choices=VirtualCircuitTerminationRoleChoices,
         label=_('Role')
     )

+ 26 - 26
netbox/core/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 #
 # Data sources
@@ -15,11 +15,11 @@ class DataSourceStatusChoices(ChoiceSet):
     FAILED = 'failed'
 
     CHOICES = (
-        (NEW, _('New'), 'blue'),
-        (QUEUED, _('Queued'), 'orange'),
-        (SYNCING, _('Syncing'), 'cyan'),
-        (COMPLETED, _('Completed'), 'green'),
-        (FAILED, _('Failed'), 'red'),
+        Choice(NEW, _('New'), color='blue', description=_('Newly created and not yet synchronized')),
+        Choice(QUEUED, _('Queued'), color='orange', description=_('Queued for synchronization')),
+        Choice(SYNCING, _('Syncing'), color='cyan', description=_('Synchronization in progress')),
+        Choice(COMPLETED, _('Completed'), color='green', description=_('Most recent synchronization succeeded')),
+        Choice(FAILED, _('Failed'), color='red', description=_('Most recent synchronization failed')),
     )
 
 
@@ -32,8 +32,8 @@ class ManagedFileRootPathChoices(ChoiceSet):
     REPORTS = 'reports'  # settings.REPORTS_ROOT
 
     CHOICES = (
-        (SCRIPTS, _('Scripts')),
-        (REPORTS, _('Reports')),
+        Choice(SCRIPTS, _('Scripts')),
+        Choice(REPORTS, _('Reports')),
     )
 
 
@@ -51,12 +51,12 @@ class JobStatusChoices(ChoiceSet):
     STATUS_FAILED = 'failed'
 
     CHOICES = (
-        (STATUS_PENDING, _('Pending'), 'cyan'),
-        (STATUS_SCHEDULED, _('Scheduled'), 'gray'),
-        (STATUS_RUNNING, _('Running'), 'blue'),
-        (STATUS_COMPLETED, _('Completed'), 'green'),
-        (STATUS_ERRORED, _('Errored'), 'red'),
-        (STATUS_FAILED, _('Failed'), 'red'),
+        Choice(STATUS_PENDING, _('Pending'), color='cyan', description=_('Awaiting execution')),
+        Choice(STATUS_SCHEDULED, _('Scheduled'), color='gray', description=_('Scheduled to run at a future time')),
+        Choice(STATUS_RUNNING, _('Running'), color='blue', description=_('Currently executing')),
+        Choice(STATUS_COMPLETED, _('Completed'), color='green', description=_('Finished successfully')),
+        Choice(STATUS_ERRORED, _('Errored'), color='red', description=_('Terminated due to an unhandled error')),
+        Choice(STATUS_FAILED, _('Failed'), color='red', description=_('Failed to complete')),
     )
 
     ENQUEUED_STATE_CHOICES = (
@@ -78,9 +78,9 @@ class JobNotificationChoices(ChoiceSet):
     NOTIFICATION_NEVER = 'never'
 
     CHOICES = (
-        (NOTIFICATION_ALWAYS, _('Always')),
-        (NOTIFICATION_ON_FAILURE, _('On failure')),
-        (NOTIFICATION_NEVER, _('Never')),
+        Choice(NOTIFICATION_ALWAYS, _('Always'), description=_('Notify after every job execution')),
+        Choice(NOTIFICATION_ON_FAILURE, _('On failure'), description=_('Notify only when a job fails')),
+        Choice(NOTIFICATION_NEVER, _('Never'), description=_('Never send job notifications')),
     )
 
 
@@ -91,12 +91,12 @@ class JobIntervalChoices(ChoiceSet):
     INTERVAL_WEEKLY = 60 * 24 * 7
 
     CHOICES = (
-        (INTERVAL_MINUTELY, _('Minutely')),
-        (INTERVAL_HOURLY, _('Hourly')),
-        (INTERVAL_HOURLY * 12, _('12 hours')),
-        (INTERVAL_DAILY, _('Daily')),
-        (INTERVAL_WEEKLY, _('Weekly')),
-        (INTERVAL_DAILY * 30, _('30 days')),
+        Choice(INTERVAL_MINUTELY, _('Minutely')),
+        Choice(INTERVAL_HOURLY, _('Hourly')),
+        Choice(INTERVAL_HOURLY * 12, _('12 hours')),
+        Choice(INTERVAL_DAILY, _('Daily')),
+        Choice(INTERVAL_WEEKLY, _('Weekly')),
+        Choice(INTERVAL_DAILY * 30, _('30 days')),
     )
 
 
@@ -111,7 +111,7 @@ class ObjectChangeActionChoices(ChoiceSet):
     ACTION_DELETE = 'delete'
 
     CHOICES = (
-        (ACTION_CREATE, _('Created'), 'green'),
-        (ACTION_UPDATE, _('Updated'), 'blue'),
-        (ACTION_DELETE, _('Deleted'), 'red'),
+        Choice(ACTION_CREATE, _('Created'), color='green'),
+        Choice(ACTION_UPDATE, _('Updated'), color='blue'),
+        Choice(ACTION_DELETE, _('Deleted'), color='red'),
     )

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

@@ -5,6 +5,7 @@ from core.choices import JobIntervalChoices
 from core.models import *
 from netbox.forms import PrimaryModelBulkEditForm
 from netbox.utils import get_data_backend_choices
+from utilities.forms.fields import ChoiceField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 
@@ -14,7 +15,7 @@ __all__ = (
 
 
 class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         choices=get_data_backend_choices,
         required=False
@@ -24,7 +25,7 @@ class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
         widget=BulkEditNullBooleanSelect(),
         label=_('Enabled')
     )
-    sync_interval = forms.ChoiceField(
+    sync_interval = ChoiceField(
         choices=JobIntervalChoices,
         required=False,
         label=_('Sync interval')

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

@@ -13,7 +13,7 @@ from netbox.forms import NetBoxModelForm, PrimaryModelForm
 from netbox.registry import registry
 from netbox.utils import get_data_backend_choices
 from utilities.forms import get_field_value
-from utilities.forms.fields import JSONField
+from utilities.forms.fields import ChoiceField, JSONField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import HTMXSelect
 
@@ -27,7 +27,7 @@ EMPTY_VALUES = ('', None, [], ())
 
 
 class DataSourceForm(PrimaryModelForm):
-    type = forms.ChoiceField(
+    type = ChoiceField(
         choices=get_data_backend_choices,
         # No hx_target_id: changing type adds/removes the Backend Parameters fieldset entirely.
         widget=HTMXSelect()

Plik diff jest za duży
+ 542 - 520
netbox/dcim/choices.py


+ 45 - 44
netbox/dcim/forms/bulk_edit.py

@@ -21,6 +21,7 @@ from tenancy.models import Tenant
 from users.models import User
 from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
 from utilities.forms.fields import (
+    ChoiceField,
     ColorField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
@@ -111,7 +112,7 @@ class SiteGroupBulkEditForm(NestedGroupModelBulkEditForm):
 
 
 class SiteBulkEditForm(PrimaryModelBulkEditForm):
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(SiteStatusChoices),
         required=False,
@@ -185,7 +186,7 @@ class LocationBulkEditForm(NestedGroupModelBulkEditForm):
             'site_id': '$site'
         }
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(LocationStatusChoices),
         required=False,
@@ -236,12 +237,12 @@ class RackTypeBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Manufacturer.objects.all(),
         required=False
     )
-    form_factor = forms.ChoiceField(
+    form_factor = ChoiceField(
         label=_('Form factor'),
         choices=add_blank_choice(RackFormFactorChoices),
         required=False
     )
-    width = forms.ChoiceField(
+    width = ChoiceField(
         label=_('Width'),
         choices=add_blank_choice(RackWidthChoices),
         required=False
@@ -274,7 +275,7 @@ class RackTypeBulkEditForm(PrimaryModelBulkEditForm):
         required=False,
         min_value=1
     )
-    outer_unit = forms.ChoiceField(
+    outer_unit = ChoiceField(
         label=_('Outer unit'),
         choices=add_blank_choice(RackDimensionUnitChoices),
         required=False
@@ -294,7 +295,7 @@ class RackTypeBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         required=False
     )
-    weight_unit = forms.ChoiceField(
+    weight_unit = ChoiceField(
         label=_('Weight unit'),
         choices=add_blank_choice(WeightUnitChoices),
         required=False,
@@ -362,7 +363,7 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         required=False
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(RackStatusChoices),
         required=False,
@@ -388,12 +389,12 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         max_length=50,
         required=False
     )
-    form_factor = forms.ChoiceField(
+    form_factor = ChoiceField(
         label=_('Form factor'),
         choices=add_blank_choice(RackFormFactorChoices),
         required=False
     )
-    width = forms.ChoiceField(
+    width = ChoiceField(
         label=_('Width'),
         choices=add_blank_choice(RackWidthChoices),
         required=False
@@ -422,7 +423,7 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         required=False,
         min_value=1
     )
-    outer_unit = forms.ChoiceField(
+    outer_unit = ChoiceField(
         label=_('Outer unit'),
         choices=add_blank_choice(RackDimensionUnitChoices),
         required=False
@@ -432,7 +433,7 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         required=False,
         min_value=1
     )
-    airflow = forms.ChoiceField(
+    airflow = ChoiceField(
         label=_('Airflow'),
         choices=add_blank_choice(RackAirflowChoices),
         required=False
@@ -447,7 +448,7 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         required=False
     )
-    weight_unit = forms.ChoiceField(
+    weight_unit = ChoiceField(
         label=_('Weight unit'),
         choices=add_blank_choice(WeightUnitChoices),
         required=False,
@@ -471,7 +472,7 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
 
 
 class RackReservationBulkEditForm(PrimaryModelBulkEditForm):
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(RackReservationStatusChoices),
         required=False,
@@ -533,7 +534,7 @@ class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm):
         widget=BulkEditNullBooleanSelect(),
         label=_('Exclude from utilization')
     )
-    airflow = forms.ChoiceField(
+    airflow = ChoiceField(
         label=_('Airflow'),
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
@@ -543,7 +544,7 @@ class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         required=False
     )
-    weight_unit = forms.ChoiceField(
+    weight_unit = ChoiceField(
         label=_('Weight unit'),
         choices=add_blank_choice(WeightUnitChoices),
         required=False,
@@ -589,7 +590,7 @@ class ModuleTypeBulkEditForm(PrimaryModelBulkEditForm):
         label=_('Part number'),
         required=False
     )
-    airflow = forms.ChoiceField(
+    airflow = ChoiceField(
         label=_('Airflow'),
         choices=add_blank_choice(ModuleAirflowChoices),
         required=False
@@ -599,7 +600,7 @@ class ModuleTypeBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         required=False
     )
-    weight_unit = forms.ChoiceField(
+    weight_unit = ChoiceField(
         label=_('Weight unit'),
         choices=add_blank_choice(WeightUnitChoices),
         required=False,
@@ -715,12 +716,12 @@ class DeviceBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Platform.objects.all(),
         required=False
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(DeviceStatusChoices),
         required=False
     )
-    airflow = forms.ChoiceField(
+    airflow = ChoiceField(
         label=_('Airflow'),
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
@@ -774,7 +775,7 @@ class ModuleBulkEditForm(PrimaryModelBulkEditForm):
             'parent': 'manufacturer',
         }
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(ModuleStatusChoices),
         required=False,
@@ -812,19 +813,19 @@ class CableBundleBulkEditForm(PrimaryModelBulkEditForm):
 
 
 class CableBulkEditForm(PrimaryModelBulkEditForm):
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         choices=add_blank_choice(CableTypeChoices),
         required=False,
         initial=''
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(LinkStatusChoices),
         required=False,
         initial=''
     )
-    profile = forms.ChoiceField(
+    profile = ChoiceField(
         label=_('Profile'),
         choices=add_blank_choice(CableProfileChoices),
         required=False,
@@ -854,7 +855,7 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         required=False
     )
-    length_unit = forms.ChoiceField(
+    length_unit = ChoiceField(
         label=_('Length unit'),
         choices=add_blank_choice(CableLengthUnitChoices),
         required=False,
@@ -938,25 +939,25 @@ class PowerFeedBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Rack.objects.all(),
         required=False,
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(PowerFeedStatusChoices),
         required=False,
         initial=''
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         choices=add_blank_choice(PowerFeedTypeChoices),
         required=False,
         initial=''
     )
-    supply = forms.ChoiceField(
+    supply = ChoiceField(
         label=_('Supply'),
         choices=add_blank_choice(PowerFeedSupplyChoices),
         required=False,
         initial=''
     )
-    phase = forms.ChoiceField(
+    phase = ChoiceField(
         label=_('Phase'),
         choices=add_blank_choice(PowerFeedPhaseChoices),
         required=False,
@@ -1010,7 +1011,7 @@ class ConsolePortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         required=False
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         choices=add_blank_choice(ConsolePortTypeChoices),
         required=False
@@ -1029,7 +1030,7 @@ class ConsoleServerPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         required=False
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         choices=add_blank_choice(ConsolePortTypeChoices),
         required=False
@@ -1052,7 +1053,7 @@ class PowerPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         required=False
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         choices=add_blank_choice(PowerPortTypeChoices),
         required=False
@@ -1094,7 +1095,7 @@ class PowerOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         required=False
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         choices=add_blank_choice(PowerOutletTypeChoices),
         required=False
@@ -1108,7 +1109,7 @@ class PowerOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         queryset=PowerPortTemplate.objects.all(),
         required=False
     )
-    feed_leg = forms.ChoiceField(
+    feed_leg = ChoiceField(
         label=_('Feed leg'),
         choices=add_blank_choice(PowerOutletFeedLegChoices),
         required=False
@@ -1142,7 +1143,7 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         required=False
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         choices=add_blank_choice(InterfaceTypeChoices),
         required=False
@@ -1161,19 +1162,19 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         label=_('Description'),
         required=False
     )
-    poe_mode = forms.ChoiceField(
+    poe_mode = ChoiceField(
         choices=add_blank_choice(InterfacePoEModeChoices),
         required=False,
         initial='',
         label=_('PoE mode')
     )
-    poe_type = forms.ChoiceField(
+    poe_type = ChoiceField(
         choices=add_blank_choice(InterfacePoETypeChoices),
         required=False,
         initial='',
         label=_('PoE type')
     )
-    rf_role = forms.ChoiceField(
+    rf_role = ChoiceField(
         choices=add_blank_choice(WirelessRoleChoices),
         required=False,
         initial='',
@@ -1193,7 +1194,7 @@ class FrontPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         required=False
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         choices=add_blank_choice(PortTypeChoices),
         required=False
@@ -1220,7 +1221,7 @@ class RearPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         required=False
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         choices=add_blank_choice(PortTypeChoices),
         required=False
@@ -1488,13 +1489,13 @@ class InterfaceBulkEditForm(
         widget=BulkEditNullBooleanSelect,
         label=_('Management only')
     )
-    poe_mode = forms.ChoiceField(
+    poe_mode = ChoiceField(
         choices=add_blank_choice(InterfacePoEModeChoices),
         required=False,
         initial='',
         label=_('PoE mode')
     )
-    poe_type = forms.ChoiceField(
+    poe_type = ChoiceField(
         choices=add_blank_choice(InterfacePoETypeChoices),
         required=False,
         initial='',
@@ -1505,7 +1506,7 @@ class InterfaceBulkEditForm(
         required=False,
         widget=BulkEditNullBooleanSelect
     )
-    mode = forms.ChoiceField(
+    mode = ChoiceField(
         label=_('Mode'),
         choices=add_blank_choice(InterfaceModeChoices),
         required=False,
@@ -1743,7 +1744,7 @@ class InventoryItemBulkEditForm(
         queryset=Manufacturer.objects.all(),
         required=False
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(InventoryItemStatusChoices),
         required=False,
@@ -1787,7 +1788,7 @@ class VirtualDeviceContextBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Device.objects.all(),
         required=False
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         required=False,
         choices=add_blank_choice(VirtualDeviceContextStatusChoices)

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

@@ -11,17 +11,21 @@ from dcim.models import *
 from extras.models import ConfigTemplate
 from ipam.choices import VLANQinQRoleChoices
 from ipam.models import ASN, VLAN, VRF, IPAddress, VLANGroup, VLANTranslationPolicy
+from netbox.choices import WeightUnitChoices
 from netbox.forms import NestedGroupModelForm, NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin
 from tenancy.forms import TenancyForm
 from users.models import User
+from utilities.choices import Choice
 from utilities.forms import add_blank_choice, get_field_value
 from utilities.forms.fields import (
+    ChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     JSONField,
     NumericArrayField,
     SlugField,
+    TypedChoiceField,
 )
 from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields, TabbedGroups
 from utilities.forms.widgets import (
@@ -34,6 +38,7 @@ from utilities.forms.widgets import (
 )
 from utilities.jsonschema import JSONSchemaProperty
 from virtualization.models import Cluster, VMInterface
+from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
 from wireless.models import WirelessLAN, WirelessLANGroup
 
 from .common import InterfaceCommonForm, ModuleCommonForm
@@ -127,6 +132,11 @@ class SiteGroupForm(NestedGroupModelForm):
 
 
 class SiteForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=SiteStatusChoices,
+        initial=SiteStatusChoices.STATUS_ACTIVE,
+    )
     region = DynamicModelChoiceField(
         label=_('Region'),
         queryset=Region.objects.all(),
@@ -207,6 +217,11 @@ class SiteForm(TenancyForm, PrimaryModelForm):
 
 
 class LocationForm(TenancyForm, NestedGroupModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=LocationStatusChoices,
+        initial=LocationStatusChoices.STATUS_ACTIVE,
+    )
     site = DynamicModelChoiceField(
         label=_('Site'),
         queryset=Site.objects.all(),
@@ -259,6 +274,20 @@ class RackRoleForm(OrganizationalModelForm):
 
 
 class RackTypeForm(PrimaryModelForm):
+    form_factor = ChoiceField(
+        label=_('Form factor'),
+        choices=RackFormFactorChoices,
+    )
+    outer_unit = TypedChoiceField(
+        label=_('Outer unit'),
+        choices=add_blank_choice(RackDimensionUnitChoices),
+        required=False,
+    )
+    weight_unit = TypedChoiceField(
+        label=_('Weight unit'),
+        choices=add_blank_choice(WeightUnitChoices),
+        required=False,
+    )
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all(),
@@ -290,6 +319,31 @@ class RackTypeForm(PrimaryModelForm):
 
 
 class RackForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=RackStatusChoices,
+        initial=RackStatusChoices.STATUS_ACTIVE,
+    )
+    form_factor = TypedChoiceField(
+        label=_('Form factor'),
+        choices=add_blank_choice(RackFormFactorChoices),
+        required=False,
+    )
+    outer_unit = TypedChoiceField(
+        label=_('Outer unit'),
+        choices=add_blank_choice(RackDimensionUnitChoices),
+        required=False,
+    )
+    airflow = TypedChoiceField(
+        label=_('Airflow'),
+        choices=add_blank_choice(RackAirflowChoices),
+        required=False,
+    )
+    weight_unit = TypedChoiceField(
+        label=_('Weight unit'),
+        choices=add_blank_choice(WeightUnitChoices),
+        required=False,
+    )
     site = DynamicModelChoiceField(
         label=_('Site'),
         queryset=Site.objects.all(),
@@ -367,6 +421,11 @@ class RackForm(TenancyForm, PrimaryModelForm):
 
 
 class RackReservationForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=RackReservationStatusChoices,
+        initial=RackReservationStatusChoices.STATUS_ACTIVE,
+    )
     rack = DynamicModelChoiceField(
         label=_('Rack'),
         queryset=Rack.objects.all(),
@@ -407,6 +466,25 @@ class ManufacturerForm(OrganizationalModelForm):
 
 
 class DeviceTypeForm(PrimaryModelForm):
+    subdevice_role = TypedChoiceField(
+        label=_('Parent/child status'),
+        choices=add_blank_choice(SubdeviceRoleChoices),
+        required=False,
+        help_text=_(
+            'Parent devices house child devices in device bays. Leave blank if this device type is neither a '
+            'parent nor a child.'
+        ),
+    )
+    airflow = TypedChoiceField(
+        label=_('Airflow'),
+        choices=add_blank_choice(DeviceAirflowChoices),
+        required=False,
+    )
+    weight_unit = TypedChoiceField(
+        label=_('Weight unit'),
+        choices=add_blank_choice(WeightUnitChoices),
+        required=False,
+    )
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all(),
@@ -471,6 +549,16 @@ class ModuleTypeProfileForm(PrimaryModelForm):
 
 
 class ModuleTypeForm(PrimaryModelForm):
+    airflow = TypedChoiceField(
+        label=_('Airflow'),
+        choices=add_blank_choice(ModuleAirflowChoices),
+        required=False,
+    )
+    weight_unit = TypedChoiceField(
+        label=_('Weight unit'),
+        choices=add_blank_choice(WeightUnitChoices),
+        required=False,
+    )
     profile = forms.ModelChoiceField(
         queryset=ModuleTypeProfile.objects.all(),
         label=_('Profile'),
@@ -611,6 +699,16 @@ class PlatformForm(NestedGroupModelForm):
 
 
 class DeviceForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=DeviceStatusChoices,
+        initial=DeviceStatusChoices.STATUS_ACTIVE,
+    )
+    airflow = TypedChoiceField(
+        label=_('Airflow'),
+        choices=add_blank_choice(DeviceAirflowChoices),
+        required=False,
+    )
     site = DynamicModelChoiceField(
         label=_('Site'),
         queryset=Site.objects.all(),
@@ -649,7 +747,7 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
             },
         )
     )
-    face = forms.ChoiceField(
+    face = TypedChoiceField(
         label=_('Face'),
         choices=add_blank_choice(DeviceFaceChoices),
         required=False,
@@ -790,6 +888,11 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
 
 
 class ModuleForm(ModuleCommonForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=ModuleStatusChoices,
+        initial=ModuleStatusChoices.STATUS_ACTIVE,
+    )
     device = DynamicModelChoiceField(
         label=_('Device'),
         queryset=Device.objects.all(),
@@ -853,7 +956,7 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
 
 def get_termination_type_choices():
     return add_blank_choice([
-        (f'{ct.app_label}.{ct.model}', ct.model_class()._meta.verbose_name.title())
+        Choice(f'{ct.app_label}.{ct.model}', ct.model_class()._meta.verbose_name.title())
         for ct in ContentType.objects.filter(CABLE_TERMINATION_MODELS)
     ])
 
@@ -870,13 +973,33 @@ class CableBundleForm(PrimaryModelForm):
 
 
 class CableForm(TenancyForm, PrimaryModelForm):
-    a_terminations_type = forms.ChoiceField(
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(CableTypeChoices),
+        required=False,
+    )
+    status = ChoiceField(
+        label=_('Status'),
+        choices=LinkStatusChoices,
+        initial=LinkStatusChoices.STATUS_CONNECTED,
+    )
+    profile = ChoiceField(
+        label=_('Profile'),
+        choices=add_blank_choice(CableProfileChoices),
+        required=False,
+    )
+    length_unit = TypedChoiceField(
+        label=_('Length unit'),
+        choices=add_blank_choice(CableLengthUnitChoices),
+        required=False,
+    )
+    a_terminations_type = ChoiceField(
         choices=get_termination_type_choices,
         required=False,
         widget=HTMXSelect(hx_target_id='cable-side-a'),
         label=_('Type')
     )
-    b_terminations_type = forms.ChoiceField(
+    b_terminations_type = ChoiceField(
         choices=get_termination_type_choices,
         required=False,
         widget=HTMXSelect(hx_target_id='cable-side-b'),
@@ -923,6 +1046,26 @@ class PowerPanelForm(PrimaryModelForm):
 
 
 class PowerFeedForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=PowerFeedStatusChoices,
+        initial=PowerFeedStatusChoices.STATUS_ACTIVE,
+    )
+    type = ChoiceField(
+        label=_('Type'),
+        choices=PowerFeedTypeChoices,
+        initial=PowerFeedTypeChoices.TYPE_PRIMARY,
+    )
+    supply = ChoiceField(
+        label=_('Supply'),
+        choices=PowerFeedSupplyChoices,
+        initial=PowerFeedSupplyChoices.SUPPLY_AC,
+    )
+    phase = ChoiceField(
+        label=_('Phase'),
+        choices=PowerFeedPhaseChoices,
+        initial=PowerFeedPhaseChoices.PHASE_SINGLE,
+    )
     power_panel = DynamicModelChoiceField(
         label=_('Power panel'),
         queryset=PowerPanel.objects.all(),
@@ -1106,6 +1249,12 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
 
 
 class ConsolePortTemplateForm(ModularComponentTemplateForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+    )
+
     class Meta:
         model = ConsolePortTemplate
         fields = [
@@ -1114,6 +1263,12 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm):
 
 
 class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+    )
+
     class Meta:
         model = ConsoleServerPortTemplate
         fields = [
@@ -1122,6 +1277,11 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
 
 
 class PowerPortTemplateForm(ModularComponentTemplateForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(PowerPortTypeChoices),
+        required=False,
+    )
     fieldsets = (
         FieldSet(
             TabbedGroups(
@@ -1140,6 +1300,17 @@ class PowerPortTemplateForm(ModularComponentTemplateForm):
 
 
 class PowerOutletTemplateForm(ModularComponentTemplateForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(PowerOutletTypeChoices),
+        required=False,
+    )
+    feed_leg = TypedChoiceField(
+        label=_('Feed leg'),
+        choices=add_blank_choice(PowerOutletFeedLegChoices),
+        required=False,
+        help_text=_('Phase (for three-phase feeds)'),
+    )
     power_port = DynamicModelChoiceField(
         label=_('Power port'),
         queryset=PowerPortTemplate.objects.all(),
@@ -1167,6 +1338,25 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
 
 
 class InterfaceTemplateForm(ModularComponentTemplateForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=InterfaceTypeChoices,
+    )
+    poe_mode = TypedChoiceField(
+        label=_('PoE mode'),
+        choices=add_blank_choice(InterfacePoEModeChoices),
+        required=False,
+    )
+    poe_type = TypedChoiceField(
+        label=_('PoE type'),
+        choices=add_blank_choice(InterfacePoETypeChoices),
+        required=False,
+    )
+    rf_role = TypedChoiceField(
+        label=_('Wireless role'),
+        choices=add_blank_choice(WirelessRoleChoices),
+        required=False,
+    )
     bridge = DynamicModelChoiceField(
         label=_('Bridge'),
         queryset=InterfaceTemplate.objects.all(),
@@ -1198,6 +1388,10 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
 
 
 class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=PortTypeChoices,
+    )
     fieldsets = (
         FieldSet(
             TabbedGroups(
@@ -1238,6 +1432,10 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
 
 
 class RearPortTemplateForm(ModularComponentTemplateForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=PortTypeChoices,
+    )
     fieldsets = (
         FieldSet(
             TabbedGroups(
@@ -1461,6 +1659,12 @@ class ModularDeviceComponentForm(DeviceComponentForm):
 
 
 class ConsolePortForm(ModularDeviceComponentForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+        help_text=_('Physical port type'),
+    )
     fieldsets = (
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
@@ -1475,6 +1679,12 @@ class ConsolePortForm(ModularDeviceComponentForm):
 
 
 class ConsoleServerPortForm(ModularDeviceComponentForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+        help_text=_('Physical port type'),
+    )
     fieldsets = (
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
@@ -1489,6 +1699,12 @@ class ConsoleServerPortForm(ModularDeviceComponentForm):
 
 
 class PowerPortForm(ModularDeviceComponentForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(PowerPortTypeChoices),
+        required=False,
+        help_text=_('Physical port type'),
+    )
     fieldsets = (
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
@@ -1505,6 +1721,23 @@ class PowerPortForm(ModularDeviceComponentForm):
 
 
 class PowerOutletForm(ModularDeviceComponentForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(PowerOutletTypeChoices),
+        required=False,
+        help_text=_('Physical port type'),
+    )
+    status = ChoiceField(
+        label=_('Status'),
+        choices=PowerOutletStatusChoices,
+        initial=PowerOutletStatusChoices.STATUS_ENABLED,
+    )
+    feed_leg = TypedChoiceField(
+        label=_('Feed leg'),
+        choices=add_blank_choice(PowerOutletFeedLegChoices),
+        required=False,
+        help_text=_('Phase (for three-phase feeds)'),
+    )
     power_port = DynamicModelChoiceField(
         label=_('Power port'),
         queryset=PowerPort.objects.all(),
@@ -1530,6 +1763,41 @@ class PowerOutletForm(ModularDeviceComponentForm):
 
 
 class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=InterfaceTypeChoices,
+    )
+    duplex = TypedChoiceField(
+        label=_('Duplex'),
+        choices=add_blank_choice(InterfaceDuplexChoices),
+        required=False,
+    )
+    poe_mode = TypedChoiceField(
+        label=_('PoE mode'),
+        choices=add_blank_choice(InterfacePoEModeChoices),
+        required=False,
+    )
+    poe_type = TypedChoiceField(
+        label=_('PoE type'),
+        choices=add_blank_choice(InterfacePoETypeChoices),
+        required=False,
+    )
+    mode = TypedChoiceField(
+        label=_('802.1Q Mode'),
+        choices=add_blank_choice(InterfaceModeChoices),
+        required=False,
+        help_text=_('IEEE 802.1Q tagging strategy'),
+    )
+    rf_role = TypedChoiceField(
+        label=_('Wireless role'),
+        choices=add_blank_choice(WirelessRoleChoices),
+        required=False,
+    )
+    rf_channel = TypedChoiceField(
+        label=_('Wireless channel'),
+        choices=add_blank_choice(WirelessChannelChoices),
+        required=False,
+    )
     vdcs = DynamicModelMultipleChoiceField(
         queryset=VirtualDeviceContext.objects.all(),
         required=False,
@@ -1676,6 +1944,10 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
 
 
 class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=PortTypeChoices,
+    )
     fieldsets = (
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected',
@@ -1712,6 +1984,10 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
 
 
 class RearPortForm(ModularDeviceComponentForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=PortTypeChoices,
+    )
     fieldsets = (
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
@@ -1770,6 +2046,11 @@ class PopulateDeviceBayForm(forms.Form):
 
 
 class InventoryItemForm(DeviceComponentForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=InventoryItemStatusChoices,
+        initial=InventoryItemStatusChoices.STATUS_ACTIVE,
+    )
     parent = DynamicModelChoiceField(
         label=_('Parent'),
         queryset=InventoryItem.objects.all(),
@@ -1930,6 +2211,10 @@ class InventoryItemRoleForm(OrganizationalModelForm):
 
 
 class VirtualDeviceContextForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=VirtualDeviceContextStatusChoices,
+    )
     device = DynamicModelChoiceField(
         label=_('Device'),
         queryset=Device.objects.all(),

+ 82 - 74
netbox/extras/choices.py

@@ -3,7 +3,7 @@ import logging
 from django.utils.translation import gettext_lazy as _
 
 from netbox.choices import ButtonColorChoices
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 #
 # CustomFields
@@ -27,19 +27,23 @@ class CustomFieldTypeChoices(ChoiceSet):
     TYPE_MULTIOBJECT = 'multiobject'
 
     CHOICES = (
-        (TYPE_TEXT, _('Text')),
-        (TYPE_LONGTEXT, _('Text (long)')),
-        (TYPE_INTEGER, _('Integer')),
-        (TYPE_DECIMAL, _('Decimal')),
-        (TYPE_BOOLEAN, _('Boolean (true/false)')),
-        (TYPE_DATE, _('Date')),
-        (TYPE_DATETIME, _('Date & time')),
-        (TYPE_URL, _('URL')),
-        (TYPE_JSON, _('JSON')),
-        (TYPE_SELECT, _('Selection')),
-        (TYPE_MULTISELECT, _('Multiple selection')),
-        (TYPE_OBJECT, _('Object')),
-        (TYPE_MULTIOBJECT, _('Multiple objects')),
+        Choice(TYPE_TEXT, _('Text'), description=_('A single line of text')),
+        Choice(TYPE_LONGTEXT, _('Text (long)'), description=_('Multi-line text with Markdown support')),
+        Choice(TYPE_INTEGER, _('Integer'), description=_('A whole number (positive or negative)')),
+        Choice(TYPE_DECIMAL, _('Decimal'), description=_('A fixed-precision decimal number')),
+        Choice(TYPE_BOOLEAN, _('Boolean'), description=_('A true or false value')),
+        Choice(TYPE_DATE, _('Date'), description=_('A calendar date')),
+        Choice(TYPE_DATETIME, _('Date & time'), description=_('A calendar date and time')),
+        Choice(TYPE_URL, _('URL'), description=_('A hyperlink to an external resource')),
+        Choice(TYPE_JSON, _('JSON'), description=_('Arbitrary data encoded as JSON')),
+        Choice(TYPE_SELECT, _('Selection'), description=_('A single value chosen from a predefined list')),
+        Choice(
+            TYPE_MULTISELECT,
+            _('Multiple selection'),
+            description=_('One or more values chosen from a predefined list')
+        ),
+        Choice(TYPE_OBJECT, _('Object'), description=_('A reference to a single NetBox object')),
+        Choice(TYPE_MULTIOBJECT, _('Multiple objects'), description=_('References to one or more NetBox objects')),
     )
 
 
@@ -50,9 +54,9 @@ class CustomFieldFilterLogicChoices(ChoiceSet):
     FILTER_EXACT = 'exact'
 
     CHOICES = (
-        (FILTER_DISABLED, _('Disabled')),
-        (FILTER_LOOSE, _('Loose')),
-        (FILTER_EXACT, _('Exact')),
+        Choice(FILTER_DISABLED, _('Disabled'), description=_('The field cannot be used for filtering')),
+        Choice(FILTER_LOOSE, _('Loose'), description=_('Match on a partial value (case-insensitive substring)')),
+        Choice(FILTER_EXACT, _('Exact'), description=_('Match on the exact value')),
     )
 
 
@@ -63,9 +67,9 @@ class CustomFieldUIVisibleChoices(ChoiceSet):
     HIDDEN = 'hidden'
 
     CHOICES = (
-        (ALWAYS, _('Always'), 'green'),
-        (IF_SET, _('If set'), 'yellow'),
-        (HIDDEN, _('Hidden'), 'gray'),
+        Choice(ALWAYS, _('Always'), color='green', description=_('Always display the field')),
+        Choice(IF_SET, _('If set'), color='yellow', description=_('Display the field only if it has a value')),
+        Choice(HIDDEN, _('Hidden'), color='gray', description=_('Never display the field')),
     )
 
 
@@ -76,9 +80,9 @@ class CustomFieldUIEditableChoices(ChoiceSet):
     HIDDEN = 'hidden'
 
     CHOICES = (
-        (YES, _('Yes'), 'green'),
-        (NO, _('No'), 'red'),
-        (HIDDEN, _('Hidden'), 'gray'),
+        Choice(YES, _('Yes'), color='green', description=_('The field value can be edited by users')),
+        Choice(NO, _('No'), color='red', description=_('The field is displayed but cannot be edited')),
+        Choice(HIDDEN, _('Hidden'), color='gray', description=_('The field is neither displayed nor editable')),
     )
 
 
@@ -89,9 +93,9 @@ class CustomFieldChoiceSetBaseChoices(ChoiceSet):
     UN_LOCODE = 'UN_LOCODE'
 
     CHOICES = (
-        (IATA, 'IATA (Airport codes)'),
-        (ISO_3166, 'ISO 3166 (Country codes)'),
-        (UN_LOCODE, 'UN/LOCODE (Location codes)'),
+        Choice(IATA, 'IATA (Airport codes)'),
+        Choice(ISO_3166, 'ISO 3166 (Country codes)'),
+        Choice(UN_LOCODE, 'UN/LOCODE (Location codes)'),
     )
 
 
@@ -112,19 +116,19 @@ class CustomFieldChoiceColorChoices(ChoiceSet):
     WHITE = 'white'
 
     CHOICES = (
-        (BLUE, _('Blue'), BLUE),
-        (INDIGO, _('Indigo'), INDIGO),
-        (PURPLE, _('Purple'), PURPLE),
-        (PINK, _('Pink'), PINK),
-        (RED, _('Red'), RED),
-        (ORANGE, _('Orange'), ORANGE),
-        (YELLOW, _('Yellow'), YELLOW),
-        (GREEN, _('Green'), GREEN),
-        (TEAL, _('Teal'), TEAL),
-        (CYAN, _('Cyan'), CYAN),
-        (GRAY, _('Gray'), GRAY),
-        (BLACK, _('Black'), BLACK),
-        (WHITE, _('White'), WHITE),
+        Choice(BLUE, _('Blue'), color=BLUE),
+        Choice(INDIGO, _('Indigo'), color=INDIGO),
+        Choice(PURPLE, _('Purple'), color=PURPLE),
+        Choice(PINK, _('Pink'), color=PINK),
+        Choice(RED, _('Red'), color=RED),
+        Choice(ORANGE, _('Orange'), color=ORANGE),
+        Choice(YELLOW, _('Yellow'), color=YELLOW),
+        Choice(GREEN, _('Green'), color=GREEN),
+        Choice(TEAL, _('Teal'), color=TEAL),
+        Choice(CYAN, _('Cyan'), color=CYAN),
+        Choice(GRAY, _('Gray'), color=GRAY),
+        Choice(BLACK, _('Black'), color=BLACK),
+        Choice(WHITE, _('White'), color=WHITE),
     )
 
 
@@ -138,7 +142,7 @@ class CustomLinkButtonClassChoices(ButtonColorChoices):
 
     CHOICES = (
         *ButtonColorChoices.CHOICES,
-        (LINK, _('Link')),
+        Choice(LINK, _('Link'), description=_('Render the button as a borderless text link')),
     )
 
 
@@ -154,10 +158,10 @@ class BookmarkOrderingChoices(ChoiceSet):
     ORDERING_ALPHABETICAL_ZA = '-name'
 
     CHOICES = (
-        (ORDERING_NEWEST, _('Newest')),
-        (ORDERING_OLDEST, _('Oldest')),
-        (ORDERING_ALPHABETICAL_AZ, _('Alphabetical (A-Z)')),
-        (ORDERING_ALPHABETICAL_ZA, _('Alphabetical (Z-A)')),
+        Choice(ORDERING_NEWEST, _('Newest')),
+        Choice(ORDERING_OLDEST, _('Oldest')),
+        Choice(ORDERING_ALPHABETICAL_AZ, _('Alphabetical (A-Z)')),
+        Choice(ORDERING_ALPHABETICAL_ZA, _('Alphabetical (Z-A)')),
     )
 
 
@@ -174,10 +178,10 @@ class JournalEntryKindChoices(ChoiceSet):
     KIND_DANGER = 'danger'
 
     CHOICES = [
-        (KIND_INFO, _('Info'), 'cyan'),
-        (KIND_SUCCESS, _('Success'), 'green'),
-        (KIND_WARNING, _('Warning'), 'yellow'),
-        (KIND_DANGER, _('Danger'), 'red'),
+        Choice(KIND_INFO, _('Info'), color='cyan', description=_('An informational entry')),
+        Choice(KIND_SUCCESS, _('Success'), color='green', description=_('A record of a successful outcome')),
+        Choice(KIND_WARNING, _('Warning'), color='yellow', description=_('A cautionary note requiring attention')),
+        Choice(KIND_DANGER, _('Danger'), color='red', description=_('A record of a critical issue or failure')),
     ]
 
 
@@ -194,11 +198,11 @@ class LogLevelChoices(ChoiceSet):
     LOG_FAILURE = 'failure'
 
     CHOICES = (
-        (LOG_DEBUG, _('Debug'), 'teal'),
-        (LOG_INFO, _('Info'), 'cyan'),
-        (LOG_SUCCESS, _('Success'), 'green'),
-        (LOG_WARNING, _('Warning'), 'yellow'),
-        (LOG_FAILURE, _('Failure'), 'red'),
+        Choice(LOG_DEBUG, _('Debug'), color='teal'),
+        Choice(LOG_INFO, _('Info'), color='cyan'),
+        Choice(LOG_SUCCESS, _('Success'), color='green'),
+        Choice(LOG_WARNING, _('Warning'), color='yellow'),
+        Choice(LOG_FAILURE, _('Failure'), color='red'),
 
     )
 
@@ -224,11 +228,11 @@ class WebhookHttpMethodChoices(ChoiceSet):
     METHOD_DELETE = 'DELETE'
 
     CHOICES = (
-        (METHOD_GET, 'GET'),
-        (METHOD_POST, 'POST'),
-        (METHOD_PUT, 'PUT'),
-        (METHOD_PATCH, 'PATCH'),
-        (METHOD_DELETE, 'DELETE'),
+        Choice(METHOD_GET, 'GET'),
+        Choice(METHOD_POST, 'POST'),
+        Choice(METHOD_PUT, 'PUT'),
+        Choice(METHOD_PATCH, 'PATCH'),
+        Choice(METHOD_DELETE, 'DELETE'),
     )
 
 
@@ -252,19 +256,19 @@ class DashboardWidgetColorChoices(ChoiceSet):
     WHITE = 'white'
 
     CHOICES = (
-        (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')),
+        Choice(BLUE, _('Blue')),
+        Choice(INDIGO, _('Indigo')),
+        Choice(PURPLE, _('Purple')),
+        Choice(PINK, _('Pink')),
+        Choice(RED, _('Red')),
+        Choice(ORANGE, _('Orange')),
+        Choice(YELLOW, _('Yellow')),
+        Choice(GREEN, _('Green')),
+        Choice(TEAL, _('Teal')),
+        Choice(CYAN, _('Cyan')),
+        Choice(GRAY, _('Gray')),
+        Choice(BLACK, _('Black')),
+        Choice(WHITE, _('White')),
     )
 
 
@@ -279,7 +283,11 @@ class EventRuleActionChoices(ChoiceSet):
     NOTIFICATION = 'notification'
 
     CHOICES = (
-        (WEBHOOK, _('Webhook')),
-        (SCRIPT, _('Script')),
-        (NOTIFICATION, _('Notification')),
+        Choice(WEBHOOK, _('Webhook'), description=_('Send an outgoing HTTP request to a remote endpoint')),
+        Choice(SCRIPT, _('Script'), description=_('Execute a custom script')),
+        Choice(
+            NOTIFICATION,
+            _('Notification'),
+            description=_('Generate a notification for one or more users or groups')
+        ),
     )

+ 3 - 2
netbox/extras/dashboard/widgets.py

@@ -16,6 +16,7 @@ from django.utils.translation import gettext as _
 
 from core.models import ObjectType
 from extras.choices import BookmarkOrderingChoices
+from utilities.choices import Choice
 from utilities.object_types import object_type_identifier, object_type_name
 from utilities.permissions import get_permission_for_model
 from utilities.proxy import resolve_proxies
@@ -40,7 +41,7 @@ logger = logging.getLogger('netbox.data_backends')
 
 def get_object_type_choices():
     return [
-        (object_type_identifier(ot), object_type_name(ot))
+        Choice(object_type_identifier(ot), object_type_name(ot))
         for ot in ObjectType.objects.public().order_by('app_label', 'model')
     ]
 
@@ -68,7 +69,7 @@ def object_list_widget_supports_model(model: Model) -> bool:
 
 def get_bookmarks_object_type_choices():
     return [
-        (object_type_identifier(ot), object_type_name(ot))
+        Choice(object_type_identifier(ot), object_type_name(ot))
         for ot in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model')
     ]
 

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

@@ -7,7 +7,7 @@ from netbox.events import get_event_type_choices
 from netbox.forms import NetBoxModelBulkEditForm, PrimaryModelBulkEditForm
 from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin
 from utilities.forms import BulkEditForm, add_blank_choice
-from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, JSONField
+from utilities.forms.fields import ChoiceField, ColorField, CommentField, DynamicModelChoiceField, JSONField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 
@@ -61,12 +61,12 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
         queryset=CustomFieldChoiceSet.objects.all(),
         required=False
     )
-    ui_visible = forms.ChoiceField(
+    ui_visible = ChoiceField(
         label=_("UI visible"),
         choices=add_blank_choice(CustomFieldUIVisibleChoices),
         required=False
     )
-    ui_editable = forms.ChoiceField(
+    ui_editable = ChoiceField(
         label=_("UI editable"),
         choices=add_blank_choice(CustomFieldUIEditableChoices),
         required=False
@@ -110,7 +110,7 @@ class CustomFieldChoiceSetBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEd
         queryset=CustomFieldChoiceSet.objects.all(),
         widget=forms.MultipleHiddenInput
     )
-    base_choices = forms.ChoiceField(
+    base_choices = ChoiceField(
         choices=add_blank_choice(CustomFieldChoiceSetBaseChoices),
         required=False
     )
@@ -144,7 +144,7 @@ class CustomLinkBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
         label=_('Weight'),
         required=False
     )
-    button_class = forms.ChoiceField(
+    button_class = ChoiceField(
         label=_('Button class'),
         choices=add_blank_choice(CustomLinkButtonClassChoices),
         required=False
@@ -252,7 +252,7 @@ class WebhookBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
         max_length=200,
         required=False
     )
-    http_method = forms.ChoiceField(
+    http_method = ChoiceField(
         choices=add_blank_choice(WebhookHttpMethodChoices),
         required=False,
         label=_('HTTP method')
@@ -429,7 +429,7 @@ class JournalEntryBulkEditForm(ChangelogMessageMixin, BulkEditForm):
         queryset=JournalEntry.objects.all(),
         widget=forms.MultipleHiddenInput
     )
-    kind = forms.ChoiceField(
+    kind = ChoiceField(
         label=_('Kind'),
         choices=add_blank_choice(JournalEntryKindChoices),
         required=False

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

@@ -17,15 +17,18 @@ from netbox.forms import NetBoxModelForm, PrimaryModelForm
 from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin
 from tenancy.models import Tenant, TenantGroup
 from users.models import Group, User
-from utilities.forms import get_field_value
+from utilities.forms import add_blank_choice, get_field_value
 from utilities.forms.fields import (
+    ChoiceField,
     CommentField,
     ContentTypeChoiceField,
     ContentTypeMultipleChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     JSONField,
+    MultipleChoiceField,
     SlugField,
+    TypedChoiceField,
 )
 from utilities.forms.rendering import FieldSet, ObjectAttribute
 from utilities.forms.widgets import ChoicesWidget, HTMXSelect
@@ -54,6 +57,33 @@ __all__ = (
 
 
 class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=CustomFieldTypeChoices,
+        initial=CustomFieldTypeChoices.TYPE_TEXT,
+        help_text=_(
+            'The type of data stored in this field. For object/multi-object fields, select the related object '
+            'type below.'
+        ),
+    )
+    filter_logic = ChoiceField(
+        label=_('Filter logic'),
+        choices=CustomFieldFilterLogicChoices,
+        initial=CustomFieldFilterLogicChoices.FILTER_LOOSE,
+        help_text=_('Loose matches any instance of a given string; exact matches the entire field.'),
+    )
+    ui_visible = ChoiceField(
+        label=_('UI visible'),
+        choices=CustomFieldUIVisibleChoices,
+        initial=CustomFieldUIVisibleChoices.ALWAYS,
+        help_text=_('Specifies whether the custom field is displayed in the UI'),
+    )
+    ui_editable = ChoiceField(
+        label=_('UI editable'),
+        choices=CustomFieldUIEditableChoices,
+        initial=CustomFieldUIEditableChoices.YES,
+        help_text=_('Specifies whether the custom field value can be edited in the UI'),
+    )
     object_types = ContentTypeMultipleChoiceField(
         label=_('Object types'),
         queryset=ObjectType.objects.with_feature('custom_fields'),
@@ -189,6 +219,12 @@ class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
 
 
 class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
+    base_choices = TypedChoiceField(
+        label=_('Base choices'),
+        choices=add_blank_choice(CustomFieldChoiceSetBaseChoices),
+        required=False,
+        help_text=_('Base set of predefined choices (optional)'),
+    )
     # TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model
     extra_choices = forms.CharField(
         widget=ChoicesWidget(),
@@ -303,6 +339,12 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
 
 
 class CustomLinkForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
+    button_class = ChoiceField(
+        label=_('Button class'),
+        choices=CustomLinkButtonClassChoices,
+        initial=CustomLinkButtonClassChoices.DEFAULT,
+        help_text=_('The class of the first link in a group will be used for the dropdown button'),
+    )
     object_types = ContentTypeMultipleChoiceField(
         label=_('Object types'),
         queryset=ObjectType.objects.with_feature('custom_links')
@@ -531,6 +573,11 @@ class SubscriptionForm(forms.ModelForm):
 
 
 class WebhookForm(OwnerMixin, NetBoxModelForm):
+    http_method = ChoiceField(
+        label=_('HTTP method'),
+        choices=WebhookHttpMethodChoices,
+        initial=WebhookHttpMethodChoices.METHOD_POST,
+    )
 
     fieldsets = (
         FieldSet('name', 'description', 'tags', name=_('Webhook')),
@@ -551,15 +598,20 @@ class WebhookForm(OwnerMixin, NetBoxModelForm):
 
 
 class EventRuleForm(OwnerMixin, NetBoxModelForm):
+    action_type = ChoiceField(
+        label=_('Action type'),
+        choices=EventRuleActionChoices,
+        initial=EventRuleActionChoices.WEBHOOK,
+    )
     object_types = ContentTypeMultipleChoiceField(
         label=_('Object types'),
         queryset=ObjectType.objects.with_feature('event_rules'),
     )
-    event_types = forms.MultipleChoiceField(
+    event_types = MultipleChoiceField(
         choices=get_event_type_choices(),
         label=_('Event types')
     )
-    action_choice = forms.ChoiceField(
+    action_choice = ChoiceField(
         label=_('Action choice'),
         choices=[]
     )
@@ -893,7 +945,7 @@ class ImageAttachmentForm(forms.ModelForm):
 
 
 class JournalEntryForm(NetBoxModelForm):
-    kind = forms.ChoiceField(
+    kind = ChoiceField(
         label=_('Kind'),
         choices=JournalEntryKindChoices
     )

+ 2 - 1
netbox/extras/models/notifications.py

@@ -12,6 +12,7 @@ from netbox.models import ChangeLoggedModel
 from netbox.models.features import has_feature
 from netbox.registry import registry
 from users.models import User
+from utilities.choices import Choice
 from utilities.querysets import RestrictedQuerySet
 
 __all__ = (
@@ -26,7 +27,7 @@ def get_event_type_choices():
     Compile a list of choices from all registered event types
     """
     return [
-        (name, event.text)
+        Choice(name, event.text)
         for name, event in registry['event_types'].items()
     ]
 

+ 45 - 40
netbox/ipam/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 class IPAddressFamilyChoices(ChoiceSet):
@@ -9,8 +9,8 @@ class IPAddressFamilyChoices(ChoiceSet):
     FAMILY_6 = 6
 
     CHOICES = (
-        (FAMILY_4, 'IPv4'),
-        (FAMILY_6, 'IPv6'),
+        Choice(FAMILY_4, 'IPv4'),
+        Choice(FAMILY_6, 'IPv6'),
     )
 
 
@@ -27,10 +27,10 @@ class PrefixStatusChoices(ChoiceSet):
     STATUS_DEPRECATED = 'deprecated'
 
     CHOICES = [
-        (STATUS_CONTAINER, _('Container'), 'gray'),
-        (STATUS_ACTIVE, _('Active'), 'blue'),
-        (STATUS_RESERVED, _('Reserved'), 'cyan'),
-        (STATUS_DEPRECATED, _('Deprecated'), 'red'),
+        Choice(STATUS_CONTAINER, _('Container'), color='gray', description=_('Organizes a set of child prefixes')),
+        Choice(STATUS_ACTIVE, _('Active'), color='blue', description=_('Provisioned and in use')),
+        Choice(STATUS_RESERVED, _('Reserved'), color='cyan', description=_('Designated for future use')),
+        Choice(STATUS_DEPRECATED, _('Deprecated'), color='red', description=_('No longer in use')),
     ]
 
 
@@ -46,9 +46,9 @@ class IPRangeStatusChoices(ChoiceSet):
     STATUS_DEPRECATED = 'deprecated'
 
     CHOICES = [
-        (STATUS_ACTIVE, _('Active'), 'blue'),
-        (STATUS_RESERVED, _('Reserved'), 'cyan'),
-        (STATUS_DEPRECATED, _('Deprecated'), 'red'),
+        Choice(STATUS_ACTIVE, _('Active'), color='blue', description=_('Provisioned and in use')),
+        Choice(STATUS_RESERVED, _('Reserved'), color='cyan', description=_('Designated for future use')),
+        Choice(STATUS_DEPRECATED, _('Deprecated'), color='red', description=_('No longer in use')),
     ]
 
 
@@ -66,11 +66,16 @@ class IPAddressStatusChoices(ChoiceSet):
     STATUS_SLAAC = 'slaac'
 
     CHOICES = [
-        (STATUS_ACTIVE, _('Active'), 'blue'),
-        (STATUS_RESERVED, _('Reserved'), 'cyan'),
-        (STATUS_DEPRECATED, _('Deprecated'), 'red'),
-        (STATUS_DHCP, _('DHCP'), 'green'),
-        (STATUS_SLAAC, _('SLAAC'), 'purple'),
+        Choice(STATUS_ACTIVE, _('Active'), color='blue', description=_('Provisioned and in use')),
+        Choice(STATUS_RESERVED, _('Reserved'), color='cyan', description=_('Designated for future use')),
+        Choice(STATUS_DEPRECATED, _('Deprecated'), color='red', description=_('No longer in use')),
+        Choice(STATUS_DHCP, _('DHCP'), color='green', description=_('Assigned dynamically via DHCP')),
+        Choice(
+            STATUS_SLAAC,
+            _('SLAAC'),
+            color='purple',
+            description=_('Assigned via IPv6 stateless address autoconfiguration')
+        ),
     ]
 
 
@@ -86,14 +91,14 @@ class IPAddressRoleChoices(ChoiceSet):
     ROLE_CARP = 'carp'
 
     CHOICES = (
-        (ROLE_LOOPBACK, _('Loopback'), 'gray'),
-        (ROLE_SECONDARY, _('Secondary'), 'blue'),
-        (ROLE_ANYCAST, _('Anycast'), 'yellow'),
-        (ROLE_VIP, 'VIP', 'purple'),
-        (ROLE_VRRP, 'VRRP', 'green'),
-        (ROLE_HSRP, 'HSRP', 'green'),
-        (ROLE_GLBP, 'GLBP', 'green'),
-        (ROLE_CARP, 'CARP', 'green'),
+        Choice(ROLE_LOOPBACK, _('Loopback'), color='gray', description=_('A loopback interface address')),
+        Choice(ROLE_SECONDARY, _('Secondary'), color='blue', description=_('A secondary address on an interface')),
+        Choice(ROLE_ANYCAST, _('Anycast'), color='yellow', description=_('An address shared among multiple nodes')),
+        Choice(ROLE_VIP, 'VIP', color='purple', description=_('A virtual IP address')),
+        Choice(ROLE_VRRP, 'VRRP', color='green', description=_('A virtual address managed by VRRP')),
+        Choice(ROLE_HSRP, 'HSRP', color='green', description=_('A virtual address managed by HSRP')),
+        Choice(ROLE_GLBP, 'GLBP', color='green', description=_('A virtual address managed by GLBP')),
+        Choice(ROLE_CARP, 'CARP', color='green', description=_('A virtual address managed by CARP')),
     )
 
 
@@ -113,18 +118,18 @@ class FHRPGroupProtocolChoices(ChoiceSet):
 
     CHOICES = (
         (_('Standard'), (
-            (PROTOCOL_VRRP2, 'VRRPv2'),
-            (PROTOCOL_VRRP3, 'VRRPv3'),
-            (PROTOCOL_CARP, 'CARP'),
+            Choice(PROTOCOL_VRRP2, 'VRRPv2', description=_('Virtual Router Redundancy Protocol version 2')),
+            Choice(PROTOCOL_VRRP3, 'VRRPv3', description=_('Virtual Router Redundancy Protocol version 3')),
+            Choice(PROTOCOL_CARP, 'CARP', description=_('Common Address Redundancy Protocol')),
         )),
         (_('CheckPoint'), (
-            (PROTOCOL_CLUSTERXL, 'ClusterXL'),
+            Choice(PROTOCOL_CLUSTERXL, 'ClusterXL', description=_('Check Point ClusterXL high-availability protocol')),
         )),
         (_('Cisco'), (
-            (PROTOCOL_HSRP, 'HSRP'),
-            (PROTOCOL_GLBP, 'GLBP'),
+            Choice(PROTOCOL_HSRP, 'HSRP', description=_('Hot Standby Router Protocol')),
+            Choice(PROTOCOL_GLBP, 'GLBP', description=_('Gateway Load Balancing Protocol')),
         )),
-        (PROTOCOL_OTHER, 'Other'),
+        Choice(PROTOCOL_OTHER, 'Other'),
     )
 
 
@@ -134,8 +139,8 @@ class FHRPGroupAuthTypeChoices(ChoiceSet):
     AUTHENTICATION_MD5 = 'md5'
 
     CHOICES = (
-        (AUTHENTICATION_PLAINTEXT, _('Plaintext')),
-        (AUTHENTICATION_MD5, 'MD5'),
+        Choice(AUTHENTICATION_PLAINTEXT, _('Plaintext'), description=_('Authentication using a cleartext password')),
+        Choice(AUTHENTICATION_MD5, 'MD5', description=_('Authentication using an MD5 hash')),
     )
 
 
@@ -151,9 +156,9 @@ class VLANStatusChoices(ChoiceSet):
     STATUS_DEPRECATED = 'deprecated'
 
     CHOICES = [
-        (STATUS_ACTIVE, _('Active'), 'blue'),
-        (STATUS_RESERVED, _('Reserved'), 'cyan'),
-        (STATUS_DEPRECATED, _('Deprecated'), 'red'),
+        Choice(STATUS_ACTIVE, _('Active'), color='blue', description=_('Provisioned and in use')),
+        Choice(STATUS_RESERVED, _('Reserved'), color='cyan', description=_('Designated for future use')),
+        Choice(STATUS_DEPRECATED, _('Deprecated'), color='red', description=_('No longer in use')),
     ]
 
 
@@ -163,8 +168,8 @@ class VLANQinQRoleChoices(ChoiceSet):
     ROLE_CUSTOMER = 'cvlan'
 
     CHOICES = [
-        (ROLE_SERVICE, _('Service'), 'blue'),
-        (ROLE_CUSTOMER, _('Customer'), 'orange'),
+        Choice(ROLE_SERVICE, _('Service'), color='blue', description=_('An outer service VLAN (S-VLAN)')),
+        Choice(ROLE_CUSTOMER, _('Customer'), color='orange', description=_('An inner customer VLAN (C-VLAN)')),
     ]
 
 
@@ -179,7 +184,7 @@ class ServiceProtocolChoices(ChoiceSet):
     PROTOCOL_SCTP = 'sctp'
 
     CHOICES = (
-        (PROTOCOL_TCP, 'TCP'),
-        (PROTOCOL_UDP, 'UDP'),
-        (PROTOCOL_SCTP, 'SCTP'),
+        Choice(PROTOCOL_TCP, 'TCP'),
+        Choice(PROTOCOL_UDP, 'UDP'),
+        Choice(PROTOCOL_SCTP, 'SCTP'),
     )

+ 10 - 9
netbox/ipam/forms/bulk_edit.py

@@ -12,6 +12,7 @@ from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditFor
 from tenancy.models import Tenant
 from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
+    ChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     GenericObjectChoiceField,
@@ -203,7 +204,7 @@ class PrefixBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         required=False
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(PrefixStatusChoices),
         required=False
@@ -247,7 +248,7 @@ class IPRangeBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         required=False
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(IPRangeStatusChoices),
         required=False
@@ -294,12 +295,12 @@ class IPAddressBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         required=False
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(IPAddressStatusChoices),
         required=False
     )
-    role = forms.ChoiceField(
+    role = ChoiceField(
         label=_('Role'),
         choices=add_blank_choice(IPAddressRoleChoices),
         required=False
@@ -321,7 +322,7 @@ class IPAddressBulkEditForm(PrimaryModelBulkEditForm):
 
 
 class FHRPGroupBulkEditForm(PrimaryModelBulkEditForm):
-    protocol = forms.ChoiceField(
+    protocol = ChoiceField(
         label=_('Protocol'),
         choices=add_blank_choice(FHRPGroupProtocolChoices),
         required=False
@@ -331,7 +332,7 @@ class FHRPGroupBulkEditForm(PrimaryModelBulkEditForm):
         required=False,
         label=_('Group ID')
     )
-    auth_type = forms.ChoiceField(
+    auth_type = ChoiceField(
         choices=add_blank_choice(FHRPGroupAuthTypeChoices),
         required=False,
         label=_('Authentication type')
@@ -415,7 +416,7 @@ class VLANBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         required=False
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(VLANStatusChoices),
         required=False
@@ -425,7 +426,7 @@ class VLANBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Role.objects.all(),
         required=False
     )
-    qinq_role = forms.ChoiceField(
+    qinq_role = ChoiceField(
         label=_('Q-in-Q role'),
         choices=add_blank_choice(VLANQinQRoleChoices),
         required=False
@@ -475,7 +476,7 @@ class VLANTranslationRuleBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class ServiceTemplateBulkEditForm(PrimaryModelBulkEditForm):
-    protocol = forms.ChoiceField(
+    protocol = ChoiceField(
         label=_('Protocol'),
         choices=add_blank_choice(ServiceProtocolChoices),
         required=False

+ 68 - 1
netbox/ipam/forms/model_forms.py

@@ -15,11 +15,13 @@ from tenancy.forms import TenancyForm
 from utilities.exceptions import PermissionsViolation
 from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
+    ChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     GenericObjectChoiceField,
     NumericArrayField,
     NumericRangeArrayField,
+    TypedChoiceField,
 )
 from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
 from utilities.forms.widgets import DatePicker
@@ -203,6 +205,12 @@ class RoleForm(OrganizationalModelForm):
 
 
 class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=PrefixStatusChoices,
+        initial=PrefixStatusChoices.STATUS_ACTIVE,
+        help_text=_('Operational status of this prefix'),
+    )
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
@@ -267,6 +275,12 @@ class PrefixBulkAddForm(PrefixForm):
 
 
 class IPRangeForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=IPRangeStatusChoices,
+        initial=IPRangeStatusChoices.STATUS_ACTIVE,
+        help_text=_('Operational status of this range'),
+    )
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
@@ -296,6 +310,18 @@ class IPRangeForm(TenancyForm, PrimaryModelForm):
 
 
 class IPAddressForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=IPAddressStatusChoices,
+        initial=IPAddressStatusChoices.STATUS_ACTIVE,
+        help_text=_('The operational status of this IP'),
+    )
+    role = TypedChoiceField(
+        label=_('Role'),
+        choices=add_blank_choice(IPAddressRoleChoices),
+        required=False,
+        help_text=_('The functional role of this IP'),
+    )
     interface = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         required=False,
@@ -483,6 +509,18 @@ class IPAddressForm(TenancyForm, PrimaryModelForm):
 
 
 class IPAddressBulkAddForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=IPAddressStatusChoices,
+        initial=IPAddressStatusChoices.STATUS_ACTIVE,
+        help_text=_('The operational status of this IP'),
+    )
+    role = TypedChoiceField(
+        label=_('Role'),
+        choices=add_blank_choice(IPAddressRoleChoices),
+        required=False,
+        help_text=_('The functional role of this IP'),
+    )
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
@@ -515,6 +553,15 @@ class IPAddressAssignForm(forms.Form):
 
 
 class FHRPGroupForm(PrimaryModelForm):
+    protocol = ChoiceField(
+        label=_('Protocol'),
+        choices=FHRPGroupProtocolChoices,
+    )
+    auth_type = TypedChoiceField(
+        label=_('Authentication type'),
+        choices=add_blank_choice(FHRPGroupAuthTypeChoices),
+        required=False,
+    )
 
     # Optionally create a new IPAddress along with the FHRPGroup
     ip_vrf = DynamicModelChoiceField(
@@ -526,7 +573,7 @@ class FHRPGroupForm(PrimaryModelForm):
         required=False,
         label=_('Address')
     )
-    ip_status = forms.ChoiceField(
+    ip_status = ChoiceField(
         choices=add_blank_choice(IPAddressStatusChoices),
         required=False,
         label=_('Status')
@@ -646,6 +693,18 @@ class VLANGroupForm(GenericObjectFormMixin, TenancyForm, OrganizationalModelForm
 
 
 class VLANForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=VLANStatusChoices,
+        initial=VLANStatusChoices.STATUS_ACTIVE,
+        help_text=_('Operational status of this VLAN'),
+    )
+    qinq_role = TypedChoiceField(
+        label=_('Q-in-Q role'),
+        choices=add_blank_choice(VLANQinQRoleChoices),
+        required=False,
+        help_text=_('Customer/service VLAN designation (for Q-in-Q/IEEE 802.1ad)'),
+    )
     group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         required=False,
@@ -742,6 +801,10 @@ class VLANTranslationRuleForm(NetBoxModelForm):
 
 
 class ServiceTemplateForm(PrimaryModelForm):
+    protocol = ChoiceField(
+        label=_('Protocol'),
+        choices=ServiceProtocolChoices,
+    )
     ports = NumericArrayField(
         label=_('Ports'),
         base_field=forms.IntegerField(
@@ -761,6 +824,10 @@ class ServiceTemplateForm(PrimaryModelForm):
 
 
 class ServiceForm(GenericObjectFormMixin, PrimaryModelForm):
+    protocol = ChoiceField(
+        label=_('Protocol'),
+        choices=ServiceProtocolChoices,
+    )
     parent = GenericObjectChoiceField(
         label=_('Parent'),
         content_type_queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS),

+ 62 - 62
netbox/netbox/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 from utilities.constants import CSV_DELIMITERS
 
 __all__ = (
@@ -48,33 +48,33 @@ class ColorChoices(ChoiceSet):
     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')),
+        Choice(COLOR_DARK_RED, _('Dark Red')),
+        Choice(COLOR_RED, _('Red')),
+        Choice(COLOR_PINK, _('Pink')),
+        Choice(COLOR_ROSE, _('Rose')),
+        Choice(COLOR_FUCHSIA, _('Fuchsia')),
+        Choice(COLOR_PURPLE, _('Purple')),
+        Choice(COLOR_DARK_PURPLE, _('Dark Purple')),
+        Choice(COLOR_INDIGO, _('Indigo')),
+        Choice(COLOR_BLUE, _('Blue')),
+        Choice(COLOR_LIGHT_BLUE, _('Light Blue')),
+        Choice(COLOR_CYAN, _('Cyan')),
+        Choice(COLOR_TEAL, _('Teal')),
+        Choice(COLOR_AQUA, _('Aqua')),
+        Choice(COLOR_DARK_GREEN, _('Dark Green')),
+        Choice(COLOR_GREEN, _('Green')),
+        Choice(COLOR_LIGHT_GREEN, _('Light Green')),
+        Choice(COLOR_LIME, _('Lime')),
+        Choice(COLOR_YELLOW, _('Yellow')),
+        Choice(COLOR_AMBER, _('Amber')),
+        Choice(COLOR_ORANGE, _('Orange')),
+        Choice(COLOR_DARK_ORANGE, _('Dark Orange')),
+        Choice(COLOR_BROWN, _('Brown')),
+        Choice(COLOR_LIGHT_GREY, _('Light Grey')),
+        Choice(COLOR_GREY, _('Grey')),
+        Choice(COLOR_DARK_GREY, _('Dark Grey')),
+        Choice(COLOR_BLACK, _('Black')),
+        Choice(COLOR_WHITE, _('White')),
     )
 
 
@@ -100,20 +100,20 @@ class ButtonColorChoices(ChoiceSet):
     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')),
+        Choice(DEFAULT, _('Default')),
+        Choice(BLUE, _('Blue')),
+        Choice(INDIGO, _('Indigo')),
+        Choice(PURPLE, _('Purple')),
+        Choice(PINK, _('Pink')),
+        Choice(RED, _('Red')),
+        Choice(ORANGE, _('Orange')),
+        Choice(YELLOW, _('Yellow')),
+        Choice(GREEN, _('Green')),
+        Choice(TEAL, _('Teal')),
+        Choice(CYAN, _('Cyan')),
+        Choice(GRAY, _('Gray')),
+        Choice(BLACK, _('Black')),
+        Choice(WHITE, _('White')),
     )
 
 
@@ -127,9 +127,9 @@ class ImportMethodChoices(ChoiceSet):
     DATA_FILE = 'datafile'
 
     CHOICES = [
-        (DIRECT, _('Direct')),
-        (UPLOAD, _('Upload')),
-        (DATA_FILE, _('Data file')),
+        Choice(DIRECT, _('Direct'), description=_('Enter data directly into a form field')),
+        Choice(UPLOAD, _('Upload'), description=_('Upload a file from the local filesystem')),
+        Choice(DATA_FILE, _('Data file'), description=_('Reference a file from a synced data source')),
     ]
 
 
@@ -140,10 +140,10 @@ class ImportFormatChoices(ChoiceSet):
     YAML = 'yaml'
 
     CHOICES = [
-        (AUTO, _('Auto-detect')),
-        (CSV, 'CSV'),
-        (JSON, 'JSON'),
-        (YAML, 'YAML'),
+        Choice(AUTO, _('Auto-detect')),
+        Choice(CSV, 'CSV'),
+        Choice(JSON, 'JSON'),
+        Choice(YAML, 'YAML'),
     ]
 
 
@@ -155,11 +155,11 @@ class CSVDelimiterChoices(ChoiceSet):
     TAB = CSV_DELIMITERS['tab']
 
     CHOICES = [
-        (AUTO, _('Auto-detect')),
-        (COMMA, _('Comma')),
-        (SEMICOLON, _('Semicolon')),
-        (PIPE, _('Pipe')),
-        (TAB, _('Tab')),
+        Choice(AUTO, _('Auto-detect')),
+        Choice(COMMA, _('Comma')),
+        Choice(SEMICOLON, _('Semicolon')),
+        Choice(PIPE, _('Pipe')),
+        Choice(TAB, _('Tab')),
     ]
 
 
@@ -174,10 +174,10 @@ class DistanceUnitChoices(ChoiceSet):
     UNIT_FOOT = 'ft'
 
     CHOICES = (
-        (UNIT_KILOMETER, _('Kilometers')),
-        (UNIT_METER, _('Meters')),
-        (UNIT_MILE, _('Miles')),
-        (UNIT_FOOT, _('Feet')),
+        Choice(UNIT_KILOMETER, _('Kilometers')),
+        Choice(UNIT_METER, _('Meters')),
+        Choice(UNIT_MILE, _('Miles')),
+        Choice(UNIT_FOOT, _('Feet')),
     )
 
 
@@ -192,8 +192,8 @@ class WeightUnitChoices(ChoiceSet):
     UNIT_OUNCE = 'oz'
 
     CHOICES = (
-        (UNIT_KILOGRAM, _('Kilograms')),
-        (UNIT_GRAM, _('Grams')),
-        (UNIT_POUND, _('Pounds')),
-        (UNIT_OUNCE, _('Ounces')),
+        Choice(UNIT_KILOGRAM, _('Kilograms')),
+        Choice(UNIT_GRAM, _('Grams')),
+        Choice(UNIT_POUND, _('Pounds')),
+        Choice(UNIT_OUNCE, _('Ounces')),
     )

+ 2 - 1
netbox/netbox/events.py

@@ -1,6 +1,7 @@
 from dataclasses import dataclass
 
 from netbox.registry import registry
+from utilities.choices import Choice
 
 EVENT_TYPE_KIND_INFO = 'info'
 EVENT_TYPE_KIND_SUCCESS = 'success'
@@ -31,7 +32,7 @@ def get_event_text(name):
 
 def get_event_type_choices():
     return [
-        (event.name, event.text) for event in registry['event_types'].values()
+        Choice(event.name, event.text) for event in registry['event_types'].values()
     ]
 
 

+ 3 - 2
netbox/netbox/preferences.py

@@ -3,13 +3,14 @@ from django.utils.translation import gettext_lazy as _
 
 from netbox.registry import registry
 from users.preferences import UserPreference
+from utilities.choices import Choice
 from utilities.constants import CSV_DELIMITERS
 from utilities.paginator import EnhancedPaginator
 
 
 def get_page_lengths():
     return [
-        (v, str(v)) for v in EnhancedPaginator.default_page_lengths
+        Choice(v, str(v)) for v in EnhancedPaginator.default_page_lengths
     ]
 
 
@@ -19,7 +20,7 @@ def get_csv_delimiters():
         label = _(k.title())
         if v.strip():
             label = f'{label} ({v})'
-        choices.append((k, label))
+        choices.append(Choice(k, label))
     return choices
 
 

+ 2 - 1
netbox/netbox/utils.py

@@ -1,4 +1,5 @@
 from netbox.registry import registry
+from utilities.choices import Choice
 
 __all__ = (
     'get_data_backend_choices',
@@ -12,7 +13,7 @@ def get_data_backend_choices():
     return [
         (None, '---------'),
         *[
-            (name, cls.label) for name, cls in registry['data_backends'].items()
+            Choice(name, cls.label) for name, cls in registry['data_backends'].items()
         ]
     ]
 

Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.js


Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 16 - 0
netbox/project-static/src/select/static.ts

@@ -4,6 +4,17 @@ import { NetBoxTomSelect } from './classes/netboxTomSelect';
 import { getPlugins } from './config';
 import { getElements } from '../util';
 
+// Render a static option, appending a description subtitle when one is present. TomSelect copies an
+// <option>'s data-* attributes into the option data, so a `data-description` attribute surfaces here as
+// `data.description` without any DOM lookup.
+function renderOption(data: TomOption, escape: typeof escape_html) {
+  let html = `<div>${escape(data.text)}`;
+  if (data.description) {
+    html = `${html}<br /><small class="text-secondary">${escape(data.description)}</small>`;
+  }
+  return `${html}</div>`;
+}
+
 // Initialize <select> elements with statically-defined options
 export function initStaticSelects(): void {
   for (const select of getElements<HTMLSelectElement>(
@@ -12,6 +23,11 @@ export function initStaticSelects(): void {
     new NetBoxTomSelect(select, {
       ...getPlugins(select),
       maxOptions: undefined,
+      render: {
+        // Only `option` (the dropdown list) renders the description; `item` (the compact selected-value display
+        // shown inside the input) is intentionally left at the default so the subtitle doesn't clutter it.
+        option: renderOption,
+      },
     });
   }
 }

+ 5 - 5
netbox/tenancy/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 #
 # Contacts
@@ -14,8 +14,8 @@ class ContactPriorityChoices(ChoiceSet):
     PRIORITY_INACTIVE = 'inactive'
 
     CHOICES = (
-        (PRIORITY_PRIMARY, _('Primary')),
-        (PRIORITY_SECONDARY, _('Secondary')),
-        (PRIORITY_TERTIARY, _('Tertiary')),
-        (PRIORITY_INACTIVE, _('Inactive')),
+        Choice(PRIORITY_PRIMARY, _('Primary')),
+        Choice(PRIORITY_SECONDARY, _('Secondary')),
+        Choice(PRIORITY_TERTIARY, _('Tertiary')),
+        Choice(PRIORITY_INACTIVE, _('Inactive')),
     )

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

@@ -8,7 +8,7 @@ from netbox.forms import (
     PrimaryModelBulkEditForm,
 )
 from utilities.forms import add_blank_choice
-from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.fields import ChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.rendering import FieldSet
 
 from ..choices import ContactPriorityChoices
@@ -137,7 +137,7 @@ class ContactAssignmentBulkEditForm(NetBoxModelBulkEditForm):
         queryset=ContactRole.objects.all(),
         required=False
     )
-    priority = forms.ChoiceField(
+    priority = ChoiceField(
         label=_('Priority'),
         choices=add_blank_choice(ContactPriorityChoices),
         required=False

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

@@ -2,7 +2,14 @@ from django import forms
 from django.utils.translation import gettext_lazy as _
 
 from netbox.forms import NestedGroupModelForm, NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
-from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
+from tenancy.choices import ContactPriorityChoices
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import (
+    DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField,
+    SlugField,
+    TypedChoiceField,
+)
 from utilities.forms.rendering import FieldSet, ObjectAttribute
 
 from ..models import *
@@ -118,6 +125,11 @@ class ContactForm(PrimaryModelForm):
 
 
 class ContactAssignmentForm(NetBoxModelForm):
+    priority = TypedChoiceField(
+        label=_('Priority'),
+        choices=add_blank_choice(ContactPriorityChoices),
+        required=False,
+    )
     group = DynamicModelChoiceField(
         label=_('Group'),
         queryset=ContactGroup.objects.all(),

+ 3 - 3
netbox/users/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 __all__ = (
     'TokenVersionChoices',
@@ -12,6 +12,6 @@ class TokenVersionChoices(ChoiceSet):
     V2 = 2
 
     CHOICES = [
-        (V1, _('v1')),
-        (V2, _('v2')),
+        Choice(V1, _('v1')),
+        Choice(V2, _('v2')),
     ]

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

@@ -19,6 +19,7 @@ from users.choices import TokenVersionChoices
 from users.constants import *
 from users.models import *
 from users.utils import user_may_grant_token
+from utilities.choices import Choice
 from utilities.data import flatten_dict
 from utilities.forms.fields import (
     ContentTypeMultipleChoiceField,
@@ -326,7 +327,7 @@ def get_object_types_choices():
 
         model_class = ot.model_class()
         model_name = model_class._meta.verbose_name if model_class else ot.model
-        choices_by_app[app_label].append((ot.pk, title(model_name)))
+        choices_by_app[app_label].append(Choice(ot.pk, title(model_name)))
 
     return list(choices_by_app.items())
 

+ 60 - 12
netbox/utilities/choices.py

@@ -1,18 +1,50 @@
 import enum
+from typing import Any
 
 from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
+from django.utils.functional import Promise
 from django.utils.translation import gettext_lazy as _
 
 from utilities.data import get_config_value_ci
 from utilities.string import enum_key
 
 __all__ = (
+    'Choice',
     'ChoiceSet',
     'unpack_grouped_choices',
 )
 
 
+class Choice(tuple):
+    """
+    A single choice within a ChoiceSet. Carries the choice's label, and optionally a color and a description, in a
+    single object. A Choice **is** a `(value, label)` two-tuple for backward compatibility with the plain-tuple choice
+    format (so it satisfies `isinstance(choice, tuple)` checks in Django, DRF, etc.); its `color` and `description`
+    are exposed only as attributes.
+    """
+    value: Any
+    label: str | Promise
+    color: str | None
+    description: str | Promise | None
+
+    def __new__(cls, value, label, color=None, description=None):
+        instance = super().__new__(cls, (value, label))
+        instance.value = value
+        instance.label = label
+        instance.color = color
+        instance.description = description
+        return instance
+
+    def __getnewargs__(self):
+        # tuple's default reconstructor would call Choice((value, label)) with a single argument, which fails
+        # __new__'s (value, label, ...) signature. Supply the real constructor args so copy/deepcopy/pickle work.
+        return self.value, self.label, self.color, self.description
+
+    def __repr__(self):
+        return f'Choice(value={self.value!r}, label={self.label!r})'
+
+
 class ChoiceSetMeta(type):
     """
     Metaclass for ChoiceSet
@@ -36,21 +68,35 @@ class ChoiceSetMeta(type):
                 if extend_choices is not None:
                     attrs['CHOICES'].extend(extend_choices)
 
-        # Define choice tuples and color maps
+        # Build the normalized choice list and the derived color map. Each choice may be defined as a Choice object
+        # (which is preserved as-is so consumers can reference its color/description) or as a plain (value, label) or
+        # (value, label, color) tuple. The colors map is kept for model-level consumers (e.g. get_FOO_color()).
         attrs['_choices'] = []
         attrs['colors'] = {}
+
+        def register(entry):
+            # A choice may be given as a dict (e.g. from FIELD_CHOICES config, to avoid importing Choice), a Choice
+            # object, or a legacy (value, label[, color]) tuple. Dicts and Choices are preserved as Choice objects so
+            # consumers can reference their color/description; legacy tuples are reduced to (value, label). Any color
+            # is also recorded on the colors map for model-level consumers.
+            if isinstance(entry, dict):
+                entry = Choice(**entry)
+            if isinstance(entry, Choice):
+                if entry.color is not None:
+                    attrs['colors'][entry.value] = entry.color
+                return entry
+            value, label = entry[0], entry[1]
+            if len(entry) >= 3:
+                attrs['colors'][value] = entry[2]
+            return value, label
+
         for choice in attrs['CHOICES']:
-            if isinstance(choice[1], (list, tuple)):
-                grouped_choices = []
-                for c in choice[1]:
-                    grouped_choices.append((c[0], c[1]))
-                    if len(c) == 3:
-                        attrs['colors'][c[0]] = c[2]
+            # A grouped choice is a (group_label, [members]) tuple; Choice and dict entries are always flat choices
+            if not isinstance(choice, (Choice, dict)) and isinstance(choice[1], (list, tuple)):
+                grouped_choices = [register(c) for c in choice[1]]
                 attrs['_choices'].append((choice[0], grouped_choices))
             else:
-                attrs['_choices'].append((choice[0], choice[1]))
-                if len(choice) == 3:
-                    attrs['colors'][choice[0]] = choice[2]
+                attrs['_choices'].append(register(choice))
 
         return super().__new__(mcs, name, bases, attrs)
 
@@ -64,8 +110,10 @@ class ChoiceSetMeta(type):
 
 class ChoiceSet(metaclass=ChoiceSetMeta):
     """
-    Holds an iterable of choice tuples suitable for passing to a Django model or form field. Choices can be defined
-    statically within the class as CHOICES and/or gleaned from the FIELD_CHOICES configuration parameter.
+    Holds an iterable of choices suitable for passing to a Django model or form field. Choices can be defined
+    statically within the class as CHOICES and/or gleaned from the FIELD_CHOICES configuration parameter. Each
+    choice may be defined as a Choice object (to carry a color and/or description) or as a plain (value, label) or
+    (value, label, color) tuple.
     """
     CHOICES = list()
 

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

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

+ 100 - 0
netbox/utilities/forms/fields/choices.py

@@ -0,0 +1,100 @@
+from django import forms
+
+from utilities.choices import Choice
+from utilities.forms import widgets
+
+__all__ = (
+    'ChoiceField',
+    'MultipleChoiceField',
+    'TypedChoiceField',
+)
+
+
+def _map_choice_attr(choices, attr):
+    """
+    Build a {value: attr_value} mapping from the Choice objects among an iterable of choices (descending into
+    optgroups), for the given Choice attribute (e.g. 'description' or 'color'). Values of None are omitted.
+
+    Django flattens choices to plain (value, label) tuples before they reach the widget, so this is collected from
+    the field's original choices, where the Choice objects are still intact. A ChoiceSet is iterable (yielding its
+    Choice objects); a plain callable (lazy choices) is not, and yields an empty mapping.
+    """
+    mapping = {}
+    try:
+        entries = iter(choices)
+    except TypeError:
+        return mapping
+    for choice in entries:
+        # Descend into an optgroup's members; a Choice is always a flat choice
+        members = [choice]
+        if not isinstance(choice, Choice) and isinstance(choice[1], (list, tuple)):
+            members = choice[1]
+        for member in members:
+            if isinstance(member, Choice) and (value := getattr(member, attr)) is not None:
+                mapping[member.value] = value
+    return mapping
+
+
+def _parent_choices_property(cls):
+    """
+    Return the `choices` property inherited from the first class after AttrChoiceMixin in cls's MRO (i.e. the
+    Django field's own property). Used to delegate to the parent getter/setter without hardcoding a specific base
+    class, so a setter override on a future Django subclass (e.g. TypedChoiceField/MultipleChoiceField) isn't
+    bypassed.
+    """
+    mro = cls.__mro__
+    for klass in mro[mro.index(AttrChoiceMixin) + 1:]:
+        if isinstance(prop := klass.__dict__.get('choices'), property):
+            return prop
+    raise AttributeError(f"No parent 'choices' property found for {cls.__name__}")  # pragma: no cover
+
+
+class AttrChoiceMixin:
+    """
+    Reads option descriptions from the Choice objects among a field's choices and passes them to a
+    description-aware Select widget for rendering as option subtitles. Set `show_descriptions=False` to suppress.
+    """
+    def __init__(self, *, choices=(), show_descriptions=True, **kwargs):
+        self.show_descriptions = show_descriptions
+        super().__init__(choices=choices, **kwargs)
+
+    def _get_choices(self):
+        return _parent_choices_property(type(self)).fget(self)
+
+    def _set_choices(self, value):
+        # Delegate to the parent setter (updates self._choices and self.widget.choices), then refresh the widget's
+        # description map from the same choices. Collecting descriptions here (rather than once in __init__) keeps
+        # them in sync should the field's choices be reassigned after construction. Descriptions are read from the
+        # raw choices, where the Choice objects are still intact, before Django normalizes them to (value, label).
+        _parent_choices_property(type(self)).fset(self, value)
+        if getattr(self, 'show_descriptions', True):
+            self.widget.descriptions = _map_choice_attr(value, 'description')
+
+    choices = property(_get_choices, _set_choices)
+
+
+class ChoiceField(AttrChoiceMixin, forms.ChoiceField):
+    """
+    Extends Django's ChoiceField to render the description defined on each Choice as an option subtitle.
+    """
+    widget = widgets.Select
+
+
+class TypedChoiceField(AttrChoiceMixin, forms.TypedChoiceField):
+    """
+    A description-aware ChoiceField for use on nullable model choice fields. Like Django's TypedChoiceField, an empty
+    selection is coerced to `empty_value` (which defaults to None here) so that a blank submission is stored as NULL
+    rather than an empty string. This mirrors the form field Django generates automatically for a nullable choice
+    field, while also rendering the description defined on each Choice as an option subtitle.
+    """
+    widget = widgets.Select
+
+    def __init__(self, *, empty_value=None, **kwargs):
+        super().__init__(empty_value=empty_value, **kwargs)
+
+
+class MultipleChoiceField(AttrChoiceMixin, forms.MultipleChoiceField):
+    """
+    Extends Django's MultipleChoiceField to render the description defined on each Choice as an option subtitle.
+    """
+    widget = widgets.SelectMultiple

+ 3 - 2
netbox/utilities/forms/utils.py

@@ -195,9 +195,10 @@ def get_selected_values(form, field_name):
 
 def add_blank_choice(choices):
     """
-    Add a blank choice to the beginning of a choices list.
+    Add a blank choice to the beginning of a choices list. Any Choice objects are preserved (rather than reduced to
+    plain tuples) so that description-aware fields can still reference their descriptions.
     """
-    return ((None, '---------'),) + tuple(choices)
+    return ((None, '---------'), *choices)
 
 
 def form_from_model(model, fields):

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

@@ -9,11 +9,44 @@ __all__ = (
     'ClearableSelect',
     'ColorSelect',
     'HTMXSelect',
+    'Select',
+    'SelectMultiple',
     'SelectWithPK',
     'SplitMultiSelectWidget',
 )
 
 
+class AttrSelectMixin:
+    """
+    Annotates each rendered <option> with a `data-description` attribute, which is displayed as subtitle text
+    beneath the option's label. Descriptions are sourced from an explicit value-to-description mapping set on
+    `descriptions`.
+    """
+    def __init__(self, *args, descriptions=None, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.descriptions = descriptions or {}
+
+    def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
+        option = super().create_option(name, value, label, selected, index, subindex, attrs)
+
+        if description := self.descriptions.get(value, ''):
+            option['attrs']['data-description'] = description
+
+        return option
+
+
+class Select(AttrSelectMixin, forms.Select):
+    """
+    A Select widget which renders an optional description beneath each option's label.
+    """
+
+
+class SelectMultiple(AttrSelectMixin, forms.SelectMultiple):
+    """
+    A SelectMultiple widget which renders an optional description beneath each option's label.
+    """
+
+
 class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
     """
     A Select widget for NullBooleanFields

+ 125 - 1
netbox/utilities/tests/test_choices.py

@@ -1,7 +1,7 @@
 from django.core.exceptions import ImproperlyConfigured
 from django.test import TestCase, override_settings
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 class ExampleChoices(ChoiceSet):
@@ -27,11 +27,109 @@ class ExampleChoices(ChoiceSet):
     )
 
 
+class ChoiceDataclassTestCase(TestCase):
+    """
+    Validate that a Choice behaves as a (value, label) two-tuple for backward compatibility.
+    """
+    def test_choice_is_tuple_compatible(self):
+        choice = Choice('a', 'A', color='red', description='desc')
+        self.assertEqual(list(choice), ['a', 'A'])
+        self.assertEqual(choice[0], 'a')
+        self.assertEqual(choice[1], 'A')
+        self.assertEqual(len(choice), 2)
+
+    def test_choice_unpacking(self):
+        value, label = Choice('a', 'A', color='red')
+        self.assertEqual(value, 'a')
+        self.assertEqual(label, 'A')
+
+    def test_choice_attributes(self):
+        choice = Choice('a', 'A')
+        self.assertIsNone(choice.color)
+        self.assertIsNone(choice.description)
+
+    def test_choice_is_copyable_and_picklable(self):
+        """A Choice must survive copy/deepcopy/pickle with all of its attributes intact."""
+        import copy
+        import pickle
+
+        choice = Choice('a', 'A', color='red', description='desc')
+        for clone in (copy.copy(choice), copy.deepcopy(choice), pickle.loads(pickle.dumps(choice))):
+            self.assertIsInstance(clone, Choice)
+            self.assertEqual(tuple(clone), ('a', 'A'))
+            self.assertEqual(clone.color, 'red')
+            self.assertEqual(clone.description, 'desc')
+
+
 class ChoiceSetTestCase(TestCase):
 
     def test_values(self):
         self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3])
 
+    def test_choice_objects_preserved_and_populate_colors(self):
+        """A ChoiceSet built from Choice objects preserves them in _choices and derives the colors map."""
+        class ChoiceObjectChoices(ChoiceSet):
+            CHOICES = [
+                Choice('a', 'A', color='red', description='First'),
+                Choice('b', 'B', color='green'),
+                Choice('c', 'C'),
+            ]
+
+        # Choice objects are preserved (and remain (value, label)-compatible)
+        entries = list(ChoiceObjectChoices)
+        self.assertTrue(all(isinstance(c, Choice) for c in entries))
+        self.assertEqual([tuple(c) for c in entries], [('a', 'A'), ('b', 'B'), ('c', 'C')])
+        self.assertEqual(entries[0].description, 'First')
+        self.assertEqual(ChoiceObjectChoices.colors, {'a': 'red', 'b': 'green'})
+
+    def test_dict_choices_normalized_to_choice(self):
+        """A ChoiceSet may define choices as dicts (e.g. from config), which normalize to Choice objects."""
+        class DictChoices(ChoiceSet):
+            CHOICES = [
+                {'value': 'a', 'label': 'A', 'color': 'red', 'description': 'First'},
+                {'value': 'b', 'label': 'B'},
+            ]
+
+        entries = list(DictChoices)
+        self.assertTrue(all(isinstance(c, Choice) for c in entries))
+        self.assertEqual([tuple(c) for c in entries], [('a', 'A'), ('b', 'B')])
+        self.assertEqual(entries[0].description, 'First')
+        self.assertEqual(DictChoices.colors, {'a': 'red'})
+
+    def test_dict_choice_with_invalid_key_raises(self):
+        """A choice dict with an unrecognized key raises a clear error."""
+        with self.assertRaises(TypeError):
+            class BadChoices(ChoiceSet):
+                CHOICES = [{'value': 'a', 'label': 'A', 'colour': 'red'}]
+
+    def test_legacy_tuples_populate_colors(self):
+        """Plain 2-/3-tuples continue to reduce to (value, label) and build the colors map."""
+        class LegacyChoices(ChoiceSet):
+            CHOICES = [
+                ('a', 'A', 'red'),
+                ('b', 'B'),
+            ]
+
+        self.assertEqual(list(LegacyChoices), [('a', 'A'), ('b', 'B')])
+        self.assertEqual(LegacyChoices.colors, {'a': 'red'})
+
+    def test_grouped_choices_with_choice_objects(self):
+        """Grouped choices normalize whether members are Choice objects or tuples."""
+        class GroupedChoices(ChoiceSet):
+            CHOICES = (
+                ('Group 1', (
+                    Choice('a', 'A', color='red', description='First'),
+                    ('b', 'B'),
+                )),
+            )
+
+        group_label, members = list(GroupedChoices)[0]
+        self.assertEqual(group_label, 'Group 1')
+        self.assertEqual([tuple(m) for m in members], [('a', 'A'), ('b', 'B')])
+        self.assertEqual(members[0].description, 'First')
+        self.assertEqual(GroupedChoices.colors, {'a': 'red'})
+        self.assertEqual(GroupedChoices.values(), ['a', 'b'])
+
     def test_key_with_non_list_choices_raises(self):
         """A ChoiceSet declaring a key must define CHOICES as a list."""
         with self.assertRaises(ImproperlyConfigured):
@@ -70,3 +168,29 @@ class FieldChoicesCaseInsensitiveTestCase(TestCase):
                 CHOICES = [('base', 'Base')]
 
             self.assertEqual(TestStatusChoices.CHOICES, [('base', 'Base'), ('extra', 'Extra')])
+
+    def test_config_choices_as_choice_objects(self):
+        """FIELD_CHOICES may provide Choice objects to define colors and descriptions."""
+        config = {'utilities.teststatus': [Choice('new', 'New', color='red', description='A new thing')]}
+        with override_settings(FIELD_CHOICES=config):
+            class TestStatusChoices(ChoiceSet):
+                key = 'TestStatus'
+                CHOICES = [('old', 'Old')]
+
+            entries = list(TestStatusChoices)
+            self.assertEqual([tuple(c) for c in entries], [('new', 'New')])
+            self.assertEqual(entries[0].description, 'A new thing')
+            self.assertEqual(TestStatusChoices.colors, {'new': 'red'})
+
+    def test_config_choices_as_dicts(self):
+        """FIELD_CHOICES may provide plain dicts, avoiding any import in configuration.py."""
+        choice = {'value': 'new', 'label': 'New', 'color': 'red', 'description': 'A new thing'}
+        with override_settings(FIELD_CHOICES={'utilities.teststatus': [choice]}):
+            class TestStatusChoices(ChoiceSet):
+                key = 'TestStatus'
+                CHOICES = [('old', 'Old')]
+
+            entries = list(TestStatusChoices)
+            self.assertEqual([tuple(c) for c in entries], [('new', 'New')])
+            self.assertEqual(entries[0].description, 'A new thing')
+            self.assertEqual(TestStatusChoices.colors, {'new': 'red'})

+ 111 - 1
netbox/utilities/tests/test_forms.py

@@ -7,7 +7,9 @@ from django.test import TestCase, override_settings
 
 from dcim.models import Site
 from netbox.choices import ImportFormatChoices
+from utilities.choices import Choice, ChoiceSet
 from utilities.forms.bulk_import import BulkImportForm
+from utilities.forms.fields import ChoiceField, MultipleChoiceField, TypedChoiceField
 from utilities.forms.fields.csv import CSVSelectWidget
 from utilities.forms.fields.dynamic import DynamicChoiceField, DynamicMultipleChoiceField
 from utilities.forms.fields.generic import GenericObjectChoiceField
@@ -15,12 +17,13 @@ from utilities.forms.forms import BulkRenameForm
 from utilities.forms.mixins import GenericObjectFormMixin
 from utilities.forms.rendering import FieldSet
 from utilities.forms.utils import (
+    add_blank_choice,
     expand_alphanumeric_pattern,
     expand_ipnetwork_pattern,
     get_capacity_unit_label,
     get_field_value,
 )
-from utilities.forms.widgets.select import AvailableOptions, HTMXSelect, SelectedOptions
+from utilities.forms.widgets.select import AvailableOptions, HTMXSelect, Select, SelectedOptions
 
 
 class ExpandIPNetworkTestCase(TestCase):
@@ -898,3 +901,110 @@ class HTMXSelectTestCase(TestCase):
     def test_hx_target_id_include_stays_on_form_fields(self):
         widget = HTMXSelect(hx_target_id='my-fieldset')
         self.assertEqual(widget.attrs['hx-include'], '#form_fields')
+
+
+class DescriptionSelectTestCase(TestCase):
+    """
+    Validate the rendering of option descriptions in static select fields.
+    """
+    class ExampleChoices(ChoiceSet):
+        FOO = 'foo'
+        BAR = 'bar'
+        CHOICES = (
+            Choice(FOO, 'Foo', description='Description of foo'),
+            Choice(BAR, 'Bar'),
+        )
+
+    def test_choiceset_descriptions_populate_widget(self):
+        field = ChoiceField(choices=self.ExampleChoices)
+        self.assertEqual(field.widget.descriptions, {self.ExampleChoices.FOO: 'Description of foo'})
+
+    def test_multiplechoicefield_descriptions_populate_widget(self):
+        field = MultipleChoiceField(choices=self.ExampleChoices)
+        self.assertEqual(field.widget.descriptions, {self.ExampleChoices.FOO: 'Description of foo'})
+
+    def test_show_descriptions_false_suppresses_descriptions(self):
+        field = ChoiceField(choices=self.ExampleChoices, show_descriptions=False)
+        self.assertEqual(field.widget.descriptions, {})
+
+    def test_add_blank_choice_preserves_descriptions(self):
+        field = ChoiceField(choices=add_blank_choice(self.ExampleChoices))
+        self.assertEqual(field.widget.descriptions, {self.ExampleChoices.FOO: 'Description of foo'})
+
+    def test_data_description_rendered_on_option(self):
+        field = ChoiceField(choices=self.ExampleChoices)
+        html = field.widget.render('test', None)
+        self.assertInHTML(
+            '<option value="foo" data-description="Description of foo">Foo</option>',
+            html
+        )
+        # Options without a description should not receive the attribute
+        self.assertInHTML('<option value="bar">Bar</option>', html)
+
+    def test_choiceset_without_descriptions(self):
+        class NoDescriptions(ChoiceSet):
+            CHOICES = (('a', 'A'),)
+
+        field = ChoiceField(choices=NoDescriptions)
+        self.assertEqual(field.widget.descriptions, {})
+
+    def test_descriptions_refresh_when_choices_reassigned(self):
+        """Reassigning a field's choices after construction should refresh the widget's description map."""
+        class OtherChoices(ChoiceSet):
+            CHOICES = (
+                Choice('baz', 'Baz', description='Description of baz'),
+            )
+
+        field = ChoiceField(choices=self.ExampleChoices)
+        self.assertEqual(field.widget.descriptions, {self.ExampleChoices.FOO: 'Description of foo'})
+
+        field.choices = OtherChoices
+        self.assertEqual(field.widget.descriptions, {'baz': 'Description of baz'})
+
+    def test_show_descriptions_false_suppresses_on_reassignment(self):
+        field = ChoiceField(choices=self.ExampleChoices, show_descriptions=False)
+        field.choices = self.ExampleChoices
+        self.assertEqual(field.widget.descriptions, {})
+
+    def test_typedchoicefield_populates_descriptions(self):
+        field = TypedChoiceField(choices=self.ExampleChoices)
+        self.assertEqual(field.widget.descriptions, {self.ExampleChoices.FOO: 'Description of foo'})
+
+    def test_typedchoicefield_empty_value_defaults_to_none(self):
+        """A blank selection on a TypedChoiceField resolves to None (for storage as NULL) rather than ''."""
+        field = TypedChoiceField(choices=add_blank_choice(self.ExampleChoices), required=False)
+        self.assertIsNone(field.empty_value)
+        self.assertIsNone(field.clean(''))
+
+    def test_explicit_descriptions_mapping(self):
+        widget = Select(choices=[('x', 'X'), ('y', 'Y')], descriptions={'x': 'Description X'})
+        html = widget.render('test', None)
+        self.assertInHTML('<option value="x" data-description="Description X">X</option>', html)
+        self.assertInHTML('<option value="y">Y</option>', html)
+
+    def test_choices_setter_delegates_through_mro(self):
+        """
+        AttrChoiceMixin must delegate to the parent field's choices setter via the MRO, not a hardcoded base,
+        so a setter override on an intermediate class is not bypassed.
+        """
+        from django import forms
+
+        from utilities.forms.fields.choices import AttrChoiceMixin
+        from utilities.forms.widgets import Select as DescriptionSelect
+
+        calls = []
+
+        class OverridingChoiceField(forms.ChoiceField):
+            def _set_choices(self, value):
+                calls.append(value)
+                forms.ChoiceField.choices.fset(self, value)
+            choices = property(forms.ChoiceField.choices.fget, _set_choices)
+
+        class CustomField(AttrChoiceMixin, OverridingChoiceField):
+            widget = DescriptionSelect
+
+        field = CustomField(choices=self.ExampleChoices)
+        # The intermediate class's setter must have been invoked (delegation not bypassed)
+        self.assertTrue(calls)
+        # And descriptions are still collected as normal
+        self.assertEqual(field.widget.descriptions, {self.ExampleChoices.FOO: 'Description of foo'})

+ 61 - 16
netbox/virtualization/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 #
 # Clusters
@@ -17,11 +17,26 @@ class ClusterStatusChoices(ChoiceSet):
     STATUS_OFFLINE = 'offline'
 
     CHOICES = [
-        (STATUS_PLANNED, _('Planned'), 'cyan'),
-        (STATUS_STAGING, _('Staging'), 'blue'),
-        (STATUS_ACTIVE, _('Active'), 'green'),
-        (STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'),
-        (STATUS_OFFLINE, _('Offline'), 'red'),
+        Choice(
+            STATUS_PLANNED, _('Planned'), color='cyan',
+            description=_('Designated for future use but not yet in service')
+        ),
+        Choice(
+            STATUS_STAGING, _('Staging'), color='blue',
+            description=_('Being prepared for production use')
+        ),
+        Choice(
+            STATUS_ACTIVE, _('Active'), color='green',
+            description=_('Fully operational and in service')
+        ),
+        Choice(
+            STATUS_DECOMMISSIONING, _('Decommissioning'), color='yellow',
+            description=_('Being removed from service')
+        ),
+        Choice(
+            STATUS_OFFLINE, _('Offline'), color='red',
+            description=_('Not currently in service')
+        ),
     ]
 
 
@@ -41,13 +56,34 @@ class VirtualMachineStatusChoices(ChoiceSet):
     STATUS_PAUSED = 'paused'
 
     CHOICES = [
-        (STATUS_OFFLINE, _('Offline'), 'gray'),
-        (STATUS_ACTIVE, _('Active'), 'green'),
-        (STATUS_PLANNED, _('Planned'), 'cyan'),
-        (STATUS_STAGED, _('Staged'), 'blue'),
-        (STATUS_FAILED, _('Failed'), 'red'),
-        (STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'),
-        (STATUS_PAUSED, _('Paused'), 'orange'),
+        Choice(
+            STATUS_OFFLINE, _('Offline'), color='gray',
+            description=_('Powered off or not currently running')
+        ),
+        Choice(
+            STATUS_ACTIVE, _('Active'), color='green',
+            description=_('Powered on and operational')
+        ),
+        Choice(
+            STATUS_PLANNED, _('Planned'), color='cyan',
+            description=_('Designated for future use but not yet provisioned')
+        ),
+        Choice(
+            STATUS_STAGED, _('Staged'), color='blue',
+            description=_('Provisioned but not yet in service')
+        ),
+        Choice(
+            STATUS_FAILED, _('Failed'), color='red',
+            description=_('In an error state or otherwise not functioning')
+        ),
+        Choice(
+            STATUS_DECOMMISSIONING, _('Decommissioning'), color='yellow',
+            description=_('Being removed from service')
+        ),
+        Choice(
+            STATUS_PAUSED, _('Paused'), color='orange',
+            description=_('Suspended with its state retained in memory')
+        ),
     ]
 
 
@@ -59,7 +95,16 @@ class VirtualMachineStartOnBootChoices(ChoiceSet):
     STATUS_LAST_STATE = 'laststate'
 
     CHOICES = [
-        (STATUS_ON, _('On'), 'green'),
-        (STATUS_OFF, _('Off'), 'gray'),
-        (STATUS_LAST_STATE, _('Last State'), 'cyan')
+        Choice(
+            STATUS_ON, _('On'), color='green',
+            description=_('Automatically start when the host boots')
+        ),
+        Choice(
+            STATUS_OFF, _('Off'), color='gray',
+            description=_('Do not start automatically when the host boots')
+        ),
+        Choice(
+            STATUS_LAST_STATE, _('Last State'), color='cyan',
+            description=_('Restore the power state the machine had before the host shut down')
+        ),
     ]

+ 5 - 5
netbox/virtualization/forms/bulk_edit.py

@@ -12,7 +12,7 @@ from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditFor
 from netbox.forms.mixins import OwnerMixin
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice
-from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.fields import ChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.utils import get_capacity_unit_label
 from utilities.forms.widgets import BulkEditNullBooleanSelect
@@ -58,7 +58,7 @@ class ClusterBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
         queryset=ClusterGroup.objects.all(),
         required=False
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(ClusterStatusChoices),
         required=False,
@@ -111,13 +111,13 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
         queryset=VirtualMachineType.objects.all(),
         required=False
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(VirtualMachineStatusChoices),
         required=False,
         initial='',
     )
-    start_on_boot = forms.ChoiceField(
+    start_on_boot = ChoiceField(
         label=_('Start on boot'),
         choices=add_blank_choice(VirtualMachineStartOnBootChoices),
         required=False,
@@ -240,7 +240,7 @@ class VMInterfaceBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
         max_length=100,
         required=False
     )
-    mode = forms.ChoiceField(
+    mode = ChoiceField(
         label=_('Mode'),
         choices=add_blank_choice(InterfaceModeChoices),
         required=False

+ 36 - 2
netbox/virtualization/forms/model_forms.py

@@ -5,6 +5,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 
+from dcim.choices import InterfaceModeChoices
 from dcim.forms.common import InterfaceCommonForm
 from dcim.forms.mixins import ScopedForm
 from dcim.models import Device, DeviceRole, MACAddress, Platform, Rack, Region, Site, SiteGroup
@@ -14,11 +15,23 @@ from ipam.models import VLAN, VRF, IPAddress, VLANGroup, VLANTranslationPolicy
 from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from netbox.forms.mixins import OwnerMixin
 from tenancy.forms import TenancyForm
-from utilities.forms import ConfirmationForm, get_field_value
-from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField
+from utilities.forms import ConfirmationForm, add_blank_choice, get_field_value
+from utilities.forms.fields import (
+    ChoiceField,
+    DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField,
+    JSONField,
+    SlugField,
+    TypedChoiceField,
+)
 from utilities.forms.rendering import FieldSet
 from utilities.forms.utils import get_capacity_unit_label
 from utilities.forms.widgets import HTMXSelect
+from virtualization.choices import (
+    ClusterStatusChoices,
+    VirtualMachineStartOnBootChoices,
+    VirtualMachineStatusChoices,
+)
 
 from ..models import *
 
@@ -60,6 +73,11 @@ class ClusterGroupForm(OrganizationalModelForm):
 
 
 class ClusterForm(TenancyForm, ScopedForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=ClusterStatusChoices,
+        initial=ClusterStatusChoices.STATUS_ACTIVE,
+    )
     type = DynamicModelChoiceField(
         label=_('Type'),
         queryset=ClusterType.objects.all(),
@@ -194,6 +212,16 @@ class VirtualMachineTypeForm(PrimaryModelForm):
 
 
 class VirtualMachineForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=VirtualMachineStatusChoices,
+        initial=VirtualMachineStatusChoices.STATUS_ACTIVE,
+    )
+    start_on_boot = ChoiceField(
+        label=_('Start on boot'),
+        choices=VirtualMachineStartOnBootChoices,
+        initial=VirtualMachineStartOnBootChoices.STATUS_OFF,
+    )
     virtual_machine_type = forms.ModelChoiceField(
         label=_('Type'),
         queryset=VirtualMachineType.objects.all(),
@@ -381,6 +409,12 @@ class VMComponentForm(OwnerMixin, NetBoxModelForm):
 
 
 class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
+    mode = TypedChoiceField(
+        label=_('802.1Q Mode'),
+        choices=add_blank_choice(InterfaceModeChoices),
+        required=False,
+        help_text=_('IEEE 802.1Q tagging strategy'),
+    )
     primary_mac_address = DynamicModelChoiceField(
         queryset=MACAddress.objects.all(),
         label=_('Primary MAC address'),

+ 119 - 81
netbox/vpn/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 #
 # Tunnels
@@ -15,9 +15,14 @@ class TunnelStatusChoices(ChoiceSet):
     STATUS_DISABLED = 'disabled'
 
     CHOICES = [
-        (STATUS_PLANNED, _('Planned'), 'cyan'),
-        (STATUS_ACTIVE, _('Active'), 'green'),
-        (STATUS_DISABLED, _('Disabled'), 'red'),
+        Choice(
+            STATUS_PLANNED,
+            _('Planned'),
+            color='cyan',
+            description=_('Designated for future use but not yet in service')
+        ),
+        Choice(STATUS_ACTIVE, _('Active'), color='green', description=_('Established and carrying traffic')),
+        Choice(STATUS_DISABLED, _('Disabled'), color='red', description=_('Administratively disabled')),
     ]
 
 
@@ -32,14 +37,22 @@ class TunnelEncapsulationChoices(ChoiceSet):
     ENCAP_WIREGUARD = 'wireguard'
 
     CHOICES = [
-        (ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')),
-        (ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')),
-        (ENCAP_IP_IP, _('IP-in-IP')),
-        (ENCAP_GRE, _('GRE')),
-        (ENCAP_WIREGUARD, _('WireGuard')),
-        (ENCAP_OPENVPN, _('OpenVPN')),
-        (ENCAP_L2TP, _('L2TP')),
-        (ENCAP_PPTP, _('PPTP')),
+        Choice(
+            ENCAP_IPSEC_TRANSPORT,
+            _('IPsec - Transport'),
+            description=_('IPsec encrypting only the packet payload between endpoints')
+        ),
+        Choice(
+            ENCAP_IPSEC_TUNNEL,
+            _('IPsec - Tunnel'),
+            description=_('IPsec encrypting the entire original IP packet')
+        ),
+        Choice(ENCAP_IP_IP, _('IP-in-IP'), description=_('Encapsulation of one IP packet within another')),
+        Choice(ENCAP_GRE, _('GRE'), description=_('Generic Routing Encapsulation')),
+        Choice(ENCAP_WIREGUARD, _('WireGuard')),
+        Choice(ENCAP_OPENVPN, _('OpenVPN')),
+        Choice(ENCAP_L2TP, _('L2TP'), description=_('Layer 2 Tunneling Protocol')),
+        Choice(ENCAP_PPTP, _('PPTP'), description=_('Point-to-Point Tunneling Protocol')),
     ]
 
 
@@ -49,8 +62,8 @@ class TunnelTerminationTypeChoices(ChoiceSet):
     TYPE_VIRTUALMACHINE = 'virtualization.virtualmachine'
 
     CHOICES = (
-        (TYPE_DEVICE, _('Device')),
-        (TYPE_VIRTUALMACHINE, _('Virtual Machine')),
+        Choice(TYPE_DEVICE, _('Device')),
+        Choice(TYPE_VIRTUALMACHINE, _('Virtual Machine')),
     )
 
 
@@ -60,9 +73,9 @@ class TunnelTerminationRoleChoices(ChoiceSet):
     ROLE_SPOKE = 'spoke'
 
     CHOICES = [
-        (ROLE_PEER, _('Peer'), 'green'),
-        (ROLE_HUB, _('Hub'), 'blue'),
-        (ROLE_SPOKE, _('Spoke'), 'orange'),
+        Choice(ROLE_PEER, _('Peer'), color='green', description=_('Symmetric endpoint in a point-to-point tunnel')),
+        Choice(ROLE_HUB, _('Hub'), color='blue', description=_('Central endpoint in a hub-and-spoke topology')),
+        Choice(ROLE_SPOKE, _('Spoke'), color='orange', description=_('Remote endpoint connecting to a hub')),
     ]
 
 
@@ -75,8 +88,8 @@ class IKEVersionChoices(ChoiceSet):
     VERSION_2 = 2
 
     CHOICES = (
-        (VERSION_1, 'IKEv1'),
-        (VERSION_2, 'IKEv2'),
+        Choice(VERSION_1, 'IKEv1'),
+        Choice(VERSION_2, 'IKEv2'),
     )
 
 
@@ -85,8 +98,8 @@ class IKEModeChoices(ChoiceSet):
     MAIN = 'main'
 
     CHOICES = (
-        (AGGRESSIVE, _('Aggressive')),
-        (MAIN, _('Main')),
+        Choice(AGGRESSIVE, _('Aggressive')),
+        Choice(MAIN, _('Main')),
     )
 
 
@@ -97,10 +110,10 @@ class AuthenticationMethodChoices(ChoiceSet):
     DSA_SIGNATURES = 'dsa-signatures'
 
     CHOICES = (
-        (PRESHARED_KEYS, _('Pre-shared keys')),
-        (CERTIFICATES, _('Certificates')),
-        (RSA_SIGNATURES, _('RSA signatures')),
-        (DSA_SIGNATURES, _('DSA signatures')),
+        Choice(PRESHARED_KEYS, _('Pre-shared keys')),
+        Choice(CERTIFICATES, _('Certificates')),
+        Choice(RSA_SIGNATURES, _('RSA signatures')),
+        Choice(DSA_SIGNATURES, _('DSA signatures')),
     )
 
 
@@ -109,8 +122,8 @@ class IPSecModeChoices(ChoiceSet):
     AH = 'ah'
 
     CHOICES = (
-        (ESP, 'ESP'),
-        (AH, 'AH'),
+        Choice(ESP, 'ESP'),
+        Choice(AH, 'AH'),
     )
 
 
@@ -125,14 +138,14 @@ class EncryptionAlgorithmChoices(ChoiceSet):
     ENCRYPTION_DES = 'des-cbc'
 
     CHOICES = (
-        (ENCRYPTION_AES128_CBC, '128-bit AES (CBC)'),
-        (ENCRYPTION_AES128_GCM, '128-bit AES (GCM)'),
-        (ENCRYPTION_AES192_CBC, '192-bit AES (CBC)'),
-        (ENCRYPTION_AES192_GCM, '192-bit AES (GCM)'),
-        (ENCRYPTION_AES256_CBC, '256-bit AES (CBC)'),
-        (ENCRYPTION_AES256_GCM, '256-bit AES (GCM)'),
-        (ENCRYPTION_3DES, '3DES'),
-        (ENCRYPTION_DES, 'DES'),
+        Choice(ENCRYPTION_AES128_CBC, '128-bit AES (CBC)'),
+        Choice(ENCRYPTION_AES128_GCM, '128-bit AES (GCM)'),
+        Choice(ENCRYPTION_AES192_CBC, '192-bit AES (CBC)'),
+        Choice(ENCRYPTION_AES192_GCM, '192-bit AES (GCM)'),
+        Choice(ENCRYPTION_AES256_CBC, '256-bit AES (CBC)'),
+        Choice(ENCRYPTION_AES256_GCM, '256-bit AES (GCM)'),
+        Choice(ENCRYPTION_3DES, '3DES'),
+        Choice(ENCRYPTION_DES, 'DES'),
     )
 
 
@@ -144,11 +157,11 @@ class AuthenticationAlgorithmChoices(ChoiceSet):
     AUTH_HMAC_MD5 = 'hmac-md5'
 
     CHOICES = (
-        (AUTH_HMAC_SHA1, 'SHA-1 HMAC'),
-        (AUTH_HMAC_SHA256, 'SHA-256 HMAC'),
-        (AUTH_HMAC_SHA384, 'SHA-384 HMAC'),
-        (AUTH_HMAC_SHA512, 'SHA-512 HMAC'),
-        (AUTH_HMAC_MD5, 'MD5 HMAC'),
+        Choice(AUTH_HMAC_SHA1, 'SHA-1 HMAC'),
+        Choice(AUTH_HMAC_SHA256, 'SHA-256 HMAC'),
+        Choice(AUTH_HMAC_SHA384, 'SHA-384 HMAC'),
+        Choice(AUTH_HMAC_SHA512, 'SHA-512 HMAC'),
+        Choice(AUTH_HMAC_MD5, 'MD5 HMAC'),
     )
 
 
@@ -183,30 +196,30 @@ class DHGroupChoices(ChoiceSet):
 
     CHOICES = (
         # Strings are formatted in this manner to optimize translations
-        (GROUP_1, _('Group {n}').format(n=1)),
-        (GROUP_2, _('Group {n}').format(n=2)),
-        (GROUP_5, _('Group {n}').format(n=5)),
-        (GROUP_14, _('Group {n}').format(n=14)),
-        (GROUP_15, _('Group {n}').format(n=15)),
-        (GROUP_16, _('Group {n}').format(n=16)),
-        (GROUP_17, _('Group {n}').format(n=17)),
-        (GROUP_18, _('Group {n}').format(n=18)),
-        (GROUP_19, _('Group {n}').format(n=19)),
-        (GROUP_20, _('Group {n}').format(n=20)),
-        (GROUP_21, _('Group {n}').format(n=21)),
-        (GROUP_22, _('Group {n}').format(n=22)),
-        (GROUP_23, _('Group {n}').format(n=23)),
-        (GROUP_24, _('Group {n}').format(n=24)),
-        (GROUP_25, _('Group {n}').format(n=25)),
-        (GROUP_26, _('Group {n}').format(n=26)),
-        (GROUP_27, _('Group {n}').format(n=27)),
-        (GROUP_28, _('Group {n}').format(n=28)),
-        (GROUP_29, _('Group {n}').format(n=29)),
-        (GROUP_30, _('Group {n}').format(n=30)),
-        (GROUP_31, _('Group {n}').format(n=31)),
-        (GROUP_32, _('Group {n}').format(n=32)),
-        (GROUP_33, _('Group {n}').format(n=33)),
-        (GROUP_34, _('Group {n}').format(n=34)),
+        Choice(GROUP_1, _('Group {n}').format(n=1)),
+        Choice(GROUP_2, _('Group {n}').format(n=2)),
+        Choice(GROUP_5, _('Group {n}').format(n=5)),
+        Choice(GROUP_14, _('Group {n}').format(n=14)),
+        Choice(GROUP_15, _('Group {n}').format(n=15)),
+        Choice(GROUP_16, _('Group {n}').format(n=16)),
+        Choice(GROUP_17, _('Group {n}').format(n=17)),
+        Choice(GROUP_18, _('Group {n}').format(n=18)),
+        Choice(GROUP_19, _('Group {n}').format(n=19)),
+        Choice(GROUP_20, _('Group {n}').format(n=20)),
+        Choice(GROUP_21, _('Group {n}').format(n=21)),
+        Choice(GROUP_22, _('Group {n}').format(n=22)),
+        Choice(GROUP_23, _('Group {n}').format(n=23)),
+        Choice(GROUP_24, _('Group {n}').format(n=24)),
+        Choice(GROUP_25, _('Group {n}').format(n=25)),
+        Choice(GROUP_26, _('Group {n}').format(n=26)),
+        Choice(GROUP_27, _('Group {n}').format(n=27)),
+        Choice(GROUP_28, _('Group {n}').format(n=28)),
+        Choice(GROUP_29, _('Group {n}').format(n=29)),
+        Choice(GROUP_30, _('Group {n}').format(n=30)),
+        Choice(GROUP_31, _('Group {n}').format(n=31)),
+        Choice(GROUP_32, _('Group {n}').format(n=32)),
+        Choice(GROUP_33, _('Group {n}').format(n=33)),
+        Choice(GROUP_34, _('Group {n}').format(n=34)),
     )
 
 
@@ -232,32 +245,52 @@ class L2VPNTypeChoices(ChoiceSet):
 
     CHOICES = (
         ('VPLS', (
-            (TYPE_VPWS, 'VPWS'),
-            (TYPE_VPLS, 'VPLS'),
+            Choice(
+                TYPE_VPWS,
+                'VPWS',
+                description=_('Virtual Private Wire Service: point-to-point Layer 2 connectivity')
+            ),
+            Choice(
+                TYPE_VPLS,
+                'VPLS',
+                description=_('Virtual Private LAN Service: multipoint Layer 2 connectivity')
+            ),
         )),
         ('VXLAN', (
-            (TYPE_VXLAN, 'VXLAN'),
-            (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'),
+            Choice(TYPE_VXLAN, 'VXLAN', description=_('Virtual Extensible LAN overlay')),
+            Choice(TYPE_VXLAN_EVPN, 'VXLAN-EVPN', description=_('VXLAN with EVPN control plane')),
         )),
         ('L2VPN E-VPN', (
-            (TYPE_MPLS_EVPN, 'MPLS EVPN'),
-            (TYPE_PBB_EVPN, 'PBB EVPN'),
-            (TYPE_EVPN_VPWS, 'EVPN VPWS')
+            Choice(TYPE_MPLS_EVPN, 'MPLS EVPN', description=_('Ethernet VPN over an MPLS transport')),
+            Choice(TYPE_PBB_EVPN, 'PBB EVPN', description=_('Provider Backbone Bridging with EVPN')),
+            Choice(TYPE_EVPN_VPWS, 'EVPN VPWS', description=_('Point-to-point service using an EVPN control plane'))
         )),
         ('E-Line', (
-            (TYPE_EPL, 'EPL'),
-            (TYPE_EVPL, 'EVPL'),
+            Choice(TYPE_EPL, 'EPL', description=_('Ethernet Private Line: dedicated point-to-point service')),
+            Choice(
+                TYPE_EVPL,
+                'EVPL',
+                description=_('Ethernet Virtual Private Line: multiplexed point-to-point service')
+            ),
         )),
         ('E-LAN', (
-            (TYPE_EPLAN, _('Ethernet Private LAN')),
-            (TYPE_EVPLAN, _('Ethernet Virtual Private LAN')),
+            Choice(TYPE_EPLAN, _('Ethernet Private LAN'), description=_('Dedicated multipoint-to-multipoint service')),
+            Choice(
+                TYPE_EVPLAN,
+                _('Ethernet Virtual Private LAN'),
+                description=_('Multiplexed multipoint-to-multipoint service')
+            ),
         )),
         ('E-Tree', (
-            (TYPE_EPTREE, _('Ethernet Private Tree')),
-            (TYPE_EVPTREE, _('Ethernet Virtual Private Tree')),
+            Choice(TYPE_EPTREE, _('Ethernet Private Tree'), description=_('Dedicated rooted multipoint service')),
+            Choice(
+                TYPE_EVPTREE,
+                _('Ethernet Virtual Private Tree'),
+                description=_('Multiplexed rooted multipoint service')
+            ),
         )),
         ('Other', (
-            (TYPE_SPB, _('SPB')),
+            Choice(TYPE_SPB, _('SPB'), description=_('Shortest Path Bridging')),
         )),
     )
 
@@ -277,7 +310,12 @@ class L2VPNStatusChoices(ChoiceSet):
     STATUS_DECOMMISSIONING = 'decommissioning'
 
     CHOICES = [
-        (STATUS_ACTIVE, _('Active'), 'green'),
-        (STATUS_PLANNED, _('Planned'), 'cyan'),
-        (STATUS_DECOMMISSIONING, _('Decommissioning'), 'red'),
+        Choice(STATUS_ACTIVE, _('Active'), color='green', description=_('Established and carrying traffic')),
+        Choice(
+            STATUS_PLANNED,
+            _('Planned'),
+            color='cyan',
+            description=_('Designated for future use but not yet in service')
+        ),
+        Choice(STATUS_DECOMMISSIONING, _('Decommissioning'), color='red', description=_('Being retired from service')),
     ]

+ 16 - 16
netbox/vpn/forms/bulk_edit.py

@@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _
 from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice
-from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.fields import ChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.rendering import FieldSet
 from vpn.choices import *
 from vpn.models import *
@@ -29,7 +29,7 @@ class TunnelGroupBulkEditForm(OrganizationalModelBulkEditForm):
 
 
 class TunnelBulkEditForm(PrimaryModelBulkEditForm):
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(TunnelStatusChoices),
         required=False
@@ -39,7 +39,7 @@ class TunnelBulkEditForm(PrimaryModelBulkEditForm):
         label=_('Tunnel group'),
         required=False
     )
-    encapsulation = forms.ChoiceField(
+    encapsulation = ChoiceField(
         label=_('Encapsulation'),
         choices=add_blank_choice(TunnelEncapsulationChoices),
         required=False
@@ -71,7 +71,7 @@ class TunnelBulkEditForm(PrimaryModelBulkEditForm):
 
 
 class TunnelTerminationBulkEditForm(NetBoxModelBulkEditForm):
-    role = forms.ChoiceField(
+    role = ChoiceField(
         label=_('Role'),
         choices=add_blank_choice(TunnelTerminationRoleChoices),
         required=False
@@ -81,22 +81,22 @@ class TunnelTerminationBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class IKEProposalBulkEditForm(PrimaryModelBulkEditForm):
-    authentication_method = forms.ChoiceField(
+    authentication_method = ChoiceField(
         label=_('Authentication method'),
         choices=add_blank_choice(AuthenticationMethodChoices),
         required=False
     )
-    encryption_algorithm = forms.ChoiceField(
+    encryption_algorithm = ChoiceField(
         label=_('Encryption algorithm'),
         choices=add_blank_choice(EncryptionAlgorithmChoices),
         required=False
     )
-    authentication_algorithm = forms.ChoiceField(
+    authentication_algorithm = ChoiceField(
         label=_('Authentication algorithm'),
         choices=add_blank_choice(AuthenticationAlgorithmChoices),
         required=False
     )
-    group = forms.ChoiceField(
+    group = ChoiceField(
         label=_('Group'),
         choices=add_blank_choice(DHGroupChoices),
         required=False
@@ -119,12 +119,12 @@ class IKEProposalBulkEditForm(PrimaryModelBulkEditForm):
 
 
 class IKEPolicyBulkEditForm(PrimaryModelBulkEditForm):
-    version = forms.ChoiceField(
+    version = ChoiceField(
         label=_('Version'),
         choices=add_blank_choice(IKEVersionChoices),
         required=False
     )
-    mode = forms.ChoiceField(
+    mode = ChoiceField(
         label=_('Mode'),
         choices=add_blank_choice(IKEModeChoices),
         required=False
@@ -144,12 +144,12 @@ class IKEPolicyBulkEditForm(PrimaryModelBulkEditForm):
 
 
 class IPSecProposalBulkEditForm(PrimaryModelBulkEditForm):
-    encryption_algorithm = forms.ChoiceField(
+    encryption_algorithm = ChoiceField(
         label=_('Encryption algorithm'),
         choices=add_blank_choice(EncryptionAlgorithmChoices),
         required=False
     )
-    authentication_algorithm = forms.ChoiceField(
+    authentication_algorithm = ChoiceField(
         label=_('Authentication algorithm'),
         choices=add_blank_choice(AuthenticationAlgorithmChoices),
         required=False
@@ -176,7 +176,7 @@ class IPSecProposalBulkEditForm(PrimaryModelBulkEditForm):
 
 
 class IPSecPolicyBulkEditForm(PrimaryModelBulkEditForm):
-    pfs_group = forms.ChoiceField(
+    pfs_group = ChoiceField(
         label=_('PFS group'),
         choices=add_blank_choice(DHGroupChoices),
         required=False
@@ -192,7 +192,7 @@ class IPSecPolicyBulkEditForm(PrimaryModelBulkEditForm):
 
 
 class IPSecProfileBulkEditForm(PrimaryModelBulkEditForm):
-    mode = forms.ChoiceField(
+    mode = ChoiceField(
         label=_('Mode'),
         choices=add_blank_choice(IPSecModeChoices),
         required=False
@@ -218,11 +218,11 @@ class IPSecProfileBulkEditForm(PrimaryModelBulkEditForm):
 
 
 class L2VPNBulkEditForm(PrimaryModelBulkEditForm):
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=L2VPNStatusChoices,
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         choices=add_blank_choice(L2VPNTypeChoices),
         required=False

+ 67 - 6
netbox/vpn/forms/model_forms.py

@@ -6,7 +6,13 @@ from dcim.models import Device, Interface
 from ipam.models import VLAN, IPAddress, RouteTarget
 from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from tenancy.forms import TenancyForm
-from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
+from utilities.forms.fields import (
+    ChoiceField,
+    DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField,
+    SlugField,
+    TypedChoiceField,
+)
 from utilities.forms.rendering import FieldSet, TabbedGroups
 from utilities.forms.utils import add_blank_choice, get_field_value
 from utilities.forms.widgets import HTMXSelect
@@ -42,6 +48,15 @@ class TunnelGroupForm(OrganizationalModelForm):
 
 
 class TunnelForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=TunnelStatusChoices,
+        initial=TunnelStatusChoices.STATUS_ACTIVE,
+    )
+    encapsulation = ChoiceField(
+        label=_('Encapsulation'),
+        choices=TunnelEncapsulationChoices,
+    )
     group = DynamicModelChoiceField(
         queryset=TunnelGroup.objects.all(),
         label=_('Tunnel Group'),
@@ -70,12 +85,12 @@ class TunnelForm(TenancyForm, PrimaryModelForm):
 
 class TunnelCreateForm(TunnelForm):
     # First termination
-    termination1_role = forms.ChoiceField(
+    termination1_role = ChoiceField(
         choices=add_blank_choice(TunnelTerminationRoleChoices),
         required=False,
         label=_('Role')
     )
-    termination1_type = forms.ChoiceField(
+    termination1_type = ChoiceField(
         choices=TunnelTerminationTypeChoices,
         required=False,
         widget=HTMXSelect(hx_target_id='tunnel-termination1'),
@@ -105,12 +120,12 @@ class TunnelCreateForm(TunnelForm):
     )
 
     # Second termination
-    termination2_role = forms.ChoiceField(
+    termination2_role = ChoiceField(
         choices=add_blank_choice(TunnelTerminationRoleChoices),
         required=False,
         label=_('Role')
     )
-    termination2_type = forms.ChoiceField(
+    termination2_type = ChoiceField(
         choices=TunnelTerminationTypeChoices,
         required=False,
         widget=HTMXSelect(hx_target_id='tunnel-termination2'),
@@ -220,10 +235,15 @@ class TunnelCreateForm(TunnelForm):
 
 
 class TunnelTerminationForm(NetBoxModelForm):
+    role = ChoiceField(
+        label=_('Role'),
+        choices=TunnelTerminationRoleChoices,
+        initial=TunnelTerminationRoleChoices.ROLE_PEER,
+    )
     tunnel = DynamicModelChoiceField(
         queryset=Tunnel.objects.all()
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         choices=TunnelTerminationTypeChoices,
         widget=HTMXSelect(hx_target_id='tunnel-termination'),
         label=_('Type')
@@ -294,6 +314,19 @@ class TunnelTerminationForm(NetBoxModelForm):
 
 
 class IKEProposalForm(PrimaryModelForm):
+    authentication_method = ChoiceField(
+        label=_('Authentication method'),
+        choices=AuthenticationMethodChoices,
+    )
+    encryption_algorithm = ChoiceField(
+        label=_('Encryption algorithm'),
+        choices=EncryptionAlgorithmChoices,
+    )
+    authentication_algorithm = TypedChoiceField(
+        label=_('Authentication algorithm'),
+        choices=add_blank_choice(AuthenticationAlgorithmChoices),
+        required=False,
+    )
 
     fieldsets = (
         FieldSet('name', 'description', 'tags', name=_('Proposal')),
@@ -312,6 +345,11 @@ class IKEProposalForm(PrimaryModelForm):
 
 
 class IKEPolicyForm(PrimaryModelForm):
+    mode = TypedChoiceField(
+        label=_('Mode'),
+        choices=add_blank_choice(IKEModeChoices),
+        required=False,
+    )
     proposals = DynamicModelMultipleChoiceField(
         queryset=IKEProposal.objects.all(),
         label=_('Proposals'),
@@ -331,6 +369,16 @@ class IKEPolicyForm(PrimaryModelForm):
 
 
 class IPSecProposalForm(PrimaryModelForm):
+    encryption_algorithm = TypedChoiceField(
+        label=_('Encryption'),
+        choices=add_blank_choice(EncryptionAlgorithmChoices),
+        required=False,
+    )
+    authentication_algorithm = TypedChoiceField(
+        label=_('Authentication'),
+        choices=add_blank_choice(AuthenticationAlgorithmChoices),
+        required=False,
+    )
 
     fieldsets = (
         FieldSet('name', 'description', 'tags', name=_('Proposal')),
@@ -368,6 +416,10 @@ class IPSecPolicyForm(PrimaryModelForm):
 
 
 class IPSecProfileForm(PrimaryModelForm):
+    mode = ChoiceField(
+        label=_('Mode'),
+        choices=IPSecModeChoices,
+    )
     ike_policy = DynamicModelChoiceField(
         queryset=IKEPolicy.objects.all(),
         label=_('IKE policy')
@@ -394,6 +446,15 @@ class IPSecProfileForm(PrimaryModelForm):
 #
 
 class L2VPNForm(TenancyForm, PrimaryModelForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=L2VPNTypeChoices,
+    )
+    status = ChoiceField(
+        label=_('Status'),
+        choices=L2VPNStatusChoices,
+        initial=L2VPNStatusChoices.STATUS_ACTIVE,
+    )
     slug = SlugField()
     import_targets = DynamicModelMultipleChoiceField(
         label=_('Import targets'),

+ 229 - 211
netbox/wireless/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 class WirelessRoleChoices(ChoiceSet):
@@ -8,8 +8,8 @@ class WirelessRoleChoices(ChoiceSet):
     ROLE_STATION = 'station'
 
     CHOICES = (
-        (ROLE_AP, _('Access point')),
-        (ROLE_STATION, _('Station')),
+        Choice(ROLE_AP, _('Access point'), description=_('Provides wireless network access to client devices')),
+        Choice(ROLE_STATION, _('Station'), description=_('Client device connecting to a wireless network')),
     )
 
 
@@ -22,10 +22,20 @@ class WirelessLANStatusChoices(ChoiceSet):
     STATUS_DEPRECATED = 'deprecated'
 
     CHOICES = [
-        (STATUS_ACTIVE, _('Active'), 'green'),
-        (STATUS_RESERVED, _('Reserved'), 'cyan'),
-        (STATUS_DISABLED, _('Disabled'), 'orange'),
-        (STATUS_DEPRECATED, _('Deprecated'), 'red'),
+        Choice(STATUS_ACTIVE, _('Active'), color='green', description=_('In service and operational')),
+        Choice(STATUS_RESERVED, _('Reserved'), color='cyan', description=_('Set aside for future use')),
+        Choice(
+            STATUS_DISABLED,
+            _('Disabled'),
+            color='orange',
+            description=_('Configured but not currently in service')
+        ),
+        Choice(
+            STATUS_DEPRECATED,
+            _('Deprecated'),
+            color='red',
+            description=_('Retained for reference but no longer in use')
+        ),
     ]
 
 
@@ -240,218 +250,218 @@ class WirelessChannelChoices(ChoiceSet):
         (
             '2.4 GHz (802.11b/g/n/ax)',
             (
-                (CHANNEL_24G_1, '1 (2412 MHz)'),
-                (CHANNEL_24G_2, '2 (2417 MHz)'),
-                (CHANNEL_24G_3, '3 (2422 MHz)'),
-                (CHANNEL_24G_4, '4 (2427 MHz)'),
-                (CHANNEL_24G_5, '5 (2432 MHz)'),
-                (CHANNEL_24G_6, '6 (2437 MHz)'),
-                (CHANNEL_24G_7, '7 (2442 MHz)'),
-                (CHANNEL_24G_8, '8 (2447 MHz)'),
-                (CHANNEL_24G_9, '9 (2452 MHz)'),
-                (CHANNEL_24G_10, '10 (2457 MHz)'),
-                (CHANNEL_24G_11, '11 (2462 MHz)'),
-                (CHANNEL_24G_12, '12 (2467 MHz)'),
-                (CHANNEL_24G_13, '13 (2472 MHz)'),
+                Choice(CHANNEL_24G_1, '1 (2412 MHz)'),
+                Choice(CHANNEL_24G_2, '2 (2417 MHz)'),
+                Choice(CHANNEL_24G_3, '3 (2422 MHz)'),
+                Choice(CHANNEL_24G_4, '4 (2427 MHz)'),
+                Choice(CHANNEL_24G_5, '5 (2432 MHz)'),
+                Choice(CHANNEL_24G_6, '6 (2437 MHz)'),
+                Choice(CHANNEL_24G_7, '7 (2442 MHz)'),
+                Choice(CHANNEL_24G_8, '8 (2447 MHz)'),
+                Choice(CHANNEL_24G_9, '9 (2452 MHz)'),
+                Choice(CHANNEL_24G_10, '10 (2457 MHz)'),
+                Choice(CHANNEL_24G_11, '11 (2462 MHz)'),
+                Choice(CHANNEL_24G_12, '12 (2467 MHz)'),
+                Choice(CHANNEL_24G_13, '13 (2472 MHz)'),
             )
         ),
         (
             '5 GHz (802.11a/n/ac/ax)',
             (
-                (CHANNEL_5G_32, '32 (5160/20 MHz)'),
-                (CHANNEL_5G_34, '34 (5170/40 MHz)'),
-                (CHANNEL_5G_36, '36 (5180/20 MHz)'),
-                (CHANNEL_5G_38, '38 (5190/40 MHz)'),
-                (CHANNEL_5G_40, '40 (5200/20 MHz)'),
-                (CHANNEL_5G_42, '42 (5210/80 MHz)'),
-                (CHANNEL_5G_44, '44 (5220/20 MHz)'),
-                (CHANNEL_5G_46, '46 (5230/40 MHz)'),
-                (CHANNEL_5G_48, '48 (5240/20 MHz)'),
-                (CHANNEL_5G_50, '50 (5250/160 MHz)'),
-                (CHANNEL_5G_52, '52 (5260/20 MHz)'),
-                (CHANNEL_5G_54, '54 (5270/40 MHz)'),
-                (CHANNEL_5G_56, '56 (5280/20 MHz)'),
-                (CHANNEL_5G_58, '58 (5290/80 MHz)'),
-                (CHANNEL_5G_60, '60 (5300/20 MHz)'),
-                (CHANNEL_5G_62, '62 (5310/40 MHz)'),
-                (CHANNEL_5G_64, '64 (5320/20 MHz)'),
-                (CHANNEL_5G_100, '100 (5500/20 MHz)'),
-                (CHANNEL_5G_102, '102 (5510/40 MHz)'),
-                (CHANNEL_5G_104, '104 (5520/20 MHz)'),
-                (CHANNEL_5G_106, '106 (5530/80 MHz)'),
-                (CHANNEL_5G_108, '108 (5540/20 MHz)'),
-                (CHANNEL_5G_110, '110 (5550/40 MHz)'),
-                (CHANNEL_5G_112, '112 (5560/20 MHz)'),
-                (CHANNEL_5G_114, '114 (5570/160 MHz)'),
-                (CHANNEL_5G_116, '116 (5580/20 MHz)'),
-                (CHANNEL_5G_118, '118 (5590/40 MHz)'),
-                (CHANNEL_5G_120, '120 (5600/20 MHz)'),
-                (CHANNEL_5G_122, '122 (5610/80 MHz)'),
-                (CHANNEL_5G_124, '124 (5620/20 MHz)'),
-                (CHANNEL_5G_126, '126 (5630/40 MHz)'),
-                (CHANNEL_5G_128, '128 (5640/20 MHz)'),
-                (CHANNEL_5G_132, '132 (5660/20 MHz)'),
-                (CHANNEL_5G_134, '134 (5670/40 MHz)'),
-                (CHANNEL_5G_136, '136 (5680/20 MHz)'),
-                (CHANNEL_5G_138, '138 (5690/80 MHz)'),
-                (CHANNEL_5G_140, '140 (5700/20 MHz)'),
-                (CHANNEL_5G_142, '142 (5710/40 MHz)'),
-                (CHANNEL_5G_144, '144 (5720/20 MHz)'),
-                (CHANNEL_5G_149, '149 (5745/20 MHz)'),
-                (CHANNEL_5G_151, '151 (5755/40 MHz)'),
-                (CHANNEL_5G_153, '153 (5765/20 MHz)'),
-                (CHANNEL_5G_155, '155 (5775/80 MHz)'),
-                (CHANNEL_5G_157, '157 (5785/20 MHz)'),
-                (CHANNEL_5G_159, '159 (5795/40 MHz)'),
-                (CHANNEL_5G_161, '161 (5805/20 MHz)'),
-                (CHANNEL_5G_163, '163 (5815/160 MHz)'),
-                (CHANNEL_5G_165, '165 (5825/20 MHz)'),
-                (CHANNEL_5G_167, '167 (5835/40 MHz)'),
-                (CHANNEL_5G_169, '169 (5845/20 MHz)'),
-                (CHANNEL_5G_171, '171 (5855/80 MHz)'),
-                (CHANNEL_5G_173, '173 (5865/20 MHz)'),
-                (CHANNEL_5G_175, '175 (5875/40 MHz)'),
-                (CHANNEL_5G_177, '177 (5885/20 MHz)'),
+                Choice(CHANNEL_5G_32, '32 (5160/20 MHz)'),
+                Choice(CHANNEL_5G_34, '34 (5170/40 MHz)'),
+                Choice(CHANNEL_5G_36, '36 (5180/20 MHz)'),
+                Choice(CHANNEL_5G_38, '38 (5190/40 MHz)'),
+                Choice(CHANNEL_5G_40, '40 (5200/20 MHz)'),
+                Choice(CHANNEL_5G_42, '42 (5210/80 MHz)'),
+                Choice(CHANNEL_5G_44, '44 (5220/20 MHz)'),
+                Choice(CHANNEL_5G_46, '46 (5230/40 MHz)'),
+                Choice(CHANNEL_5G_48, '48 (5240/20 MHz)'),
+                Choice(CHANNEL_5G_50, '50 (5250/160 MHz)'),
+                Choice(CHANNEL_5G_52, '52 (5260/20 MHz)'),
+                Choice(CHANNEL_5G_54, '54 (5270/40 MHz)'),
+                Choice(CHANNEL_5G_56, '56 (5280/20 MHz)'),
+                Choice(CHANNEL_5G_58, '58 (5290/80 MHz)'),
+                Choice(CHANNEL_5G_60, '60 (5300/20 MHz)'),
+                Choice(CHANNEL_5G_62, '62 (5310/40 MHz)'),
+                Choice(CHANNEL_5G_64, '64 (5320/20 MHz)'),
+                Choice(CHANNEL_5G_100, '100 (5500/20 MHz)'),
+                Choice(CHANNEL_5G_102, '102 (5510/40 MHz)'),
+                Choice(CHANNEL_5G_104, '104 (5520/20 MHz)'),
+                Choice(CHANNEL_5G_106, '106 (5530/80 MHz)'),
+                Choice(CHANNEL_5G_108, '108 (5540/20 MHz)'),
+                Choice(CHANNEL_5G_110, '110 (5550/40 MHz)'),
+                Choice(CHANNEL_5G_112, '112 (5560/20 MHz)'),
+                Choice(CHANNEL_5G_114, '114 (5570/160 MHz)'),
+                Choice(CHANNEL_5G_116, '116 (5580/20 MHz)'),
+                Choice(CHANNEL_5G_118, '118 (5590/40 MHz)'),
+                Choice(CHANNEL_5G_120, '120 (5600/20 MHz)'),
+                Choice(CHANNEL_5G_122, '122 (5610/80 MHz)'),
+                Choice(CHANNEL_5G_124, '124 (5620/20 MHz)'),
+                Choice(CHANNEL_5G_126, '126 (5630/40 MHz)'),
+                Choice(CHANNEL_5G_128, '128 (5640/20 MHz)'),
+                Choice(CHANNEL_5G_132, '132 (5660/20 MHz)'),
+                Choice(CHANNEL_5G_134, '134 (5670/40 MHz)'),
+                Choice(CHANNEL_5G_136, '136 (5680/20 MHz)'),
+                Choice(CHANNEL_5G_138, '138 (5690/80 MHz)'),
+                Choice(CHANNEL_5G_140, '140 (5700/20 MHz)'),
+                Choice(CHANNEL_5G_142, '142 (5710/40 MHz)'),
+                Choice(CHANNEL_5G_144, '144 (5720/20 MHz)'),
+                Choice(CHANNEL_5G_149, '149 (5745/20 MHz)'),
+                Choice(CHANNEL_5G_151, '151 (5755/40 MHz)'),
+                Choice(CHANNEL_5G_153, '153 (5765/20 MHz)'),
+                Choice(CHANNEL_5G_155, '155 (5775/80 MHz)'),
+                Choice(CHANNEL_5G_157, '157 (5785/20 MHz)'),
+                Choice(CHANNEL_5G_159, '159 (5795/40 MHz)'),
+                Choice(CHANNEL_5G_161, '161 (5805/20 MHz)'),
+                Choice(CHANNEL_5G_163, '163 (5815/160 MHz)'),
+                Choice(CHANNEL_5G_165, '165 (5825/20 MHz)'),
+                Choice(CHANNEL_5G_167, '167 (5835/40 MHz)'),
+                Choice(CHANNEL_5G_169, '169 (5845/20 MHz)'),
+                Choice(CHANNEL_5G_171, '171 (5855/80 MHz)'),
+                Choice(CHANNEL_5G_173, '173 (5865/20 MHz)'),
+                Choice(CHANNEL_5G_175, '175 (5875/40 MHz)'),
+                Choice(CHANNEL_5G_177, '177 (5885/20 MHz)'),
             )
         ),
         (
             '6 GHz (802.11ax)',
             (
-                (CHANNEL_6G_1, '1 (5955/20 MHz)'),
-                (CHANNEL_6G_3, '3 (5965/40 MHz)'),
-                (CHANNEL_6G_5, '5 (5975/20 MHz)'),
-                (CHANNEL_6G_7, '7 (5985/80 MHz)'),
-                (CHANNEL_6G_9, '9 (5995/20 MHz)'),
-                (CHANNEL_6G_11, '11 (6005/40 MHz)'),
-                (CHANNEL_6G_13, '13 (6015/20 MHz)'),
-                (CHANNEL_6G_15, '15 (6025/160 MHz)'),
-                (CHANNEL_6G_17, '17 (6035/20 MHz)'),
-                (CHANNEL_6G_19, '19 (6045/40 MHz)'),
-                (CHANNEL_6G_21, '21 (6055/20 MHz)'),
-                (CHANNEL_6G_23, '23 (6065/80 MHz)'),
-                (CHANNEL_6G_25, '25 (6075/20 MHz)'),
-                (CHANNEL_6G_27, '27 (6085/40 MHz)'),
-                (CHANNEL_6G_29, '29 (6095/20 MHz)'),
-                (CHANNEL_6G_31, '31 (6105/320 MHz)'),
-                (CHANNEL_6G_33, '33 (6115/20 MHz)'),
-                (CHANNEL_6G_35, '35 (6125/40 MHz)'),
-                (CHANNEL_6G_37, '37 (6135/20 MHz)'),
-                (CHANNEL_6G_39, '39 (6145/80 MHz)'),
-                (CHANNEL_6G_41, '41 (6155/20 MHz)'),
-                (CHANNEL_6G_43, '43 (6165/40 MHz)'),
-                (CHANNEL_6G_45, '45 (6175/20 MHz)'),
-                (CHANNEL_6G_47, '47 (6185/160 MHz)'),
-                (CHANNEL_6G_49, '49 (6195/20 MHz)'),
-                (CHANNEL_6G_51, '51 (6205/40 MHz)'),
-                (CHANNEL_6G_53, '53 (6215/20 MHz)'),
-                (CHANNEL_6G_55, '55 (6225/80 MHz)'),
-                (CHANNEL_6G_57, '57 (6235/20 MHz)'),
-                (CHANNEL_6G_59, '59 (6245/40 MHz)'),
-                (CHANNEL_6G_61, '61 (6255/20 MHz)'),
-                (CHANNEL_6G_65, '65 (6275/20 MHz)'),
-                (CHANNEL_6G_67, '67 (6285/40 MHz)'),
-                (CHANNEL_6G_69, '69 (6295/20 MHz)'),
-                (CHANNEL_6G_71, '71 (6305/80 MHz)'),
-                (CHANNEL_6G_73, '73 (6315/20 MHz)'),
-                (CHANNEL_6G_75, '75 (6325/40 MHz)'),
-                (CHANNEL_6G_77, '77 (6335/20 MHz)'),
-                (CHANNEL_6G_79, '79 (6345/160 MHz)'),
-                (CHANNEL_6G_81, '81 (6355/20 MHz)'),
-                (CHANNEL_6G_83, '83 (6365/40 MHz)'),
-                (CHANNEL_6G_85, '85 (6375/20 MHz)'),
-                (CHANNEL_6G_87, '87 (6385/80 MHz)'),
-                (CHANNEL_6G_89, '89 (6395/20 MHz)'),
-                (CHANNEL_6G_91, '91 (6405/40 MHz)'),
-                (CHANNEL_6G_93, '93 (6415/20 MHz)'),
-                (CHANNEL_6G_95, '95 (6425/320 MHz)'),
-                (CHANNEL_6G_97, '97 (6435/20 MHz)'),
-                (CHANNEL_6G_99, '99 (6445/40 MHz)'),
-                (CHANNEL_6G_101, '101 (6455/20 MHz)'),
-                (CHANNEL_6G_103, '103 (6465/80 MHz)'),
-                (CHANNEL_6G_105, '105 (6475/20 MHz)'),
-                (CHANNEL_6G_107, '107 (6485/40 MHz)'),
-                (CHANNEL_6G_109, '109 (6495/20 MHz)'),
-                (CHANNEL_6G_111, '111 (6505/160 MHz)'),
-                (CHANNEL_6G_113, '113 (6515/20 MHz)'),
-                (CHANNEL_6G_115, '115 (6525/40 MHz)'),
-                (CHANNEL_6G_117, '117 (6535/20 MHz)'),
-                (CHANNEL_6G_119, '119 (6545/80 MHz)'),
-                (CHANNEL_6G_121, '121 (6555/20 MHz)'),
-                (CHANNEL_6G_123, '123 (6565/40 MHz)'),
-                (CHANNEL_6G_125, '125 (6575/20 MHz)'),
-                (CHANNEL_6G_129, '129 (6595/20 MHz)'),
-                (CHANNEL_6G_131, '131 (6605/40 MHz)'),
-                (CHANNEL_6G_133, '133 (6615/20 MHz)'),
-                (CHANNEL_6G_135, '135 (6625/80 MHz)'),
-                (CHANNEL_6G_137, '137 (6635/20 MHz)'),
-                (CHANNEL_6G_139, '139 (6645/40 MHz)'),
-                (CHANNEL_6G_141, '141 (6655/20 MHz)'),
-                (CHANNEL_6G_143, '143 (6665/160 MHz)'),
-                (CHANNEL_6G_145, '145 (6675/20 MHz)'),
-                (CHANNEL_6G_147, '147 (6685/40 MHz)'),
-                (CHANNEL_6G_149, '149 (6695/20 MHz)'),
-                (CHANNEL_6G_151, '151 (6705/80 MHz)'),
-                (CHANNEL_6G_153, '153 (6715/20 MHz)'),
-                (CHANNEL_6G_155, '155 (6725/40 MHz)'),
-                (CHANNEL_6G_157, '157 (6735/20 MHz)'),
-                (CHANNEL_6G_159, '159 (6745/320 MHz)'),
-                (CHANNEL_6G_161, '161 (6755/20 MHz)'),
-                (CHANNEL_6G_163, '163 (6765/40 MHz)'),
-                (CHANNEL_6G_165, '165 (6775/20 MHz)'),
-                (CHANNEL_6G_167, '167 (6785/80 MHz)'),
-                (CHANNEL_6G_169, '169 (6795/20 MHz)'),
-                (CHANNEL_6G_171, '171 (6805/40 MHz)'),
-                (CHANNEL_6G_173, '173 (6815/20 MHz)'),
-                (CHANNEL_6G_175, '175 (6825/160 MHz)'),
-                (CHANNEL_6G_177, '177 (6835/20 MHz)'),
-                (CHANNEL_6G_179, '179 (6845/40 MHz)'),
-                (CHANNEL_6G_181, '181 (6855/20 MHz)'),
-                (CHANNEL_6G_183, '183 (6865/80 MHz)'),
-                (CHANNEL_6G_185, '185 (6875/20 MHz)'),
-                (CHANNEL_6G_187, '187 (6885/40 MHz)'),
-                (CHANNEL_6G_189, '189 (6895/20 MHz)'),
-                (CHANNEL_6G_193, '193 (6915/20 MHz)'),
-                (CHANNEL_6G_195, '195 (6925/40 MHz)'),
-                (CHANNEL_6G_197, '197 (6935/20 MHz)'),
-                (CHANNEL_6G_199, '199 (6945/80 MHz)'),
-                (CHANNEL_6G_201, '201 (6955/20 MHz)'),
-                (CHANNEL_6G_203, '203 (6965/40 MHz)'),
-                (CHANNEL_6G_205, '205 (6975/20 MHz)'),
-                (CHANNEL_6G_207, '207 (6985/160 MHz)'),
-                (CHANNEL_6G_209, '209 (6995/20 MHz)'),
-                (CHANNEL_6G_211, '211 (7005/40 MHz)'),
-                (CHANNEL_6G_213, '213 (7015/20 MHz)'),
-                (CHANNEL_6G_215, '215 (7025/80 MHz)'),
-                (CHANNEL_6G_217, '217 (7035/20 MHz)'),
-                (CHANNEL_6G_219, '219 (7045/40 MHz)'),
-                (CHANNEL_6G_221, '221 (7055/20 MHz)'),
-                (CHANNEL_6G_225, '225 (7075/20 MHz)'),
-                (CHANNEL_6G_227, '227 (7085/40 MHz)'),
-                (CHANNEL_6G_229, '229 (7095/20 MHz)'),
-                (CHANNEL_6G_233, '233 (7115/20 MHz)'),
+                Choice(CHANNEL_6G_1, '1 (5955/20 MHz)'),
+                Choice(CHANNEL_6G_3, '3 (5965/40 MHz)'),
+                Choice(CHANNEL_6G_5, '5 (5975/20 MHz)'),
+                Choice(CHANNEL_6G_7, '7 (5985/80 MHz)'),
+                Choice(CHANNEL_6G_9, '9 (5995/20 MHz)'),
+                Choice(CHANNEL_6G_11, '11 (6005/40 MHz)'),
+                Choice(CHANNEL_6G_13, '13 (6015/20 MHz)'),
+                Choice(CHANNEL_6G_15, '15 (6025/160 MHz)'),
+                Choice(CHANNEL_6G_17, '17 (6035/20 MHz)'),
+                Choice(CHANNEL_6G_19, '19 (6045/40 MHz)'),
+                Choice(CHANNEL_6G_21, '21 (6055/20 MHz)'),
+                Choice(CHANNEL_6G_23, '23 (6065/80 MHz)'),
+                Choice(CHANNEL_6G_25, '25 (6075/20 MHz)'),
+                Choice(CHANNEL_6G_27, '27 (6085/40 MHz)'),
+                Choice(CHANNEL_6G_29, '29 (6095/20 MHz)'),
+                Choice(CHANNEL_6G_31, '31 (6105/320 MHz)'),
+                Choice(CHANNEL_6G_33, '33 (6115/20 MHz)'),
+                Choice(CHANNEL_6G_35, '35 (6125/40 MHz)'),
+                Choice(CHANNEL_6G_37, '37 (6135/20 MHz)'),
+                Choice(CHANNEL_6G_39, '39 (6145/80 MHz)'),
+                Choice(CHANNEL_6G_41, '41 (6155/20 MHz)'),
+                Choice(CHANNEL_6G_43, '43 (6165/40 MHz)'),
+                Choice(CHANNEL_6G_45, '45 (6175/20 MHz)'),
+                Choice(CHANNEL_6G_47, '47 (6185/160 MHz)'),
+                Choice(CHANNEL_6G_49, '49 (6195/20 MHz)'),
+                Choice(CHANNEL_6G_51, '51 (6205/40 MHz)'),
+                Choice(CHANNEL_6G_53, '53 (6215/20 MHz)'),
+                Choice(CHANNEL_6G_55, '55 (6225/80 MHz)'),
+                Choice(CHANNEL_6G_57, '57 (6235/20 MHz)'),
+                Choice(CHANNEL_6G_59, '59 (6245/40 MHz)'),
+                Choice(CHANNEL_6G_61, '61 (6255/20 MHz)'),
+                Choice(CHANNEL_6G_65, '65 (6275/20 MHz)'),
+                Choice(CHANNEL_6G_67, '67 (6285/40 MHz)'),
+                Choice(CHANNEL_6G_69, '69 (6295/20 MHz)'),
+                Choice(CHANNEL_6G_71, '71 (6305/80 MHz)'),
+                Choice(CHANNEL_6G_73, '73 (6315/20 MHz)'),
+                Choice(CHANNEL_6G_75, '75 (6325/40 MHz)'),
+                Choice(CHANNEL_6G_77, '77 (6335/20 MHz)'),
+                Choice(CHANNEL_6G_79, '79 (6345/160 MHz)'),
+                Choice(CHANNEL_6G_81, '81 (6355/20 MHz)'),
+                Choice(CHANNEL_6G_83, '83 (6365/40 MHz)'),
+                Choice(CHANNEL_6G_85, '85 (6375/20 MHz)'),
+                Choice(CHANNEL_6G_87, '87 (6385/80 MHz)'),
+                Choice(CHANNEL_6G_89, '89 (6395/20 MHz)'),
+                Choice(CHANNEL_6G_91, '91 (6405/40 MHz)'),
+                Choice(CHANNEL_6G_93, '93 (6415/20 MHz)'),
+                Choice(CHANNEL_6G_95, '95 (6425/320 MHz)'),
+                Choice(CHANNEL_6G_97, '97 (6435/20 MHz)'),
+                Choice(CHANNEL_6G_99, '99 (6445/40 MHz)'),
+                Choice(CHANNEL_6G_101, '101 (6455/20 MHz)'),
+                Choice(CHANNEL_6G_103, '103 (6465/80 MHz)'),
+                Choice(CHANNEL_6G_105, '105 (6475/20 MHz)'),
+                Choice(CHANNEL_6G_107, '107 (6485/40 MHz)'),
+                Choice(CHANNEL_6G_109, '109 (6495/20 MHz)'),
+                Choice(CHANNEL_6G_111, '111 (6505/160 MHz)'),
+                Choice(CHANNEL_6G_113, '113 (6515/20 MHz)'),
+                Choice(CHANNEL_6G_115, '115 (6525/40 MHz)'),
+                Choice(CHANNEL_6G_117, '117 (6535/20 MHz)'),
+                Choice(CHANNEL_6G_119, '119 (6545/80 MHz)'),
+                Choice(CHANNEL_6G_121, '121 (6555/20 MHz)'),
+                Choice(CHANNEL_6G_123, '123 (6565/40 MHz)'),
+                Choice(CHANNEL_6G_125, '125 (6575/20 MHz)'),
+                Choice(CHANNEL_6G_129, '129 (6595/20 MHz)'),
+                Choice(CHANNEL_6G_131, '131 (6605/40 MHz)'),
+                Choice(CHANNEL_6G_133, '133 (6615/20 MHz)'),
+                Choice(CHANNEL_6G_135, '135 (6625/80 MHz)'),
+                Choice(CHANNEL_6G_137, '137 (6635/20 MHz)'),
+                Choice(CHANNEL_6G_139, '139 (6645/40 MHz)'),
+                Choice(CHANNEL_6G_141, '141 (6655/20 MHz)'),
+                Choice(CHANNEL_6G_143, '143 (6665/160 MHz)'),
+                Choice(CHANNEL_6G_145, '145 (6675/20 MHz)'),
+                Choice(CHANNEL_6G_147, '147 (6685/40 MHz)'),
+                Choice(CHANNEL_6G_149, '149 (6695/20 MHz)'),
+                Choice(CHANNEL_6G_151, '151 (6705/80 MHz)'),
+                Choice(CHANNEL_6G_153, '153 (6715/20 MHz)'),
+                Choice(CHANNEL_6G_155, '155 (6725/40 MHz)'),
+                Choice(CHANNEL_6G_157, '157 (6735/20 MHz)'),
+                Choice(CHANNEL_6G_159, '159 (6745/320 MHz)'),
+                Choice(CHANNEL_6G_161, '161 (6755/20 MHz)'),
+                Choice(CHANNEL_6G_163, '163 (6765/40 MHz)'),
+                Choice(CHANNEL_6G_165, '165 (6775/20 MHz)'),
+                Choice(CHANNEL_6G_167, '167 (6785/80 MHz)'),
+                Choice(CHANNEL_6G_169, '169 (6795/20 MHz)'),
+                Choice(CHANNEL_6G_171, '171 (6805/40 MHz)'),
+                Choice(CHANNEL_6G_173, '173 (6815/20 MHz)'),
+                Choice(CHANNEL_6G_175, '175 (6825/160 MHz)'),
+                Choice(CHANNEL_6G_177, '177 (6835/20 MHz)'),
+                Choice(CHANNEL_6G_179, '179 (6845/40 MHz)'),
+                Choice(CHANNEL_6G_181, '181 (6855/20 MHz)'),
+                Choice(CHANNEL_6G_183, '183 (6865/80 MHz)'),
+                Choice(CHANNEL_6G_185, '185 (6875/20 MHz)'),
+                Choice(CHANNEL_6G_187, '187 (6885/40 MHz)'),
+                Choice(CHANNEL_6G_189, '189 (6895/20 MHz)'),
+                Choice(CHANNEL_6G_193, '193 (6915/20 MHz)'),
+                Choice(CHANNEL_6G_195, '195 (6925/40 MHz)'),
+                Choice(CHANNEL_6G_197, '197 (6935/20 MHz)'),
+                Choice(CHANNEL_6G_199, '199 (6945/80 MHz)'),
+                Choice(CHANNEL_6G_201, '201 (6955/20 MHz)'),
+                Choice(CHANNEL_6G_203, '203 (6965/40 MHz)'),
+                Choice(CHANNEL_6G_205, '205 (6975/20 MHz)'),
+                Choice(CHANNEL_6G_207, '207 (6985/160 MHz)'),
+                Choice(CHANNEL_6G_209, '209 (6995/20 MHz)'),
+                Choice(CHANNEL_6G_211, '211 (7005/40 MHz)'),
+                Choice(CHANNEL_6G_213, '213 (7015/20 MHz)'),
+                Choice(CHANNEL_6G_215, '215 (7025/80 MHz)'),
+                Choice(CHANNEL_6G_217, '217 (7035/20 MHz)'),
+                Choice(CHANNEL_6G_219, '219 (7045/40 MHz)'),
+                Choice(CHANNEL_6G_221, '221 (7055/20 MHz)'),
+                Choice(CHANNEL_6G_225, '225 (7075/20 MHz)'),
+                Choice(CHANNEL_6G_227, '227 (7085/40 MHz)'),
+                Choice(CHANNEL_6G_229, '229 (7095/20 MHz)'),
+                Choice(CHANNEL_6G_233, '233 (7115/20 MHz)'),
             )
         ),
         (
             '60 GHz (802.11ad/ay)',
             (
-                (CHANNEL_60G_1, '1 (58.32/2.16 GHz)'),
-                (CHANNEL_60G_2, '2 (60.48/2.16 GHz)'),
-                (CHANNEL_60G_3, '3 (62.64/2.16 GHz)'),
-                (CHANNEL_60G_4, '4 (64.80/2.16 GHz)'),
-                (CHANNEL_60G_5, '5 (66.96/2.16 GHz)'),
-                (CHANNEL_60G_6, '6 (69.12/2.16 GHz)'),
-                (CHANNEL_60G_9, '9 (59.40/4.32 GHz)'),
-                (CHANNEL_60G_10, '10 (61.56/4.32 GHz)'),
-                (CHANNEL_60G_11, '11 (63.72/4.32 GHz)'),
-                (CHANNEL_60G_12, '12 (65.88/4.32 GHz)'),
-                (CHANNEL_60G_13, '13 (68.04/4.32 GHz)'),
-                (CHANNEL_60G_17, '17 (60.48/6.48 GHz)'),
-                (CHANNEL_60G_18, '18 (62.64/6.48 GHz)'),
-                (CHANNEL_60G_19, '19 (64.80/6.48 GHz)'),
-                (CHANNEL_60G_20, '20 (66.96/6.48 GHz)'),
-                (CHANNEL_60G_25, '25 (61.56/8.64 GHz)'),
-                (CHANNEL_60G_26, '26 (63.72/8.64 GHz)'),
-                (CHANNEL_60G_27, '27 (65.88/8.64 GHz)'),
+                Choice(CHANNEL_60G_1, '1 (58.32/2.16 GHz)'),
+                Choice(CHANNEL_60G_2, '2 (60.48/2.16 GHz)'),
+                Choice(CHANNEL_60G_3, '3 (62.64/2.16 GHz)'),
+                Choice(CHANNEL_60G_4, '4 (64.80/2.16 GHz)'),
+                Choice(CHANNEL_60G_5, '5 (66.96/2.16 GHz)'),
+                Choice(CHANNEL_60G_6, '6 (69.12/2.16 GHz)'),
+                Choice(CHANNEL_60G_9, '9 (59.40/4.32 GHz)'),
+                Choice(CHANNEL_60G_10, '10 (61.56/4.32 GHz)'),
+                Choice(CHANNEL_60G_11, '11 (63.72/4.32 GHz)'),
+                Choice(CHANNEL_60G_12, '12 (65.88/4.32 GHz)'),
+                Choice(CHANNEL_60G_13, '13 (68.04/4.32 GHz)'),
+                Choice(CHANNEL_60G_17, '17 (60.48/6.48 GHz)'),
+                Choice(CHANNEL_60G_18, '18 (62.64/6.48 GHz)'),
+                Choice(CHANNEL_60G_19, '19 (64.80/6.48 GHz)'),
+                Choice(CHANNEL_60G_20, '20 (66.96/6.48 GHz)'),
+                Choice(CHANNEL_60G_25, '25 (61.56/8.64 GHz)'),
+                Choice(CHANNEL_60G_26, '26 (63.72/8.64 GHz)'),
+                Choice(CHANNEL_60G_27, '27 (65.88/8.64 GHz)'),
             )
         ),
     )
@@ -464,10 +474,18 @@ class WirelessAuthTypeChoices(ChoiceSet):
     TYPE_WPA_ENTERPRISE = 'wpa-enterprise'
 
     CHOICES = (
-        (TYPE_OPEN, _('Open')),
-        (TYPE_WEP, 'WEP'),
-        (TYPE_WPA_PERSONAL, _('WPA Personal (PSK)')),
-        (TYPE_WPA_ENTERPRISE, _('WPA Enterprise')),
+        Choice(TYPE_OPEN, _('Open'), description=_('No authentication or encryption')),
+        Choice(TYPE_WEP, 'WEP', description=_('Wired Equivalent Privacy (legacy, insecure)')),
+        Choice(
+            TYPE_WPA_PERSONAL,
+            _('WPA Personal (PSK)'),
+            description=_('Wi-Fi Protected Access using a shared pre-shared key')
+        ),
+        Choice(
+            TYPE_WPA_ENTERPRISE,
+            _('WPA Enterprise'),
+            description=_('Wi-Fi Protected Access using 802.1X authentication')
+        ),
     )
 
 
@@ -477,7 +495,7 @@ class WirelessAuthCipherChoices(ChoiceSet):
     CIPHER_AES = 'aes'
 
     CHOICES = (
-        (CIPHER_AUTO, _('Auto')),
-        (CIPHER_TKIP, 'TKIP'),
-        (CIPHER_AES, 'AES'),
+        Choice(CIPHER_AUTO, _('Auto'), description=_('Automatically negotiate the encryption cipher')),
+        Choice(CIPHER_TKIP, 'TKIP', description=_('Temporal Key Integrity Protocol (legacy)')),
+        Choice(CIPHER_AES, 'AES', description=_('Advanced Encryption Standard')),
     )

+ 8 - 8
netbox/wireless/forms/bulk_edit.py

@@ -8,7 +8,7 @@ from netbox.choices import *
 from netbox.forms import NestedGroupModelBulkEditForm, PrimaryModelBulkEditForm
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice
-from utilities.forms.fields import DynamicModelChoiceField
+from utilities.forms.fields import ChoiceField, DynamicModelChoiceField
 from utilities.forms.rendering import FieldSet
 from wireless.choices import *
 from wireless.constants import SSID_MAX_LENGTH
@@ -36,7 +36,7 @@ class WirelessLANGroupBulkEditForm(NestedGroupModelBulkEditForm):
 
 
 class WirelessLANBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(WirelessLANStatusChoices),
         required=False
@@ -61,12 +61,12 @@ class WirelessLANBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         required=False
     )
-    auth_type = forms.ChoiceField(
+    auth_type = ChoiceField(
         label=_('Authentication type'),
         choices=add_blank_choice(WirelessAuthTypeChoices),
         required=False
     )
-    auth_cipher = forms.ChoiceField(
+    auth_cipher = ChoiceField(
         label=_('Authentication cipher'),
         choices=add_blank_choice(WirelessAuthCipherChoices),
         required=False
@@ -93,7 +93,7 @@ class WirelessLinkBulkEditForm(PrimaryModelBulkEditForm):
         required=False,
         label=_('SSID')
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(LinkStatusChoices),
         required=False
@@ -103,12 +103,12 @@ class WirelessLinkBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         required=False
     )
-    auth_type = forms.ChoiceField(
+    auth_type = ChoiceField(
         label=_('Authentication type'),
         choices=add_blank_choice(WirelessAuthTypeChoices),
         required=False
     )
-    auth_cipher = forms.ChoiceField(
+    auth_cipher = ChoiceField(
         label=_('Authentication cipher'),
         choices=add_blank_choice(WirelessAuthCipherChoices),
         required=False
@@ -122,7 +122,7 @@ class WirelessLinkBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         required=False
     )
-    distance_unit = forms.ChoiceField(
+    distance_unit = ChoiceField(
         label=_('Distance unit'),
         choices=add_blank_choice(DistanceUnitChoices),
         required=False,

+ 44 - 1
netbox/wireless/forms/model_forms.py

@@ -1,14 +1,22 @@
 from django.forms import PasswordInput
 from django.utils.translation import gettext_lazy as _
 
+from dcim.choices import LinkStatusChoices
 from dcim.forms.mixins import ScopedForm
 from dcim.models import Device, Interface, Location, Site
 from ipam.models import VLAN
+from netbox.choices import DistanceUnitChoices
 from netbox.forms import NestedGroupModelForm, PrimaryModelForm
 from tenancy.forms import TenancyForm
-from utilities.forms.fields import DynamicModelChoiceField
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import ChoiceField, DynamicModelChoiceField, TypedChoiceField
 from utilities.forms.mixins import DistanceValidationMixin
 from utilities.forms.rendering import FieldSet, InlineFields
+from wireless.choices import (
+    WirelessAuthCipherChoices,
+    WirelessAuthTypeChoices,
+    WirelessLANStatusChoices,
+)
 from wireless.models import *
 
 __all__ = (
@@ -37,6 +45,21 @@ class WirelessLANGroupForm(NestedGroupModelForm):
 
 
 class WirelessLANForm(ScopedForm, TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=WirelessLANStatusChoices,
+        initial=WirelessLANStatusChoices.STATUS_ACTIVE,
+    )
+    auth_type = TypedChoiceField(
+        label=_('Authentication type'),
+        choices=add_blank_choice(WirelessAuthTypeChoices),
+        required=False,
+    )
+    auth_cipher = TypedChoiceField(
+        label=_('Authentication cipher'),
+        choices=add_blank_choice(WirelessAuthCipherChoices),
+        required=False,
+    )
     group = DynamicModelChoiceField(
         label=_('Group'),
         queryset=WirelessLANGroup.objects.all(),
@@ -72,6 +95,26 @@ class WirelessLANForm(ScopedForm, TenancyForm, PrimaryModelForm):
 
 
 class WirelessLinkForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=LinkStatusChoices,
+        initial=LinkStatusChoices.STATUS_CONNECTED,
+    )
+    auth_type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(WirelessAuthTypeChoices),
+        required=False,
+    )
+    auth_cipher = TypedChoiceField(
+        label=_('Cipher'),
+        choices=add_blank_choice(WirelessAuthCipherChoices),
+        required=False,
+    )
+    distance_unit = TypedChoiceField(
+        label=_('Distance unit'),
+        choices=add_blank_choice(DistanceUnitChoices),
+        required=False,
+    )
     site_a = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         required=False,

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików