Răsfoiți Sursa

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

Jeremy Stretch 5 zile în urmă
părinte
comite
a7be755e01
47 a modificat fișierele cu 2433 adăugiri și 1222 ștergeri
  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"
 !!! info "Case-Insensitive Field Identifiers"
     Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent.
     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:
     options:
       members: false
       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
 ## Dynamic Object Fields
 
 
 ::: utilities.forms.fields.DynamicModelChoiceField
 ::: utilities.forms.fields.DynamicModelChoiceField

+ 44 - 38
netbox/circuits/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 #
 #
 # Circuits
 # Circuits
@@ -18,12 +18,18 @@ class CircuitStatusChoices(ChoiceSet):
     STATUS_DECOMMISSIONED = 'decommissioned'
     STATUS_DECOMMISSIONED = 'decommissioned'
 
 
     CHOICES = [
     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'
     key = 'Circuit.commit_rate'
 
 
     CHOICES = [
     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'
     SIDE_Z = 'Z'
 
 
     CHOICES = (
     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'
     key = 'CircuitTermination.port_speed'
 
 
     CHOICES = [
     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'
     PRIORITY_INACTIVE = 'inactive'
 
 
     CHOICES = [
     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'
     ROLE_SPOKE = 'spoke'
 
 
     CHOICES = [
     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 tenancy.models import Tenant
 from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
 from utilities.forms.fields import (
+    ChoiceField,
     ColorField,
     ColorField,
     DynamicModelChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     DynamicModelMultipleChoiceField,
@@ -124,7 +125,7 @@ class CircuitBulkEditForm(PrimaryModelBulkEditForm):
             'provider': '$provider'
             'provider': '$provider'
         }
         }
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(CircuitStatusChoices),
         choices=add_blank_choice(CircuitStatusChoices),
         required=False,
         required=False,
@@ -157,7 +158,7 @@ class CircuitBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         min_value=0,
         required=False
         required=False
     )
     )
-    distance_unit = forms.ChoiceField(
+    distance_unit = ChoiceField(
         label=_('Distance unit'),
         label=_('Distance unit'),
         choices=add_blank_choice(DistanceUnitChoices),
         choices=add_blank_choice(DistanceUnitChoices),
         required=False,
         required=False,
@@ -232,7 +233,7 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
         queryset=Circuit.objects.all(),
         queryset=Circuit.objects.all(),
         required=False
         required=False
     )
     )
-    priority = forms.ChoiceField(
+    priority = ChoiceField(
         label=_('Priority'),
         label=_('Priority'),
         choices=add_blank_choice(CircuitPriorityChoices),
         choices=add_blank_choice(CircuitPriorityChoices),
         required=False
         required=False
@@ -274,7 +275,7 @@ class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
         queryset=VirtualCircuitType.objects.all(),
         queryset=VirtualCircuitType.objects.all(),
         required=False
         required=False
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(CircuitStatusChoices),
         choices=add_blank_choice(CircuitStatusChoices),
         required=False,
         required=False,
@@ -297,7 +298,7 @@ class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
 
 
 
 
 class VirtualCircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
 class VirtualCircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
-    role = forms.ChoiceField(
+    role = ChoiceField(
         label=_('Role'),
         label=_('Role'),
         choices=add_blank_choice(VirtualCircuitTerminationRoleChoices),
         choices=add_blank_choice(VirtualCircuitTerminationRoleChoices),
         required=False,
         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.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from circuits.choices import (
 from circuits.choices import (
     CircuitCommitRateChoices,
     CircuitCommitRateChoices,
+    CircuitPriorityChoices,
+    CircuitStatusChoices,
     CircuitTerminationPortSpeedChoices,
     CircuitTerminationPortSpeedChoices,
+    CircuitTerminationSideChoices,
     VirtualCircuitTerminationRoleChoices,
     VirtualCircuitTerminationRoleChoices,
 )
 )
 from circuits.constants import *
 from circuits.constants import *
 from circuits.models import *
 from circuits.models import *
 from dcim.models import Interface
 from dcim.models import Interface
 from ipam.models import ASN
 from ipam.models import ASN
+from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
-from utilities.forms import GenericObjectFormMixin
+from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
 from utilities.forms.fields import (
+    ChoiceField,
     DynamicModelChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     DynamicModelMultipleChoiceField,
     GenericObjectChoiceField,
     GenericObjectChoiceField,
     SlugField,
     SlugField,
+    TypedChoiceField,
 )
 )
 from utilities.forms.mixins import DistanceValidationMixin
 from utilities.forms.mixins import DistanceValidationMixin
 from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
 from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
@@ -134,6 +139,16 @@ class CircuitTypeForm(OrganizationalModelForm):
 
 
 
 
 class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
 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(
     provider = DynamicModelChoiceField(
         label=_('Provider'),
         label=_('Provider'),
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
@@ -185,6 +200,10 @@ class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
 
 
 
 
 class CircuitTerminationForm(GenericObjectFormMixin, NetBoxModelForm):
 class CircuitTerminationForm(GenericObjectFormMixin, NetBoxModelForm):
+    term_side = ChoiceField(
+        label=_('Termination side'),
+        choices=CircuitTerminationSideChoices,
+    )
     circuit = DynamicModelChoiceField(
     circuit = DynamicModelChoiceField(
         label=_('Circuit'),
         label=_('Circuit'),
         queryset=Circuit.objects.all(),
         queryset=Circuit.objects.all(),
@@ -236,6 +255,11 @@ class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
 
 
 
 
 class CircuitGroupAssignmentForm(GenericObjectFormMixin, NetBoxModelForm):
 class CircuitGroupAssignmentForm(GenericObjectFormMixin, NetBoxModelForm):
+    priority = TypedChoiceField(
+        label=_('Priority'),
+        choices=add_blank_choice(CircuitPriorityChoices),
+        required=False,
+    )
     group = DynamicModelChoiceField(
     group = DynamicModelChoiceField(
         label=_('Group'),
         label=_('Group'),
         queryset=CircuitGroup.objects.all(),
         queryset=CircuitGroup.objects.all(),
@@ -275,6 +299,11 @@ class VirtualCircuitTypeForm(OrganizationalModelForm):
 
 
 
 
 class VirtualCircuitForm(TenancyForm, PrimaryModelForm):
 class VirtualCircuitForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=CircuitStatusChoices,
+        initial=CircuitStatusChoices.STATUS_ACTIVE,
+    )
     provider_network = DynamicModelChoiceField(
     provider_network = DynamicModelChoiceField(
         label=_('Provider network'),
         label=_('Provider network'),
         queryset=ProviderNetwork.objects.all(),
         queryset=ProviderNetwork.objects.all(),
@@ -312,7 +341,7 @@ class VirtualCircuitTerminationForm(NetBoxModelForm):
         queryset=VirtualCircuit.objects.all(),
         queryset=VirtualCircuit.objects.all(),
         selector=True
         selector=True
     )
     )
-    role = forms.ChoiceField(
+    role = ChoiceField(
         choices=VirtualCircuitTerminationRoleChoices,
         choices=VirtualCircuitTerminationRoleChoices,
         label=_('Role')
         label=_('Role')
     )
     )

+ 26 - 26
netbox/core/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 #
 #
 # Data sources
 # Data sources
@@ -15,11 +15,11 @@ class DataSourceStatusChoices(ChoiceSet):
     FAILED = 'failed'
     FAILED = 'failed'
 
 
     CHOICES = (
     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
     REPORTS = 'reports'  # settings.REPORTS_ROOT
 
 
     CHOICES = (
     CHOICES = (
-        (SCRIPTS, _('Scripts')),
-        (REPORTS, _('Reports')),
+        Choice(SCRIPTS, _('Scripts')),
+        Choice(REPORTS, _('Reports')),
     )
     )
 
 
 
 
@@ -51,12 +51,12 @@ class JobStatusChoices(ChoiceSet):
     STATUS_FAILED = 'failed'
     STATUS_FAILED = 'failed'
 
 
     CHOICES = (
     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 = (
     ENQUEUED_STATE_CHOICES = (
@@ -78,9 +78,9 @@ class JobNotificationChoices(ChoiceSet):
     NOTIFICATION_NEVER = 'never'
     NOTIFICATION_NEVER = 'never'
 
 
     CHOICES = (
     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
     INTERVAL_WEEKLY = 60 * 24 * 7
 
 
     CHOICES = (
     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'
     ACTION_DELETE = 'delete'
 
 
     CHOICES = (
     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 core.models import *
 from netbox.forms import PrimaryModelBulkEditForm
 from netbox.forms import PrimaryModelBulkEditForm
 from netbox.utils import get_data_backend_choices
 from netbox.utils import get_data_backend_choices
+from utilities.forms.fields import ChoiceField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 
 
@@ -14,7 +15,7 @@ __all__ = (
 
 
 
 
 class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
 class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=get_data_backend_choices,
         choices=get_data_backend_choices,
         required=False
         required=False
@@ -24,7 +25,7 @@ class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
         widget=BulkEditNullBooleanSelect(),
         widget=BulkEditNullBooleanSelect(),
         label=_('Enabled')
         label=_('Enabled')
     )
     )
-    sync_interval = forms.ChoiceField(
+    sync_interval = ChoiceField(
         choices=JobIntervalChoices,
         choices=JobIntervalChoices,
         required=False,
         required=False,
         label=_('Sync interval')
         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.registry import registry
 from netbox.utils import get_data_backend_choices
 from netbox.utils import get_data_backend_choices
 from utilities.forms import get_field_value
 from utilities.forms import get_field_value
-from utilities.forms.fields import JSONField
+from utilities.forms.fields import ChoiceField, JSONField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import HTMXSelect
 from utilities.forms.widgets import HTMXSelect
 
 
@@ -27,7 +27,7 @@ EMPTY_VALUES = ('', None, [], ())
 
 
 
 
 class DataSourceForm(PrimaryModelForm):
 class DataSourceForm(PrimaryModelForm):
-    type = forms.ChoiceField(
+    type = ChoiceField(
         choices=get_data_backend_choices,
         choices=get_data_backend_choices,
         # No hx_target_id: changing type adds/removes the Backend Parameters fieldset entirely.
         # No hx_target_id: changing type adds/removes the Backend Parameters fieldset entirely.
         widget=HTMXSelect()
         widget=HTMXSelect()

Fișier diff suprimat deoarece este prea mare
+ 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 users.models import User
 from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
 from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
 from utilities.forms.fields import (
 from utilities.forms.fields import (
+    ChoiceField,
     ColorField,
     ColorField,
     DynamicModelChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     DynamicModelMultipleChoiceField,
@@ -111,7 +112,7 @@ class SiteGroupBulkEditForm(NestedGroupModelBulkEditForm):
 
 
 
 
 class SiteBulkEditForm(PrimaryModelBulkEditForm):
 class SiteBulkEditForm(PrimaryModelBulkEditForm):
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(SiteStatusChoices),
         choices=add_blank_choice(SiteStatusChoices),
         required=False,
         required=False,
@@ -185,7 +186,7 @@ class LocationBulkEditForm(NestedGroupModelBulkEditForm):
             'site_id': '$site'
             'site_id': '$site'
         }
         }
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(LocationStatusChoices),
         choices=add_blank_choice(LocationStatusChoices),
         required=False,
         required=False,
@@ -236,12 +237,12 @@ class RackTypeBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False
         required=False
     )
     )
-    form_factor = forms.ChoiceField(
+    form_factor = ChoiceField(
         label=_('Form factor'),
         label=_('Form factor'),
         choices=add_blank_choice(RackFormFactorChoices),
         choices=add_blank_choice(RackFormFactorChoices),
         required=False
         required=False
     )
     )
-    width = forms.ChoiceField(
+    width = ChoiceField(
         label=_('Width'),
         label=_('Width'),
         choices=add_blank_choice(RackWidthChoices),
         choices=add_blank_choice(RackWidthChoices),
         required=False
         required=False
@@ -274,7 +275,7 @@ class RackTypeBulkEditForm(PrimaryModelBulkEditForm):
         required=False,
         required=False,
         min_value=1
         min_value=1
     )
     )
-    outer_unit = forms.ChoiceField(
+    outer_unit = ChoiceField(
         label=_('Outer unit'),
         label=_('Outer unit'),
         choices=add_blank_choice(RackDimensionUnitChoices),
         choices=add_blank_choice(RackDimensionUnitChoices),
         required=False
         required=False
@@ -294,7 +295,7 @@ class RackTypeBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         min_value=0,
         required=False
         required=False
     )
     )
-    weight_unit = forms.ChoiceField(
+    weight_unit = ChoiceField(
         label=_('Weight unit'),
         label=_('Weight unit'),
         choices=add_blank_choice(WeightUnitChoices),
         choices=add_blank_choice(WeightUnitChoices),
         required=False,
         required=False,
@@ -362,7 +363,7 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False
         required=False
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(RackStatusChoices),
         choices=add_blank_choice(RackStatusChoices),
         required=False,
         required=False,
@@ -388,12 +389,12 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         max_length=50,
         max_length=50,
         required=False
         required=False
     )
     )
-    form_factor = forms.ChoiceField(
+    form_factor = ChoiceField(
         label=_('Form factor'),
         label=_('Form factor'),
         choices=add_blank_choice(RackFormFactorChoices),
         choices=add_blank_choice(RackFormFactorChoices),
         required=False
         required=False
     )
     )
-    width = forms.ChoiceField(
+    width = ChoiceField(
         label=_('Width'),
         label=_('Width'),
         choices=add_blank_choice(RackWidthChoices),
         choices=add_blank_choice(RackWidthChoices),
         required=False
         required=False
@@ -422,7 +423,7 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         required=False,
         required=False,
         min_value=1
         min_value=1
     )
     )
-    outer_unit = forms.ChoiceField(
+    outer_unit = ChoiceField(
         label=_('Outer unit'),
         label=_('Outer unit'),
         choices=add_blank_choice(RackDimensionUnitChoices),
         choices=add_blank_choice(RackDimensionUnitChoices),
         required=False
         required=False
@@ -432,7 +433,7 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         required=False,
         required=False,
         min_value=1
         min_value=1
     )
     )
-    airflow = forms.ChoiceField(
+    airflow = ChoiceField(
         label=_('Airflow'),
         label=_('Airflow'),
         choices=add_blank_choice(RackAirflowChoices),
         choices=add_blank_choice(RackAirflowChoices),
         required=False
         required=False
@@ -447,7 +448,7 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         min_value=0,
         required=False
         required=False
     )
     )
-    weight_unit = forms.ChoiceField(
+    weight_unit = ChoiceField(
         label=_('Weight unit'),
         label=_('Weight unit'),
         choices=add_blank_choice(WeightUnitChoices),
         choices=add_blank_choice(WeightUnitChoices),
         required=False,
         required=False,
@@ -471,7 +472,7 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
 
 
 
 
 class RackReservationBulkEditForm(PrimaryModelBulkEditForm):
 class RackReservationBulkEditForm(PrimaryModelBulkEditForm):
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(RackReservationStatusChoices),
         choices=add_blank_choice(RackReservationStatusChoices),
         required=False,
         required=False,
@@ -533,7 +534,7 @@ class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm):
         widget=BulkEditNullBooleanSelect(),
         widget=BulkEditNullBooleanSelect(),
         label=_('Exclude from utilization')
         label=_('Exclude from utilization')
     )
     )
-    airflow = forms.ChoiceField(
+    airflow = ChoiceField(
         label=_('Airflow'),
         label=_('Airflow'),
         choices=add_blank_choice(DeviceAirflowChoices),
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
         required=False
@@ -543,7 +544,7 @@ class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         min_value=0,
         required=False
         required=False
     )
     )
-    weight_unit = forms.ChoiceField(
+    weight_unit = ChoiceField(
         label=_('Weight unit'),
         label=_('Weight unit'),
         choices=add_blank_choice(WeightUnitChoices),
         choices=add_blank_choice(WeightUnitChoices),
         required=False,
         required=False,
@@ -589,7 +590,7 @@ class ModuleTypeBulkEditForm(PrimaryModelBulkEditForm):
         label=_('Part number'),
         label=_('Part number'),
         required=False
         required=False
     )
     )
-    airflow = forms.ChoiceField(
+    airflow = ChoiceField(
         label=_('Airflow'),
         label=_('Airflow'),
         choices=add_blank_choice(ModuleAirflowChoices),
         choices=add_blank_choice(ModuleAirflowChoices),
         required=False
         required=False
@@ -599,7 +600,7 @@ class ModuleTypeBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         min_value=0,
         required=False
         required=False
     )
     )
-    weight_unit = forms.ChoiceField(
+    weight_unit = ChoiceField(
         label=_('Weight unit'),
         label=_('Weight unit'),
         choices=add_blank_choice(WeightUnitChoices),
         choices=add_blank_choice(WeightUnitChoices),
         required=False,
         required=False,
@@ -715,12 +716,12 @@ class DeviceBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         required=False
         required=False
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(DeviceStatusChoices),
         choices=add_blank_choice(DeviceStatusChoices),
         required=False
         required=False
     )
     )
-    airflow = forms.ChoiceField(
+    airflow = ChoiceField(
         label=_('Airflow'),
         label=_('Airflow'),
         choices=add_blank_choice(DeviceAirflowChoices),
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
         required=False
@@ -774,7 +775,7 @@ class ModuleBulkEditForm(PrimaryModelBulkEditForm):
             'parent': 'manufacturer',
             'parent': 'manufacturer',
         }
         }
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(ModuleStatusChoices),
         choices=add_blank_choice(ModuleStatusChoices),
         required=False,
         required=False,
@@ -812,19 +813,19 @@ class CableBundleBulkEditForm(PrimaryModelBulkEditForm):
 
 
 
 
 class CableBulkEditForm(PrimaryModelBulkEditForm):
 class CableBulkEditForm(PrimaryModelBulkEditForm):
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=add_blank_choice(CableTypeChoices),
         choices=add_blank_choice(CableTypeChoices),
         required=False,
         required=False,
         initial=''
         initial=''
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(LinkStatusChoices),
         choices=add_blank_choice(LinkStatusChoices),
         required=False,
         required=False,
         initial=''
         initial=''
     )
     )
-    profile = forms.ChoiceField(
+    profile = ChoiceField(
         label=_('Profile'),
         label=_('Profile'),
         choices=add_blank_choice(CableProfileChoices),
         choices=add_blank_choice(CableProfileChoices),
         required=False,
         required=False,
@@ -854,7 +855,7 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         min_value=0,
         required=False
         required=False
     )
     )
-    length_unit = forms.ChoiceField(
+    length_unit = ChoiceField(
         label=_('Length unit'),
         label=_('Length unit'),
         choices=add_blank_choice(CableLengthUnitChoices),
         choices=add_blank_choice(CableLengthUnitChoices),
         required=False,
         required=False,
@@ -938,25 +939,25 @@ class PowerFeedBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         required=False,
         required=False,
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(PowerFeedStatusChoices),
         choices=add_blank_choice(PowerFeedStatusChoices),
         required=False,
         required=False,
         initial=''
         initial=''
     )
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=add_blank_choice(PowerFeedTypeChoices),
         choices=add_blank_choice(PowerFeedTypeChoices),
         required=False,
         required=False,
         initial=''
         initial=''
     )
     )
-    supply = forms.ChoiceField(
+    supply = ChoiceField(
         label=_('Supply'),
         label=_('Supply'),
         choices=add_blank_choice(PowerFeedSupplyChoices),
         choices=add_blank_choice(PowerFeedSupplyChoices),
         required=False,
         required=False,
         initial=''
         initial=''
     )
     )
-    phase = forms.ChoiceField(
+    phase = ChoiceField(
         label=_('Phase'),
         label=_('Phase'),
         choices=add_blank_choice(PowerFeedPhaseChoices),
         choices=add_blank_choice(PowerFeedPhaseChoices),
         required=False,
         required=False,
@@ -1010,7 +1011,7 @@ class ConsolePortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         max_length=64,
         required=False
         required=False
     )
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=add_blank_choice(ConsolePortTypeChoices),
         choices=add_blank_choice(ConsolePortTypeChoices),
         required=False
         required=False
@@ -1029,7 +1030,7 @@ class ConsoleServerPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         max_length=64,
         required=False
         required=False
     )
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=add_blank_choice(ConsolePortTypeChoices),
         choices=add_blank_choice(ConsolePortTypeChoices),
         required=False
         required=False
@@ -1052,7 +1053,7 @@ class PowerPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         max_length=64,
         required=False
         required=False
     )
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=add_blank_choice(PowerPortTypeChoices),
         choices=add_blank_choice(PowerPortTypeChoices),
         required=False
         required=False
@@ -1094,7 +1095,7 @@ class PowerOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         max_length=64,
         required=False
         required=False
     )
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=add_blank_choice(PowerOutletTypeChoices),
         choices=add_blank_choice(PowerOutletTypeChoices),
         required=False
         required=False
@@ -1108,7 +1109,7 @@ class PowerOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         queryset=PowerPortTemplate.objects.all(),
         queryset=PowerPortTemplate.objects.all(),
         required=False
         required=False
     )
     )
-    feed_leg = forms.ChoiceField(
+    feed_leg = ChoiceField(
         label=_('Feed leg'),
         label=_('Feed leg'),
         choices=add_blank_choice(PowerOutletFeedLegChoices),
         choices=add_blank_choice(PowerOutletFeedLegChoices),
         required=False
         required=False
@@ -1142,7 +1143,7 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         max_length=64,
         required=False
         required=False
     )
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=add_blank_choice(InterfaceTypeChoices),
         choices=add_blank_choice(InterfaceTypeChoices),
         required=False
         required=False
@@ -1161,19 +1162,19 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         label=_('Description'),
         label=_('Description'),
         required=False
         required=False
     )
     )
-    poe_mode = forms.ChoiceField(
+    poe_mode = ChoiceField(
         choices=add_blank_choice(InterfacePoEModeChoices),
         choices=add_blank_choice(InterfacePoEModeChoices),
         required=False,
         required=False,
         initial='',
         initial='',
         label=_('PoE mode')
         label=_('PoE mode')
     )
     )
-    poe_type = forms.ChoiceField(
+    poe_type = ChoiceField(
         choices=add_blank_choice(InterfacePoETypeChoices),
         choices=add_blank_choice(InterfacePoETypeChoices),
         required=False,
         required=False,
         initial='',
         initial='',
         label=_('PoE type')
         label=_('PoE type')
     )
     )
-    rf_role = forms.ChoiceField(
+    rf_role = ChoiceField(
         choices=add_blank_choice(WirelessRoleChoices),
         choices=add_blank_choice(WirelessRoleChoices),
         required=False,
         required=False,
         initial='',
         initial='',
@@ -1193,7 +1194,7 @@ class FrontPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         max_length=64,
         required=False
         required=False
     )
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=add_blank_choice(PortTypeChoices),
         choices=add_blank_choice(PortTypeChoices),
         required=False
         required=False
@@ -1220,7 +1221,7 @@ class RearPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         max_length=64,
         max_length=64,
         required=False
         required=False
     )
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=add_blank_choice(PortTypeChoices),
         choices=add_blank_choice(PortTypeChoices),
         required=False
         required=False
@@ -1488,13 +1489,13 @@ class InterfaceBulkEditForm(
         widget=BulkEditNullBooleanSelect,
         widget=BulkEditNullBooleanSelect,
         label=_('Management only')
         label=_('Management only')
     )
     )
-    poe_mode = forms.ChoiceField(
+    poe_mode = ChoiceField(
         choices=add_blank_choice(InterfacePoEModeChoices),
         choices=add_blank_choice(InterfacePoEModeChoices),
         required=False,
         required=False,
         initial='',
         initial='',
         label=_('PoE mode')
         label=_('PoE mode')
     )
     )
-    poe_type = forms.ChoiceField(
+    poe_type = ChoiceField(
         choices=add_blank_choice(InterfacePoETypeChoices),
         choices=add_blank_choice(InterfacePoETypeChoices),
         required=False,
         required=False,
         initial='',
         initial='',
@@ -1505,7 +1506,7 @@ class InterfaceBulkEditForm(
         required=False,
         required=False,
         widget=BulkEditNullBooleanSelect
         widget=BulkEditNullBooleanSelect
     )
     )
-    mode = forms.ChoiceField(
+    mode = ChoiceField(
         label=_('Mode'),
         label=_('Mode'),
         choices=add_blank_choice(InterfaceModeChoices),
         choices=add_blank_choice(InterfaceModeChoices),
         required=False,
         required=False,
@@ -1743,7 +1744,7 @@ class InventoryItemBulkEditForm(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False
         required=False
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(InventoryItemStatusChoices),
         choices=add_blank_choice(InventoryItemStatusChoices),
         required=False,
         required=False,
@@ -1787,7 +1788,7 @@ class VirtualDeviceContextBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False
         required=False
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         required=False,
         required=False,
         choices=add_blank_choice(VirtualDeviceContextStatusChoices)
         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 extras.models import ConfigTemplate
 from ipam.choices import VLANQinQRoleChoices
 from ipam.choices import VLANQinQRoleChoices
 from ipam.models import ASN, VLAN, VRF, IPAddress, VLANGroup, VLANTranslationPolicy
 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 import NestedGroupModelForm, NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin
 from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from users.models import User
 from users.models import User
+from utilities.choices import Choice
 from utilities.forms import add_blank_choice, get_field_value
 from utilities.forms import add_blank_choice, get_field_value
 from utilities.forms.fields import (
 from utilities.forms.fields import (
+    ChoiceField,
     DynamicModelChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     DynamicModelMultipleChoiceField,
     JSONField,
     JSONField,
     NumericArrayField,
     NumericArrayField,
     SlugField,
     SlugField,
+    TypedChoiceField,
 )
 )
 from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields, TabbedGroups
 from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields, TabbedGroups
 from utilities.forms.widgets import (
 from utilities.forms.widgets import (
@@ -34,6 +38,7 @@ from utilities.forms.widgets import (
 )
 )
 from utilities.jsonschema import JSONSchemaProperty
 from utilities.jsonschema import JSONSchemaProperty
 from virtualization.models import Cluster, VMInterface
 from virtualization.models import Cluster, VMInterface
+from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
 from wireless.models import WirelessLAN, WirelessLANGroup
 from wireless.models import WirelessLAN, WirelessLANGroup
 
 
 from .common import InterfaceCommonForm, ModuleCommonForm
 from .common import InterfaceCommonForm, ModuleCommonForm
@@ -127,6 +132,11 @@ class SiteGroupForm(NestedGroupModelForm):
 
 
 
 
 class SiteForm(TenancyForm, PrimaryModelForm):
 class SiteForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=SiteStatusChoices,
+        initial=SiteStatusChoices.STATUS_ACTIVE,
+    )
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         label=_('Region'),
         label=_('Region'),
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -207,6 +217,11 @@ class SiteForm(TenancyForm, PrimaryModelForm):
 
 
 
 
 class LocationForm(TenancyForm, NestedGroupModelForm):
 class LocationForm(TenancyForm, NestedGroupModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=LocationStatusChoices,
+        initial=LocationStatusChoices.STATUS_ACTIVE,
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         label=_('Site'),
         label=_('Site'),
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -259,6 +274,20 @@ class RackRoleForm(OrganizationalModelForm):
 
 
 
 
 class RackTypeForm(PrimaryModelForm):
 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(
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -290,6 +319,31 @@ class RackTypeForm(PrimaryModelForm):
 
 
 
 
 class RackForm(TenancyForm, 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(
     site = DynamicModelChoiceField(
         label=_('Site'),
         label=_('Site'),
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -367,6 +421,11 @@ class RackForm(TenancyForm, PrimaryModelForm):
 
 
 
 
 class RackReservationForm(TenancyForm, PrimaryModelForm):
 class RackReservationForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=RackReservationStatusChoices,
+        initial=RackReservationStatusChoices.STATUS_ACTIVE,
+    )
     rack = DynamicModelChoiceField(
     rack = DynamicModelChoiceField(
         label=_('Rack'),
         label=_('Rack'),
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
@@ -407,6 +466,25 @@ class ManufacturerForm(OrganizationalModelForm):
 
 
 
 
 class DeviceTypeForm(PrimaryModelForm):
 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(
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -471,6 +549,16 @@ class ModuleTypeProfileForm(PrimaryModelForm):
 
 
 
 
 class ModuleTypeForm(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(
     profile = forms.ModelChoiceField(
         queryset=ModuleTypeProfile.objects.all(),
         queryset=ModuleTypeProfile.objects.all(),
         label=_('Profile'),
         label=_('Profile'),
@@ -611,6 +699,16 @@ class PlatformForm(NestedGroupModelForm):
 
 
 
 
 class DeviceForm(TenancyForm, PrimaryModelForm):
 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(
     site = DynamicModelChoiceField(
         label=_('Site'),
         label=_('Site'),
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -649,7 +747,7 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
             },
             },
         )
         )
     )
     )
-    face = forms.ChoiceField(
+    face = TypedChoiceField(
         label=_('Face'),
         label=_('Face'),
         choices=add_blank_choice(DeviceFaceChoices),
         choices=add_blank_choice(DeviceFaceChoices),
         required=False,
         required=False,
@@ -790,6 +888,11 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
 
 
 
 
 class ModuleForm(ModuleCommonForm, PrimaryModelForm):
 class ModuleForm(ModuleCommonForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=ModuleStatusChoices,
+        initial=ModuleStatusChoices.STATUS_ACTIVE,
+    )
     device = DynamicModelChoiceField(
     device = DynamicModelChoiceField(
         label=_('Device'),
         label=_('Device'),
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -853,7 +956,7 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
 
 
 def get_termination_type_choices():
 def get_termination_type_choices():
     return add_blank_choice([
     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)
         for ct in ContentType.objects.filter(CABLE_TERMINATION_MODELS)
     ])
     ])
 
 
@@ -870,13 +973,33 @@ class CableBundleForm(PrimaryModelForm):
 
 
 
 
 class CableForm(TenancyForm, 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,
         choices=get_termination_type_choices,
         required=False,
         required=False,
         widget=HTMXSelect(hx_target_id='cable-side-a'),
         widget=HTMXSelect(hx_target_id='cable-side-a'),
         label=_('Type')
         label=_('Type')
     )
     )
-    b_terminations_type = forms.ChoiceField(
+    b_terminations_type = ChoiceField(
         choices=get_termination_type_choices,
         choices=get_termination_type_choices,
         required=False,
         required=False,
         widget=HTMXSelect(hx_target_id='cable-side-b'),
         widget=HTMXSelect(hx_target_id='cable-side-b'),
@@ -923,6 +1046,26 @@ class PowerPanelForm(PrimaryModelForm):
 
 
 
 
 class PowerFeedForm(TenancyForm, 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(
     power_panel = DynamicModelChoiceField(
         label=_('Power panel'),
         label=_('Power panel'),
         queryset=PowerPanel.objects.all(),
         queryset=PowerPanel.objects.all(),
@@ -1106,6 +1249,12 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
 
 
 
 
 class ConsolePortTemplateForm(ModularComponentTemplateForm):
 class ConsolePortTemplateForm(ModularComponentTemplateForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+    )
+
     class Meta:
     class Meta:
         model = ConsolePortTemplate
         model = ConsolePortTemplate
         fields = [
         fields = [
@@ -1114,6 +1263,12 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm):
 
 
 
 
 class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
 class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+    )
+
     class Meta:
     class Meta:
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
         fields = [
         fields = [
@@ -1122,6 +1277,11 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
 
 
 
 
 class PowerPortTemplateForm(ModularComponentTemplateForm):
 class PowerPortTemplateForm(ModularComponentTemplateForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(PowerPortTypeChoices),
+        required=False,
+    )
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             TabbedGroups(
             TabbedGroups(
@@ -1140,6 +1300,17 @@ class PowerPortTemplateForm(ModularComponentTemplateForm):
 
 
 
 
 class PowerOutletTemplateForm(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(
     power_port = DynamicModelChoiceField(
         label=_('Power port'),
         label=_('Power port'),
         queryset=PowerPortTemplate.objects.all(),
         queryset=PowerPortTemplate.objects.all(),
@@ -1167,6 +1338,25 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
 
 
 
 
 class InterfaceTemplateForm(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(
     bridge = DynamicModelChoiceField(
         label=_('Bridge'),
         label=_('Bridge'),
         queryset=InterfaceTemplate.objects.all(),
         queryset=InterfaceTemplate.objects.all(),
@@ -1198,6 +1388,10 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
 
 
 
 
 class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
 class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=PortTypeChoices,
+    )
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             TabbedGroups(
             TabbedGroups(
@@ -1238,6 +1432,10 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
 
 
 
 
 class RearPortTemplateForm(ModularComponentTemplateForm):
 class RearPortTemplateForm(ModularComponentTemplateForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=PortTypeChoices,
+    )
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             TabbedGroups(
             TabbedGroups(
@@ -1461,6 +1659,12 @@ class ModularDeviceComponentForm(DeviceComponentForm):
 
 
 
 
 class ConsolePortForm(ModularDeviceComponentForm):
 class ConsolePortForm(ModularDeviceComponentForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+        help_text=_('Physical port type'),
+    )
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
             'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
@@ -1475,6 +1679,12 @@ class ConsolePortForm(ModularDeviceComponentForm):
 
 
 
 
 class ConsoleServerPortForm(ModularDeviceComponentForm):
 class ConsoleServerPortForm(ModularDeviceComponentForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+        help_text=_('Physical port type'),
+    )
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
             'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
@@ -1489,6 +1699,12 @@ class ConsoleServerPortForm(ModularDeviceComponentForm):
 
 
 
 
 class PowerPortForm(ModularDeviceComponentForm):
 class PowerPortForm(ModularDeviceComponentForm):
+    type = TypedChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(PowerPortTypeChoices),
+        required=False,
+        help_text=_('Physical port type'),
+    )
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
             'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
@@ -1505,6 +1721,23 @@ class PowerPortForm(ModularDeviceComponentForm):
 
 
 
 
 class PowerOutletForm(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(
     power_port = DynamicModelChoiceField(
         label=_('Power port'),
         label=_('Power port'),
         queryset=PowerPort.objects.all(),
         queryset=PowerPort.objects.all(),
@@ -1530,6 +1763,41 @@ class PowerOutletForm(ModularDeviceComponentForm):
 
 
 
 
 class InterfaceForm(InterfaceCommonForm, 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(
     vdcs = DynamicModelMultipleChoiceField(
         queryset=VirtualDeviceContext.objects.all(),
         queryset=VirtualDeviceContext.objects.all(),
         required=False,
         required=False,
@@ -1676,6 +1944,10 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
 
 
 
 
 class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
 class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=PortTypeChoices,
+    )
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected',
             'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected',
@@ -1712,6 +1984,10 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
 
 
 
 
 class RearPortForm(ModularDeviceComponentForm):
 class RearPortForm(ModularDeviceComponentForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=PortTypeChoices,
+    )
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
             'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
@@ -1770,6 +2046,11 @@ class PopulateDeviceBayForm(forms.Form):
 
 
 
 
 class InventoryItemForm(DeviceComponentForm):
 class InventoryItemForm(DeviceComponentForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=InventoryItemStatusChoices,
+        initial=InventoryItemStatusChoices.STATUS_ACTIVE,
+    )
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         label=_('Parent'),
         label=_('Parent'),
         queryset=InventoryItem.objects.all(),
         queryset=InventoryItem.objects.all(),
@@ -1930,6 +2211,10 @@ class InventoryItemRoleForm(OrganizationalModelForm):
 
 
 
 
 class VirtualDeviceContextForm(TenancyForm, PrimaryModelForm):
 class VirtualDeviceContextForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=VirtualDeviceContextStatusChoices,
+    )
     device = DynamicModelChoiceField(
     device = DynamicModelChoiceField(
         label=_('Device'),
         label=_('Device'),
         queryset=Device.objects.all(),
         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 django.utils.translation import gettext_lazy as _
 
 
 from netbox.choices import ButtonColorChoices
 from netbox.choices import ButtonColorChoices
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 #
 #
 # CustomFields
 # CustomFields
@@ -27,19 +27,23 @@ class CustomFieldTypeChoices(ChoiceSet):
     TYPE_MULTIOBJECT = 'multiobject'
     TYPE_MULTIOBJECT = 'multiobject'
 
 
     CHOICES = (
     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'
     FILTER_EXACT = 'exact'
 
 
     CHOICES = (
     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'
     HIDDEN = 'hidden'
 
 
     CHOICES = (
     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'
     HIDDEN = 'hidden'
 
 
     CHOICES = (
     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'
     UN_LOCODE = 'UN_LOCODE'
 
 
     CHOICES = (
     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'
     WHITE = 'white'
 
 
     CHOICES = (
     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 = (
     CHOICES = (
         *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'
     ORDERING_ALPHABETICAL_ZA = '-name'
 
 
     CHOICES = (
     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'
     KIND_DANGER = 'danger'
 
 
     CHOICES = [
     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'
     LOG_FAILURE = 'failure'
 
 
     CHOICES = (
     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'
     METHOD_DELETE = 'DELETE'
 
 
     CHOICES = (
     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'
     WHITE = 'white'
 
 
     CHOICES = (
     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'
     NOTIFICATION = 'notification'
 
 
     CHOICES = (
     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 core.models import ObjectType
 from extras.choices import BookmarkOrderingChoices
 from extras.choices import BookmarkOrderingChoices
+from utilities.choices import Choice
 from utilities.object_types import object_type_identifier, object_type_name
 from utilities.object_types import object_type_identifier, object_type_name
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
 from utilities.proxy import resolve_proxies
 from utilities.proxy import resolve_proxies
@@ -40,7 +41,7 @@ logger = logging.getLogger('netbox.data_backends')
 
 
 def get_object_type_choices():
 def get_object_type_choices():
     return [
     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')
         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():
 def get_bookmarks_object_type_choices():
     return [
     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')
         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 import NetBoxModelBulkEditForm, PrimaryModelBulkEditForm
 from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin
 from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin
 from utilities.forms import BulkEditForm, add_blank_choice
 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.rendering import FieldSet
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 
 
@@ -61,12 +61,12 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
         queryset=CustomFieldChoiceSet.objects.all(),
         queryset=CustomFieldChoiceSet.objects.all(),
         required=False
         required=False
     )
     )
-    ui_visible = forms.ChoiceField(
+    ui_visible = ChoiceField(
         label=_("UI visible"),
         label=_("UI visible"),
         choices=add_blank_choice(CustomFieldUIVisibleChoices),
         choices=add_blank_choice(CustomFieldUIVisibleChoices),
         required=False
         required=False
     )
     )
-    ui_editable = forms.ChoiceField(
+    ui_editable = ChoiceField(
         label=_("UI editable"),
         label=_("UI editable"),
         choices=add_blank_choice(CustomFieldUIEditableChoices),
         choices=add_blank_choice(CustomFieldUIEditableChoices),
         required=False
         required=False
@@ -110,7 +110,7 @@ class CustomFieldChoiceSetBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEd
         queryset=CustomFieldChoiceSet.objects.all(),
         queryset=CustomFieldChoiceSet.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
     )
     )
-    base_choices = forms.ChoiceField(
+    base_choices = ChoiceField(
         choices=add_blank_choice(CustomFieldChoiceSetBaseChoices),
         choices=add_blank_choice(CustomFieldChoiceSetBaseChoices),
         required=False
         required=False
     )
     )
@@ -144,7 +144,7 @@ class CustomLinkBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
         label=_('Weight'),
         label=_('Weight'),
         required=False
         required=False
     )
     )
-    button_class = forms.ChoiceField(
+    button_class = ChoiceField(
         label=_('Button class'),
         label=_('Button class'),
         choices=add_blank_choice(CustomLinkButtonClassChoices),
         choices=add_blank_choice(CustomLinkButtonClassChoices),
         required=False
         required=False
@@ -252,7 +252,7 @@ class WebhookBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
         max_length=200,
         max_length=200,
         required=False
         required=False
     )
     )
-    http_method = forms.ChoiceField(
+    http_method = ChoiceField(
         choices=add_blank_choice(WebhookHttpMethodChoices),
         choices=add_blank_choice(WebhookHttpMethodChoices),
         required=False,
         required=False,
         label=_('HTTP method')
         label=_('HTTP method')
@@ -429,7 +429,7 @@ class JournalEntryBulkEditForm(ChangelogMessageMixin, BulkEditForm):
         queryset=JournalEntry.objects.all(),
         queryset=JournalEntry.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
     )
     )
-    kind = forms.ChoiceField(
+    kind = ChoiceField(
         label=_('Kind'),
         label=_('Kind'),
         choices=add_blank_choice(JournalEntryKindChoices),
         choices=add_blank_choice(JournalEntryKindChoices),
         required=False
         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 netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from users.models import Group, User
 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 (
 from utilities.forms.fields import (
+    ChoiceField,
     CommentField,
     CommentField,
     ContentTypeChoiceField,
     ContentTypeChoiceField,
     ContentTypeMultipleChoiceField,
     ContentTypeMultipleChoiceField,
     DynamicModelChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     DynamicModelMultipleChoiceField,
     JSONField,
     JSONField,
+    MultipleChoiceField,
     SlugField,
     SlugField,
+    TypedChoiceField,
 )
 )
 from utilities.forms.rendering import FieldSet, ObjectAttribute
 from utilities.forms.rendering import FieldSet, ObjectAttribute
 from utilities.forms.widgets import ChoicesWidget, HTMXSelect
 from utilities.forms.widgets import ChoicesWidget, HTMXSelect
@@ -54,6 +57,33 @@ __all__ = (
 
 
 
 
 class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
 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(
     object_types = ContentTypeMultipleChoiceField(
         label=_('Object types'),
         label=_('Object types'),
         queryset=ObjectType.objects.with_feature('custom_fields'),
         queryset=ObjectType.objects.with_feature('custom_fields'),
@@ -189,6 +219,12 @@ class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
 
 
 
 
 class CustomFieldChoiceSetForm(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
     # TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model
     extra_choices = forms.CharField(
     extra_choices = forms.CharField(
         widget=ChoicesWidget(),
         widget=ChoicesWidget(),
@@ -303,6 +339,12 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
 
 
 
 
 class CustomLinkForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
 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(
     object_types = ContentTypeMultipleChoiceField(
         label=_('Object types'),
         label=_('Object types'),
         queryset=ObjectType.objects.with_feature('custom_links')
         queryset=ObjectType.objects.with_feature('custom_links')
@@ -531,6 +573,11 @@ class SubscriptionForm(forms.ModelForm):
 
 
 
 
 class WebhookForm(OwnerMixin, NetBoxModelForm):
 class WebhookForm(OwnerMixin, NetBoxModelForm):
+    http_method = ChoiceField(
+        label=_('HTTP method'),
+        choices=WebhookHttpMethodChoices,
+        initial=WebhookHttpMethodChoices.METHOD_POST,
+    )
 
 
     fieldsets = (
     fieldsets = (
         FieldSet('name', 'description', 'tags', name=_('Webhook')),
         FieldSet('name', 'description', 'tags', name=_('Webhook')),
@@ -551,15 +598,20 @@ class WebhookForm(OwnerMixin, NetBoxModelForm):
 
 
 
 
 class EventRuleForm(OwnerMixin, NetBoxModelForm):
 class EventRuleForm(OwnerMixin, NetBoxModelForm):
+    action_type = ChoiceField(
+        label=_('Action type'),
+        choices=EventRuleActionChoices,
+        initial=EventRuleActionChoices.WEBHOOK,
+    )
     object_types = ContentTypeMultipleChoiceField(
     object_types = ContentTypeMultipleChoiceField(
         label=_('Object types'),
         label=_('Object types'),
         queryset=ObjectType.objects.with_feature('event_rules'),
         queryset=ObjectType.objects.with_feature('event_rules'),
     )
     )
-    event_types = forms.MultipleChoiceField(
+    event_types = MultipleChoiceField(
         choices=get_event_type_choices(),
         choices=get_event_type_choices(),
         label=_('Event types')
         label=_('Event types')
     )
     )
-    action_choice = forms.ChoiceField(
+    action_choice = ChoiceField(
         label=_('Action choice'),
         label=_('Action choice'),
         choices=[]
         choices=[]
     )
     )
@@ -893,7 +945,7 @@ class ImageAttachmentForm(forms.ModelForm):
 
 
 
 
 class JournalEntryForm(NetBoxModelForm):
 class JournalEntryForm(NetBoxModelForm):
-    kind = forms.ChoiceField(
+    kind = ChoiceField(
         label=_('Kind'),
         label=_('Kind'),
         choices=JournalEntryKindChoices
         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.models.features import has_feature
 from netbox.registry import registry
 from netbox.registry import registry
 from users.models import User
 from users.models import User
+from utilities.choices import Choice
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 
 
 __all__ = (
 __all__ = (
@@ -26,7 +27,7 @@ def get_event_type_choices():
     Compile a list of choices from all registered event types
     Compile a list of choices from all registered event types
     """
     """
     return [
     return [
-        (name, event.text)
+        Choice(name, event.text)
         for name, event in registry['event_types'].items()
         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 django.utils.translation import gettext_lazy as _
 
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 
 
 class IPAddressFamilyChoices(ChoiceSet):
 class IPAddressFamilyChoices(ChoiceSet):
@@ -9,8 +9,8 @@ class IPAddressFamilyChoices(ChoiceSet):
     FAMILY_6 = 6
     FAMILY_6 = 6
 
 
     CHOICES = (
     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'
     STATUS_DEPRECATED = 'deprecated'
 
 
     CHOICES = [
     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'
     STATUS_DEPRECATED = 'deprecated'
 
 
     CHOICES = [
     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'
     STATUS_SLAAC = 'slaac'
 
 
     CHOICES = [
     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'
     ROLE_CARP = 'carp'
 
 
     CHOICES = (
     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 = (
     CHOICES = (
         (_('Standard'), (
         (_('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'), (
         (_('CheckPoint'), (
-            (PROTOCOL_CLUSTERXL, 'ClusterXL'),
+            Choice(PROTOCOL_CLUSTERXL, 'ClusterXL', description=_('Check Point ClusterXL high-availability protocol')),
         )),
         )),
         (_('Cisco'), (
         (_('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'
     AUTHENTICATION_MD5 = 'md5'
 
 
     CHOICES = (
     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'
     STATUS_DEPRECATED = 'deprecated'
 
 
     CHOICES = [
     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'
     ROLE_CUSTOMER = 'cvlan'
 
 
     CHOICES = [
     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'
     PROTOCOL_SCTP = 'sctp'
 
 
     CHOICES = (
     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 tenancy.models import Tenant
 from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
 from utilities.forms.fields import (
+    ChoiceField,
     DynamicModelChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     DynamicModelMultipleChoiceField,
     GenericObjectChoiceField,
     GenericObjectChoiceField,
@@ -203,7 +204,7 @@ class PrefixBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False
         required=False
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(PrefixStatusChoices),
         choices=add_blank_choice(PrefixStatusChoices),
         required=False
         required=False
@@ -247,7 +248,7 @@ class IPRangeBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False
         required=False
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(IPRangeStatusChoices),
         choices=add_blank_choice(IPRangeStatusChoices),
         required=False
         required=False
@@ -294,12 +295,12 @@ class IPAddressBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False
         required=False
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(IPAddressStatusChoices),
         choices=add_blank_choice(IPAddressStatusChoices),
         required=False
         required=False
     )
     )
-    role = forms.ChoiceField(
+    role = ChoiceField(
         label=_('Role'),
         label=_('Role'),
         choices=add_blank_choice(IPAddressRoleChoices),
         choices=add_blank_choice(IPAddressRoleChoices),
         required=False
         required=False
@@ -321,7 +322,7 @@ class IPAddressBulkEditForm(PrimaryModelBulkEditForm):
 
 
 
 
 class FHRPGroupBulkEditForm(PrimaryModelBulkEditForm):
 class FHRPGroupBulkEditForm(PrimaryModelBulkEditForm):
-    protocol = forms.ChoiceField(
+    protocol = ChoiceField(
         label=_('Protocol'),
         label=_('Protocol'),
         choices=add_blank_choice(FHRPGroupProtocolChoices),
         choices=add_blank_choice(FHRPGroupProtocolChoices),
         required=False
         required=False
@@ -331,7 +332,7 @@ class FHRPGroupBulkEditForm(PrimaryModelBulkEditForm):
         required=False,
         required=False,
         label=_('Group ID')
         label=_('Group ID')
     )
     )
-    auth_type = forms.ChoiceField(
+    auth_type = ChoiceField(
         choices=add_blank_choice(FHRPGroupAuthTypeChoices),
         choices=add_blank_choice(FHRPGroupAuthTypeChoices),
         required=False,
         required=False,
         label=_('Authentication type')
         label=_('Authentication type')
@@ -415,7 +416,7 @@ class VLANBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False
         required=False
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(VLANStatusChoices),
         choices=add_blank_choice(VLANStatusChoices),
         required=False
         required=False
@@ -425,7 +426,7 @@ class VLANBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         required=False
         required=False
     )
     )
-    qinq_role = forms.ChoiceField(
+    qinq_role = ChoiceField(
         label=_('Q-in-Q role'),
         label=_('Q-in-Q role'),
         choices=add_blank_choice(VLANQinQRoleChoices),
         choices=add_blank_choice(VLANQinQRoleChoices),
         required=False
         required=False
@@ -475,7 +476,7 @@ class VLANTranslationRuleBulkEditForm(NetBoxModelBulkEditForm):
 
 
 
 
 class ServiceTemplateBulkEditForm(PrimaryModelBulkEditForm):
 class ServiceTemplateBulkEditForm(PrimaryModelBulkEditForm):
-    protocol = forms.ChoiceField(
+    protocol = ChoiceField(
         label=_('Protocol'),
         label=_('Protocol'),
         choices=add_blank_choice(ServiceProtocolChoices),
         choices=add_blank_choice(ServiceProtocolChoices),
         required=False
         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.exceptions import PermissionsViolation
 from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms import GenericObjectFormMixin, add_blank_choice
 from utilities.forms.fields import (
 from utilities.forms.fields import (
+    ChoiceField,
     DynamicModelChoiceField,
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     DynamicModelMultipleChoiceField,
     GenericObjectChoiceField,
     GenericObjectChoiceField,
     NumericArrayField,
     NumericArrayField,
     NumericRangeArrayField,
     NumericRangeArrayField,
+    TypedChoiceField,
 )
 )
 from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
 from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
 from utilities.forms.widgets import DatePicker
 from utilities.forms.widgets import DatePicker
@@ -203,6 +205,12 @@ class RoleForm(OrganizationalModelForm):
 
 
 
 
 class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm):
 class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=PrefixStatusChoices,
+        initial=PrefixStatusChoices.STATUS_ACTIVE,
+        help_text=_('Operational status of this prefix'),
+    )
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
@@ -267,6 +275,12 @@ class PrefixBulkAddForm(PrefixForm):
 
 
 
 
 class IPRangeForm(TenancyForm, PrimaryModelForm):
 class IPRangeForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=IPRangeStatusChoices,
+        initial=IPRangeStatusChoices.STATUS_ACTIVE,
+        help_text=_('Operational status of this range'),
+    )
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
@@ -296,6 +310,18 @@ class IPRangeForm(TenancyForm, PrimaryModelForm):
 
 
 
 
 class IPAddressForm(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(
     interface = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False,
         required=False,
@@ -483,6 +509,18 @@ class IPAddressForm(TenancyForm, PrimaryModelForm):
 
 
 
 
 class IPAddressBulkAddForm(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(
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
@@ -515,6 +553,15 @@ class IPAddressAssignForm(forms.Form):
 
 
 
 
 class FHRPGroupForm(PrimaryModelForm):
 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
     # Optionally create a new IPAddress along with the FHRPGroup
     ip_vrf = DynamicModelChoiceField(
     ip_vrf = DynamicModelChoiceField(
@@ -526,7 +573,7 @@ class FHRPGroupForm(PrimaryModelForm):
         required=False,
         required=False,
         label=_('Address')
         label=_('Address')
     )
     )
-    ip_status = forms.ChoiceField(
+    ip_status = ChoiceField(
         choices=add_blank_choice(IPAddressStatusChoices),
         choices=add_blank_choice(IPAddressStatusChoices),
         required=False,
         required=False,
         label=_('Status')
         label=_('Status')
@@ -646,6 +693,18 @@ class VLANGroupForm(GenericObjectFormMixin, TenancyForm, OrganizationalModelForm
 
 
 
 
 class VLANForm(TenancyForm, PrimaryModelForm):
 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(
     group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
         required=False,
         required=False,
@@ -742,6 +801,10 @@ class VLANTranslationRuleForm(NetBoxModelForm):
 
 
 
 
 class ServiceTemplateForm(PrimaryModelForm):
 class ServiceTemplateForm(PrimaryModelForm):
+    protocol = ChoiceField(
+        label=_('Protocol'),
+        choices=ServiceProtocolChoices,
+    )
     ports = NumericArrayField(
     ports = NumericArrayField(
         label=_('Ports'),
         label=_('Ports'),
         base_field=forms.IntegerField(
         base_field=forms.IntegerField(
@@ -761,6 +824,10 @@ class ServiceTemplateForm(PrimaryModelForm):
 
 
 
 
 class ServiceForm(GenericObjectFormMixin, PrimaryModelForm):
 class ServiceForm(GenericObjectFormMixin, PrimaryModelForm):
+    protocol = ChoiceField(
+        label=_('Protocol'),
+        choices=ServiceProtocolChoices,
+    )
     parent = GenericObjectChoiceField(
     parent = GenericObjectChoiceField(
         label=_('Parent'),
         label=_('Parent'),
         content_type_queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS),
         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 django.utils.translation import gettext_lazy as _
 
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 from utilities.constants import CSV_DELIMITERS
 from utilities.constants import CSV_DELIMITERS
 
 
 __all__ = (
 __all__ = (
@@ -48,33 +48,33 @@ class ColorChoices(ChoiceSet):
     COLOR_WHITE = 'ffffff'
     COLOR_WHITE = 'ffffff'
 
 
     CHOICES = (
     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'
     WHITE = 'white'
 
 
     CHOICES = (
     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'
     DATA_FILE = 'datafile'
 
 
     CHOICES = [
     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'
     YAML = 'yaml'
 
 
     CHOICES = [
     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']
     TAB = CSV_DELIMITERS['tab']
 
 
     CHOICES = [
     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'
     UNIT_FOOT = 'ft'
 
 
     CHOICES = (
     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'
     UNIT_OUNCE = 'oz'
 
 
     CHOICES = (
     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 dataclasses import dataclass
 
 
 from netbox.registry import registry
 from netbox.registry import registry
+from utilities.choices import Choice
 
 
 EVENT_TYPE_KIND_INFO = 'info'
 EVENT_TYPE_KIND_INFO = 'info'
 EVENT_TYPE_KIND_SUCCESS = 'success'
 EVENT_TYPE_KIND_SUCCESS = 'success'
@@ -31,7 +32,7 @@ def get_event_text(name):
 
 
 def get_event_type_choices():
 def get_event_type_choices():
     return [
     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 netbox.registry import registry
 from users.preferences import UserPreference
 from users.preferences import UserPreference
+from utilities.choices import Choice
 from utilities.constants import CSV_DELIMITERS
 from utilities.constants import CSV_DELIMITERS
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
 
 
 
 
 def get_page_lengths():
 def get_page_lengths():
     return [
     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())
         label = _(k.title())
         if v.strip():
         if v.strip():
             label = f'{label} ({v})'
             label = f'{label} ({v})'
-        choices.append((k, label))
+        choices.append(Choice(k, label))
     return choices
     return choices
 
 
 
 

+ 2 - 1
netbox/netbox/utils.py

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

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox.js


Fișier diff suprimat deoarece este prea mare
+ 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 { getPlugins } from './config';
 import { getElements } from '../util';
 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
 // Initialize <select> elements with statically-defined options
 export function initStaticSelects(): void {
 export function initStaticSelects(): void {
   for (const select of getElements<HTMLSelectElement>(
   for (const select of getElements<HTMLSelectElement>(
@@ -12,6 +23,11 @@ export function initStaticSelects(): void {
     new NetBoxTomSelect(select, {
     new NetBoxTomSelect(select, {
       ...getPlugins(select),
       ...getPlugins(select),
       maxOptions: undefined,
       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 django.utils.translation import gettext_lazy as _
 
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 #
 #
 # Contacts
 # Contacts
@@ -14,8 +14,8 @@ class ContactPriorityChoices(ChoiceSet):
     PRIORITY_INACTIVE = 'inactive'
     PRIORITY_INACTIVE = 'inactive'
 
 
     CHOICES = (
     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,
     PrimaryModelBulkEditForm,
 )
 )
 from utilities.forms import add_blank_choice
 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.rendering import FieldSet
 
 
 from ..choices import ContactPriorityChoices
 from ..choices import ContactPriorityChoices
@@ -137,7 +137,7 @@ class ContactAssignmentBulkEditForm(NetBoxModelBulkEditForm):
         queryset=ContactRole.objects.all(),
         queryset=ContactRole.objects.all(),
         required=False
         required=False
     )
     )
-    priority = forms.ChoiceField(
+    priority = ChoiceField(
         label=_('Priority'),
         label=_('Priority'),
         choices=add_blank_choice(ContactPriorityChoices),
         choices=add_blank_choice(ContactPriorityChoices),
         required=False
         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 django.utils.translation import gettext_lazy as _
 
 
 from netbox.forms import NestedGroupModelForm, NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 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 utilities.forms.rendering import FieldSet, ObjectAttribute
 
 
 from ..models import *
 from ..models import *
@@ -118,6 +125,11 @@ class ContactForm(PrimaryModelForm):
 
 
 
 
 class ContactAssignmentForm(NetBoxModelForm):
 class ContactAssignmentForm(NetBoxModelForm):
+    priority = TypedChoiceField(
+        label=_('Priority'),
+        choices=add_blank_choice(ContactPriorityChoices),
+        required=False,
+    )
     group = DynamicModelChoiceField(
     group = DynamicModelChoiceField(
         label=_('Group'),
         label=_('Group'),
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),

+ 3 - 3
netbox/users/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 __all__ = (
 __all__ = (
     'TokenVersionChoices',
     'TokenVersionChoices',
@@ -12,6 +12,6 @@ class TokenVersionChoices(ChoiceSet):
     V2 = 2
     V2 = 2
 
 
     CHOICES = [
     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.constants import *
 from users.models import *
 from users.models import *
 from users.utils import user_may_grant_token
 from users.utils import user_may_grant_token
+from utilities.choices import Choice
 from utilities.data import flatten_dict
 from utilities.data import flatten_dict
 from utilities.forms.fields import (
 from utilities.forms.fields import (
     ContentTypeMultipleChoiceField,
     ContentTypeMultipleChoiceField,
@@ -326,7 +327,7 @@ def get_object_types_choices():
 
 
         model_class = ot.model_class()
         model_class = ot.model_class()
         model_name = model_class._meta.verbose_name if model_class else ot.model
         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())
     return list(choices_by_app.items())
 
 

+ 60 - 12
netbox/utilities/choices.py

@@ -1,18 +1,50 @@
 import enum
 import enum
+from typing import Any
 
 
 from django.conf import settings
 from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
+from django.utils.functional import Promise
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from utilities.data import get_config_value_ci
 from utilities.data import get_config_value_ci
 from utilities.string import enum_key
 from utilities.string import enum_key
 
 
 __all__ = (
 __all__ = (
+    'Choice',
     'ChoiceSet',
     'ChoiceSet',
     'unpack_grouped_choices',
     '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):
 class ChoiceSetMeta(type):
     """
     """
     Metaclass for ChoiceSet
     Metaclass for ChoiceSet
@@ -36,21 +68,35 @@ class ChoiceSetMeta(type):
                 if extend_choices is not None:
                 if extend_choices is not None:
                     attrs['CHOICES'].extend(extend_choices)
                     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['_choices'] = []
         attrs['colors'] = {}
         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']:
         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))
                 attrs['_choices'].append((choice[0], grouped_choices))
             else:
             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)
         return super().__new__(mcs, name, bases, attrs)
 
 
@@ -64,8 +110,10 @@ class ChoiceSetMeta(type):
 
 
 class ChoiceSet(metaclass=ChoiceSetMeta):
 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()
     CHOICES = list()
 
 

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

@@ -1,4 +1,5 @@
 from .array import *
 from .array import *
+from .choices import *
 from .content_types import *
 from .content_types import *
 from .csv import *
 from .csv import *
 from .dynamic 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):
 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):
 def form_from_model(model, fields):

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

@@ -9,11 +9,44 @@ __all__ = (
     'ClearableSelect',
     'ClearableSelect',
     'ColorSelect',
     'ColorSelect',
     'HTMXSelect',
     'HTMXSelect',
+    'Select',
+    'SelectMultiple',
     'SelectWithPK',
     'SelectWithPK',
     'SplitMultiSelectWidget',
     '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):
 class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
     """
     """
     A Select widget for NullBooleanFields
     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.core.exceptions import ImproperlyConfigured
 from django.test import TestCase, override_settings
 from django.test import TestCase, override_settings
 
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 
 
 class ExampleChoices(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):
 class ChoiceSetTestCase(TestCase):
 
 
     def test_values(self):
     def test_values(self):
         self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3])
         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):
     def test_key_with_non_list_choices_raises(self):
         """A ChoiceSet declaring a key must define CHOICES as a list."""
         """A ChoiceSet declaring a key must define CHOICES as a list."""
         with self.assertRaises(ImproperlyConfigured):
         with self.assertRaises(ImproperlyConfigured):
@@ -70,3 +168,29 @@ class FieldChoicesCaseInsensitiveTestCase(TestCase):
                 CHOICES = [('base', 'Base')]
                 CHOICES = [('base', 'Base')]
 
 
             self.assertEqual(TestStatusChoices.CHOICES, [('base', 'Base'), ('extra', 'Extra')])
             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 dcim.models import Site
 from netbox.choices import ImportFormatChoices
 from netbox.choices import ImportFormatChoices
+from utilities.choices import Choice, ChoiceSet
 from utilities.forms.bulk_import import BulkImportForm
 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.csv import CSVSelectWidget
 from utilities.forms.fields.dynamic import DynamicChoiceField, DynamicMultipleChoiceField
 from utilities.forms.fields.dynamic import DynamicChoiceField, DynamicMultipleChoiceField
 from utilities.forms.fields.generic import GenericObjectChoiceField
 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.mixins import GenericObjectFormMixin
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
 from utilities.forms.utils import (
 from utilities.forms.utils import (
+    add_blank_choice,
     expand_alphanumeric_pattern,
     expand_alphanumeric_pattern,
     expand_ipnetwork_pattern,
     expand_ipnetwork_pattern,
     get_capacity_unit_label,
     get_capacity_unit_label,
     get_field_value,
     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):
 class ExpandIPNetworkTestCase(TestCase):
@@ -898,3 +901,110 @@ class HTMXSelectTestCase(TestCase):
     def test_hx_target_id_include_stays_on_form_fields(self):
     def test_hx_target_id_include_stays_on_form_fields(self):
         widget = HTMXSelect(hx_target_id='my-fieldset')
         widget = HTMXSelect(hx_target_id='my-fieldset')
         self.assertEqual(widget.attrs['hx-include'], '#form_fields')
         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 django.utils.translation import gettext_lazy as _
 
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 #
 #
 # Clusters
 # Clusters
@@ -17,11 +17,26 @@ class ClusterStatusChoices(ChoiceSet):
     STATUS_OFFLINE = 'offline'
     STATUS_OFFLINE = 'offline'
 
 
     CHOICES = [
     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'
     STATUS_PAUSED = 'paused'
 
 
     CHOICES = [
     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'
     STATUS_LAST_STATE = 'laststate'
 
 
     CHOICES = [
     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 netbox.forms.mixins import OwnerMixin
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice
 from utilities.forms import add_blank_choice
-from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.fields import ChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
 from utilities.forms.utils import get_capacity_unit_label
 from utilities.forms.utils import get_capacity_unit_label
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 from utilities.forms.widgets import BulkEditNullBooleanSelect
@@ -58,7 +58,7 @@ class ClusterBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         required=False
         required=False
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(ClusterStatusChoices),
         choices=add_blank_choice(ClusterStatusChoices),
         required=False,
         required=False,
@@ -111,13 +111,13 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
         queryset=VirtualMachineType.objects.all(),
         queryset=VirtualMachineType.objects.all(),
         required=False
         required=False
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(VirtualMachineStatusChoices),
         choices=add_blank_choice(VirtualMachineStatusChoices),
         required=False,
         required=False,
         initial='',
         initial='',
     )
     )
-    start_on_boot = forms.ChoiceField(
+    start_on_boot = ChoiceField(
         label=_('Start on boot'),
         label=_('Start on boot'),
         choices=add_blank_choice(VirtualMachineStartOnBootChoices),
         choices=add_blank_choice(VirtualMachineStartOnBootChoices),
         required=False,
         required=False,
@@ -240,7 +240,7 @@ class VMInterfaceBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
         max_length=100,
         max_length=100,
         required=False
         required=False
     )
     )
-    mode = forms.ChoiceField(
+    mode = ChoiceField(
         label=_('Mode'),
         label=_('Mode'),
         choices=add_blank_choice(InterfaceModeChoices),
         choices=add_blank_choice(InterfaceModeChoices),
         required=False
         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.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from dcim.choices import InterfaceModeChoices
 from dcim.forms.common import InterfaceCommonForm
 from dcim.forms.common import InterfaceCommonForm
 from dcim.forms.mixins import ScopedForm
 from dcim.forms.mixins import ScopedForm
 from dcim.models import Device, DeviceRole, MACAddress, Platform, Rack, Region, Site, SiteGroup
 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 import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from netbox.forms.mixins import OwnerMixin
 from netbox.forms.mixins import OwnerMixin
 from tenancy.forms import TenancyForm
 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.rendering import FieldSet
 from utilities.forms.utils import get_capacity_unit_label
 from utilities.forms.utils import get_capacity_unit_label
 from utilities.forms.widgets import HTMXSelect
 from utilities.forms.widgets import HTMXSelect
+from virtualization.choices import (
+    ClusterStatusChoices,
+    VirtualMachineStartOnBootChoices,
+    VirtualMachineStatusChoices,
+)
 
 
 from ..models import *
 from ..models import *
 
 
@@ -60,6 +73,11 @@ class ClusterGroupForm(OrganizationalModelForm):
 
 
 
 
 class ClusterForm(TenancyForm, ScopedForm, PrimaryModelForm):
 class ClusterForm(TenancyForm, ScopedForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=ClusterStatusChoices,
+        initial=ClusterStatusChoices.STATUS_ACTIVE,
+    )
     type = DynamicModelChoiceField(
     type = DynamicModelChoiceField(
         label=_('Type'),
         label=_('Type'),
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
@@ -194,6 +212,16 @@ class VirtualMachineTypeForm(PrimaryModelForm):
 
 
 
 
 class VirtualMachineForm(TenancyForm, 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(
     virtual_machine_type = forms.ModelChoiceField(
         label=_('Type'),
         label=_('Type'),
         queryset=VirtualMachineType.objects.all(),
         queryset=VirtualMachineType.objects.all(),
@@ -381,6 +409,12 @@ class VMComponentForm(OwnerMixin, NetBoxModelForm):
 
 
 
 
 class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
 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(
     primary_mac_address = DynamicModelChoiceField(
         queryset=MACAddress.objects.all(),
         queryset=MACAddress.objects.all(),
         label=_('Primary MAC address'),
         label=_('Primary MAC address'),

+ 119 - 81
netbox/vpn/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 #
 #
 # Tunnels
 # Tunnels
@@ -15,9 +15,14 @@ class TunnelStatusChoices(ChoiceSet):
     STATUS_DISABLED = 'disabled'
     STATUS_DISABLED = 'disabled'
 
 
     CHOICES = [
     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'
     ENCAP_WIREGUARD = 'wireguard'
 
 
     CHOICES = [
     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'
     TYPE_VIRTUALMACHINE = 'virtualization.virtualmachine'
 
 
     CHOICES = (
     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'
     ROLE_SPOKE = 'spoke'
 
 
     CHOICES = [
     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
     VERSION_2 = 2
 
 
     CHOICES = (
     CHOICES = (
-        (VERSION_1, 'IKEv1'),
-        (VERSION_2, 'IKEv2'),
+        Choice(VERSION_1, 'IKEv1'),
+        Choice(VERSION_2, 'IKEv2'),
     )
     )
 
 
 
 
@@ -85,8 +98,8 @@ class IKEModeChoices(ChoiceSet):
     MAIN = 'main'
     MAIN = 'main'
 
 
     CHOICES = (
     CHOICES = (
-        (AGGRESSIVE, _('Aggressive')),
-        (MAIN, _('Main')),
+        Choice(AGGRESSIVE, _('Aggressive')),
+        Choice(MAIN, _('Main')),
     )
     )
 
 
 
 
@@ -97,10 +110,10 @@ class AuthenticationMethodChoices(ChoiceSet):
     DSA_SIGNATURES = 'dsa-signatures'
     DSA_SIGNATURES = 'dsa-signatures'
 
 
     CHOICES = (
     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'
     AH = 'ah'
 
 
     CHOICES = (
     CHOICES = (
-        (ESP, 'ESP'),
-        (AH, 'AH'),
+        Choice(ESP, 'ESP'),
+        Choice(AH, 'AH'),
     )
     )
 
 
 
 
@@ -125,14 +138,14 @@ class EncryptionAlgorithmChoices(ChoiceSet):
     ENCRYPTION_DES = 'des-cbc'
     ENCRYPTION_DES = 'des-cbc'
 
 
     CHOICES = (
     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'
     AUTH_HMAC_MD5 = 'hmac-md5'
 
 
     CHOICES = (
     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 = (
     CHOICES = (
         # Strings are formatted in this manner to optimize translations
         # 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 = (
     CHOICES = (
         ('VPLS', (
         ('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', (
         ('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', (
         ('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', (
         ('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', (
         ('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', (
         ('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', (
         ('Other', (
-            (TYPE_SPB, _('SPB')),
+            Choice(TYPE_SPB, _('SPB'), description=_('Shortest Path Bridging')),
         )),
         )),
     )
     )
 
 
@@ -277,7 +310,12 @@ class L2VPNStatusChoices(ChoiceSet):
     STATUS_DECOMMISSIONING = 'decommissioning'
     STATUS_DECOMMISSIONING = 'decommissioning'
 
 
     CHOICES = [
     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 netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice
 from utilities.forms import add_blank_choice
-from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.fields import ChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
 from vpn.choices import *
 from vpn.choices import *
 from vpn.models import *
 from vpn.models import *
@@ -29,7 +29,7 @@ class TunnelGroupBulkEditForm(OrganizationalModelBulkEditForm):
 
 
 
 
 class TunnelBulkEditForm(PrimaryModelBulkEditForm):
 class TunnelBulkEditForm(PrimaryModelBulkEditForm):
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(TunnelStatusChoices),
         choices=add_blank_choice(TunnelStatusChoices),
         required=False
         required=False
@@ -39,7 +39,7 @@ class TunnelBulkEditForm(PrimaryModelBulkEditForm):
         label=_('Tunnel group'),
         label=_('Tunnel group'),
         required=False
         required=False
     )
     )
-    encapsulation = forms.ChoiceField(
+    encapsulation = ChoiceField(
         label=_('Encapsulation'),
         label=_('Encapsulation'),
         choices=add_blank_choice(TunnelEncapsulationChoices),
         choices=add_blank_choice(TunnelEncapsulationChoices),
         required=False
         required=False
@@ -71,7 +71,7 @@ class TunnelBulkEditForm(PrimaryModelBulkEditForm):
 
 
 
 
 class TunnelTerminationBulkEditForm(NetBoxModelBulkEditForm):
 class TunnelTerminationBulkEditForm(NetBoxModelBulkEditForm):
-    role = forms.ChoiceField(
+    role = ChoiceField(
         label=_('Role'),
         label=_('Role'),
         choices=add_blank_choice(TunnelTerminationRoleChoices),
         choices=add_blank_choice(TunnelTerminationRoleChoices),
         required=False
         required=False
@@ -81,22 +81,22 @@ class TunnelTerminationBulkEditForm(NetBoxModelBulkEditForm):
 
 
 
 
 class IKEProposalBulkEditForm(PrimaryModelBulkEditForm):
 class IKEProposalBulkEditForm(PrimaryModelBulkEditForm):
-    authentication_method = forms.ChoiceField(
+    authentication_method = ChoiceField(
         label=_('Authentication method'),
         label=_('Authentication method'),
         choices=add_blank_choice(AuthenticationMethodChoices),
         choices=add_blank_choice(AuthenticationMethodChoices),
         required=False
         required=False
     )
     )
-    encryption_algorithm = forms.ChoiceField(
+    encryption_algorithm = ChoiceField(
         label=_('Encryption algorithm'),
         label=_('Encryption algorithm'),
         choices=add_blank_choice(EncryptionAlgorithmChoices),
         choices=add_blank_choice(EncryptionAlgorithmChoices),
         required=False
         required=False
     )
     )
-    authentication_algorithm = forms.ChoiceField(
+    authentication_algorithm = ChoiceField(
         label=_('Authentication algorithm'),
         label=_('Authentication algorithm'),
         choices=add_blank_choice(AuthenticationAlgorithmChoices),
         choices=add_blank_choice(AuthenticationAlgorithmChoices),
         required=False
         required=False
     )
     )
-    group = forms.ChoiceField(
+    group = ChoiceField(
         label=_('Group'),
         label=_('Group'),
         choices=add_blank_choice(DHGroupChoices),
         choices=add_blank_choice(DHGroupChoices),
         required=False
         required=False
@@ -119,12 +119,12 @@ class IKEProposalBulkEditForm(PrimaryModelBulkEditForm):
 
 
 
 
 class IKEPolicyBulkEditForm(PrimaryModelBulkEditForm):
 class IKEPolicyBulkEditForm(PrimaryModelBulkEditForm):
-    version = forms.ChoiceField(
+    version = ChoiceField(
         label=_('Version'),
         label=_('Version'),
         choices=add_blank_choice(IKEVersionChoices),
         choices=add_blank_choice(IKEVersionChoices),
         required=False
         required=False
     )
     )
-    mode = forms.ChoiceField(
+    mode = ChoiceField(
         label=_('Mode'),
         label=_('Mode'),
         choices=add_blank_choice(IKEModeChoices),
         choices=add_blank_choice(IKEModeChoices),
         required=False
         required=False
@@ -144,12 +144,12 @@ class IKEPolicyBulkEditForm(PrimaryModelBulkEditForm):
 
 
 
 
 class IPSecProposalBulkEditForm(PrimaryModelBulkEditForm):
 class IPSecProposalBulkEditForm(PrimaryModelBulkEditForm):
-    encryption_algorithm = forms.ChoiceField(
+    encryption_algorithm = ChoiceField(
         label=_('Encryption algorithm'),
         label=_('Encryption algorithm'),
         choices=add_blank_choice(EncryptionAlgorithmChoices),
         choices=add_blank_choice(EncryptionAlgorithmChoices),
         required=False
         required=False
     )
     )
-    authentication_algorithm = forms.ChoiceField(
+    authentication_algorithm = ChoiceField(
         label=_('Authentication algorithm'),
         label=_('Authentication algorithm'),
         choices=add_blank_choice(AuthenticationAlgorithmChoices),
         choices=add_blank_choice(AuthenticationAlgorithmChoices),
         required=False
         required=False
@@ -176,7 +176,7 @@ class IPSecProposalBulkEditForm(PrimaryModelBulkEditForm):
 
 
 
 
 class IPSecPolicyBulkEditForm(PrimaryModelBulkEditForm):
 class IPSecPolicyBulkEditForm(PrimaryModelBulkEditForm):
-    pfs_group = forms.ChoiceField(
+    pfs_group = ChoiceField(
         label=_('PFS group'),
         label=_('PFS group'),
         choices=add_blank_choice(DHGroupChoices),
         choices=add_blank_choice(DHGroupChoices),
         required=False
         required=False
@@ -192,7 +192,7 @@ class IPSecPolicyBulkEditForm(PrimaryModelBulkEditForm):
 
 
 
 
 class IPSecProfileBulkEditForm(PrimaryModelBulkEditForm):
 class IPSecProfileBulkEditForm(PrimaryModelBulkEditForm):
-    mode = forms.ChoiceField(
+    mode = ChoiceField(
         label=_('Mode'),
         label=_('Mode'),
         choices=add_blank_choice(IPSecModeChoices),
         choices=add_blank_choice(IPSecModeChoices),
         required=False
         required=False
@@ -218,11 +218,11 @@ class IPSecProfileBulkEditForm(PrimaryModelBulkEditForm):
 
 
 
 
 class L2VPNBulkEditForm(PrimaryModelBulkEditForm):
 class L2VPNBulkEditForm(PrimaryModelBulkEditForm):
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=L2VPNStatusChoices,
         choices=L2VPNStatusChoices,
     )
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=add_blank_choice(L2VPNTypeChoices),
         choices=add_blank_choice(L2VPNTypeChoices),
         required=False
         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 ipam.models import VLAN, IPAddress, RouteTarget
 from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
 from tenancy.forms import TenancyForm
 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.rendering import FieldSet, TabbedGroups
 from utilities.forms.utils import add_blank_choice, get_field_value
 from utilities.forms.utils import add_blank_choice, get_field_value
 from utilities.forms.widgets import HTMXSelect
 from utilities.forms.widgets import HTMXSelect
@@ -42,6 +48,15 @@ class TunnelGroupForm(OrganizationalModelForm):
 
 
 
 
 class TunnelForm(TenancyForm, PrimaryModelForm):
 class TunnelForm(TenancyForm, PrimaryModelForm):
+    status = ChoiceField(
+        label=_('Status'),
+        choices=TunnelStatusChoices,
+        initial=TunnelStatusChoices.STATUS_ACTIVE,
+    )
+    encapsulation = ChoiceField(
+        label=_('Encapsulation'),
+        choices=TunnelEncapsulationChoices,
+    )
     group = DynamicModelChoiceField(
     group = DynamicModelChoiceField(
         queryset=TunnelGroup.objects.all(),
         queryset=TunnelGroup.objects.all(),
         label=_('Tunnel Group'),
         label=_('Tunnel Group'),
@@ -70,12 +85,12 @@ class TunnelForm(TenancyForm, PrimaryModelForm):
 
 
 class TunnelCreateForm(TunnelForm):
 class TunnelCreateForm(TunnelForm):
     # First termination
     # First termination
-    termination1_role = forms.ChoiceField(
+    termination1_role = ChoiceField(
         choices=add_blank_choice(TunnelTerminationRoleChoices),
         choices=add_blank_choice(TunnelTerminationRoleChoices),
         required=False,
         required=False,
         label=_('Role')
         label=_('Role')
     )
     )
-    termination1_type = forms.ChoiceField(
+    termination1_type = ChoiceField(
         choices=TunnelTerminationTypeChoices,
         choices=TunnelTerminationTypeChoices,
         required=False,
         required=False,
         widget=HTMXSelect(hx_target_id='tunnel-termination1'),
         widget=HTMXSelect(hx_target_id='tunnel-termination1'),
@@ -105,12 +120,12 @@ class TunnelCreateForm(TunnelForm):
     )
     )
 
 
     # Second termination
     # Second termination
-    termination2_role = forms.ChoiceField(
+    termination2_role = ChoiceField(
         choices=add_blank_choice(TunnelTerminationRoleChoices),
         choices=add_blank_choice(TunnelTerminationRoleChoices),
         required=False,
         required=False,
         label=_('Role')
         label=_('Role')
     )
     )
-    termination2_type = forms.ChoiceField(
+    termination2_type = ChoiceField(
         choices=TunnelTerminationTypeChoices,
         choices=TunnelTerminationTypeChoices,
         required=False,
         required=False,
         widget=HTMXSelect(hx_target_id='tunnel-termination2'),
         widget=HTMXSelect(hx_target_id='tunnel-termination2'),
@@ -220,10 +235,15 @@ class TunnelCreateForm(TunnelForm):
 
 
 
 
 class TunnelTerminationForm(NetBoxModelForm):
 class TunnelTerminationForm(NetBoxModelForm):
+    role = ChoiceField(
+        label=_('Role'),
+        choices=TunnelTerminationRoleChoices,
+        initial=TunnelTerminationRoleChoices.ROLE_PEER,
+    )
     tunnel = DynamicModelChoiceField(
     tunnel = DynamicModelChoiceField(
         queryset=Tunnel.objects.all()
         queryset=Tunnel.objects.all()
     )
     )
-    type = forms.ChoiceField(
+    type = ChoiceField(
         choices=TunnelTerminationTypeChoices,
         choices=TunnelTerminationTypeChoices,
         widget=HTMXSelect(hx_target_id='tunnel-termination'),
         widget=HTMXSelect(hx_target_id='tunnel-termination'),
         label=_('Type')
         label=_('Type')
@@ -294,6 +314,19 @@ class TunnelTerminationForm(NetBoxModelForm):
 
 
 
 
 class IKEProposalForm(PrimaryModelForm):
 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 = (
     fieldsets = (
         FieldSet('name', 'description', 'tags', name=_('Proposal')),
         FieldSet('name', 'description', 'tags', name=_('Proposal')),
@@ -312,6 +345,11 @@ class IKEProposalForm(PrimaryModelForm):
 
 
 
 
 class IKEPolicyForm(PrimaryModelForm):
 class IKEPolicyForm(PrimaryModelForm):
+    mode = TypedChoiceField(
+        label=_('Mode'),
+        choices=add_blank_choice(IKEModeChoices),
+        required=False,
+    )
     proposals = DynamicModelMultipleChoiceField(
     proposals = DynamicModelMultipleChoiceField(
         queryset=IKEProposal.objects.all(),
         queryset=IKEProposal.objects.all(),
         label=_('Proposals'),
         label=_('Proposals'),
@@ -331,6 +369,16 @@ class IKEPolicyForm(PrimaryModelForm):
 
 
 
 
 class IPSecProposalForm(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 = (
     fieldsets = (
         FieldSet('name', 'description', 'tags', name=_('Proposal')),
         FieldSet('name', 'description', 'tags', name=_('Proposal')),
@@ -368,6 +416,10 @@ class IPSecPolicyForm(PrimaryModelForm):
 
 
 
 
 class IPSecProfileForm(PrimaryModelForm):
 class IPSecProfileForm(PrimaryModelForm):
+    mode = ChoiceField(
+        label=_('Mode'),
+        choices=IPSecModeChoices,
+    )
     ike_policy = DynamicModelChoiceField(
     ike_policy = DynamicModelChoiceField(
         queryset=IKEPolicy.objects.all(),
         queryset=IKEPolicy.objects.all(),
         label=_('IKE policy')
         label=_('IKE policy')
@@ -394,6 +446,15 @@ class IPSecProfileForm(PrimaryModelForm):
 #
 #
 
 
 class L2VPNForm(TenancyForm, PrimaryModelForm):
 class L2VPNForm(TenancyForm, PrimaryModelForm):
+    type = ChoiceField(
+        label=_('Type'),
+        choices=L2VPNTypeChoices,
+    )
+    status = ChoiceField(
+        label=_('Status'),
+        choices=L2VPNStatusChoices,
+        initial=L2VPNStatusChoices.STATUS_ACTIVE,
+    )
     slug = SlugField()
     slug = SlugField()
     import_targets = DynamicModelMultipleChoiceField(
     import_targets = DynamicModelMultipleChoiceField(
         label=_('Import targets'),
         label=_('Import targets'),

+ 229 - 211
netbox/wireless/choices.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from utilities.choices import ChoiceSet
+from utilities.choices import Choice, ChoiceSet
 
 
 
 
 class WirelessRoleChoices(ChoiceSet):
 class WirelessRoleChoices(ChoiceSet):
@@ -8,8 +8,8 @@ class WirelessRoleChoices(ChoiceSet):
     ROLE_STATION = 'station'
     ROLE_STATION = 'station'
 
 
     CHOICES = (
     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'
     STATUS_DEPRECATED = 'deprecated'
 
 
     CHOICES = [
     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)',
             '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)',
             '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)',
             '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)',
             '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'
     TYPE_WPA_ENTERPRISE = 'wpa-enterprise'
 
 
     CHOICES = (
     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'
     CIPHER_AES = 'aes'
 
 
     CHOICES = (
     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 netbox.forms import NestedGroupModelBulkEditForm, PrimaryModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice
 from utilities.forms import add_blank_choice
-from utilities.forms.fields import DynamicModelChoiceField
+from utilities.forms.fields import ChoiceField, DynamicModelChoiceField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
 from wireless.choices import *
 from wireless.choices import *
 from wireless.constants import SSID_MAX_LENGTH
 from wireless.constants import SSID_MAX_LENGTH
@@ -36,7 +36,7 @@ class WirelessLANGroupBulkEditForm(NestedGroupModelBulkEditForm):
 
 
 
 
 class WirelessLANBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
 class WirelessLANBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(WirelessLANStatusChoices),
         choices=add_blank_choice(WirelessLANStatusChoices),
         required=False
         required=False
@@ -61,12 +61,12 @@ class WirelessLANBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False
         required=False
     )
     )
-    auth_type = forms.ChoiceField(
+    auth_type = ChoiceField(
         label=_('Authentication type'),
         label=_('Authentication type'),
         choices=add_blank_choice(WirelessAuthTypeChoices),
         choices=add_blank_choice(WirelessAuthTypeChoices),
         required=False
         required=False
     )
     )
-    auth_cipher = forms.ChoiceField(
+    auth_cipher = ChoiceField(
         label=_('Authentication cipher'),
         label=_('Authentication cipher'),
         choices=add_blank_choice(WirelessAuthCipherChoices),
         choices=add_blank_choice(WirelessAuthCipherChoices),
         required=False
         required=False
@@ -93,7 +93,7 @@ class WirelessLinkBulkEditForm(PrimaryModelBulkEditForm):
         required=False,
         required=False,
         label=_('SSID')
         label=_('SSID')
     )
     )
-    status = forms.ChoiceField(
+    status = ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(LinkStatusChoices),
         choices=add_blank_choice(LinkStatusChoices),
         required=False
         required=False
@@ -103,12 +103,12 @@ class WirelessLinkBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False
         required=False
     )
     )
-    auth_type = forms.ChoiceField(
+    auth_type = ChoiceField(
         label=_('Authentication type'),
         label=_('Authentication type'),
         choices=add_blank_choice(WirelessAuthTypeChoices),
         choices=add_blank_choice(WirelessAuthTypeChoices),
         required=False
         required=False
     )
     )
-    auth_cipher = forms.ChoiceField(
+    auth_cipher = ChoiceField(
         label=_('Authentication cipher'),
         label=_('Authentication cipher'),
         choices=add_blank_choice(WirelessAuthCipherChoices),
         choices=add_blank_choice(WirelessAuthCipherChoices),
         required=False
         required=False
@@ -122,7 +122,7 @@ class WirelessLinkBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         min_value=0,
         required=False
         required=False
     )
     )
-    distance_unit = forms.ChoiceField(
+    distance_unit = ChoiceField(
         label=_('Distance unit'),
         label=_('Distance unit'),
         choices=add_blank_choice(DistanceUnitChoices),
         choices=add_blank_choice(DistanceUnitChoices),
         required=False,
         required=False,

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

@@ -1,14 +1,22 @@
 from django.forms import PasswordInput
 from django.forms import PasswordInput
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from dcim.choices import LinkStatusChoices
 from dcim.forms.mixins import ScopedForm
 from dcim.forms.mixins import ScopedForm
 from dcim.models import Device, Interface, Location, Site
 from dcim.models import Device, Interface, Location, Site
 from ipam.models import VLAN
 from ipam.models import VLAN
+from netbox.choices import DistanceUnitChoices
 from netbox.forms import NestedGroupModelForm, PrimaryModelForm
 from netbox.forms import NestedGroupModelForm, PrimaryModelForm
 from tenancy.forms import TenancyForm
 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.mixins import DistanceValidationMixin
 from utilities.forms.rendering import FieldSet, InlineFields
 from utilities.forms.rendering import FieldSet, InlineFields
+from wireless.choices import (
+    WirelessAuthCipherChoices,
+    WirelessAuthTypeChoices,
+    WirelessLANStatusChoices,
+)
 from wireless.models import *
 from wireless.models import *
 
 
 __all__ = (
 __all__ = (
@@ -37,6 +45,21 @@ class WirelessLANGroupForm(NestedGroupModelForm):
 
 
 
 
 class WirelessLANForm(ScopedForm, TenancyForm, PrimaryModelForm):
 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(
     group = DynamicModelChoiceField(
         label=_('Group'),
         label=_('Group'),
         queryset=WirelessLANGroup.objects.all(),
         queryset=WirelessLANGroup.objects.all(),
@@ -72,6 +95,26 @@ class WirelessLANForm(ScopedForm, TenancyForm, PrimaryModelForm):
 
 
 
 
 class WirelessLinkForm(DistanceValidationMixin, 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(
     site_a = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff