Răsfoiți Sursa

Merge pull request #3732 from netbox-community/3569-api-choice-slugs

Replace API integer API choice values with slugs
Jeremy Stretch 6 ani în urmă
părinte
comite
dcb2c4722c
76 a modificat fișierele cu 2760 adăugiri și 1401 ștergeri
  1. 2 2
      netbox/circuits/api/serializers.py
  2. 48 0
      netbox/circuits/choices.py
  3. 0 23
      netbox/circuits/constants.py
  4. 2 2
      netbox/circuits/filters.py
  5. 4 4
      netbox/circuits/forms.py
  6. 39 0
      netbox/circuits/migrations/0016_3569_circuit_fields.py
  7. 17 7
      netbox/circuits/models.py
  8. 28 13
      netbox/circuits/tests/test_api.py
  9. 9 5
      netbox/circuits/views.py
  10. 34 34
      netbox/dcim/api/serializers.py
  11. 493 120
      netbox/dcim/choices.py
  12. 9 469
      netbox/dcim/constants.py
  13. 9 9
      netbox/dcim/filters.py
  14. 11 11
      netbox/dcim/fixtures/dcim.json
  15. 82 99
      netbox/dcim/forms.py
  16. 35 0
      netbox/dcim/migrations/0078_3569_site_fields.py
  17. 92 0
      netbox/dcim/migrations/0079_3569_rack_fields.py
  18. 39 0
      netbox/dcim/migrations/0080_3569_devicetype_fields.py
  19. 65 0
      netbox/dcim/migrations/0081_3569_device_fields.py
  20. 147 0
      netbox/dcim/migrations/0082_3569_interface_fields.py
  21. 93 0
      netbox/dcim/migrations/0082_3569_port_fields.py
  22. 85 0
      netbox/dcim/migrations/0083_3569_cable_fields.py
  23. 100 0
      netbox/dcim/migrations/0084_3569_powerfeed_fields.py
  24. 62 0
      netbox/dcim/migrations/0085_3569_poweroutlet_fields.py
  25. 151 102
      netbox/dcim/models.py
  26. 0 8
      netbox/dcim/tables.py
  27. 44 43
      netbox/dcim/tests/test_api.py
  28. 8 8
      netbox/dcim/tests/test_forms.py
  29. 17 17
      netbox/dcim/tests/test_models.py
  30. 19 19
      netbox/dcim/tests/test_views.py
  31. 6 6
      netbox/extras/api/customfields.py
  32. 4 3
      netbox/extras/api/serializers.py
  33. 140 0
      netbox/extras/choices.py
  34. 0 87
      netbox/extras/constants.py
  35. 9 3
      netbox/extras/filters.py
  36. 8 7
      netbox/extras/forms.py
  37. 7 7
      netbox/extras/middleware.py
  38. 0 2
      netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py
  39. 5 7
      netbox/extras/migrations/0010_customfield_filter_logic.py
  40. 69 0
      netbox/extras/migrations/0029_3569_customfield_fields.py
  41. 36 0
      netbox/extras/migrations/0030_3569_objectchange_fields.py
  42. 35 0
      netbox/extras/migrations/0031_3569_exporttemplate_fields.py
  43. 35 0
      netbox/extras/migrations/0032_3569_webhook_fields.py
  44. 35 26
      netbox/extras/models.py
  45. 3 3
      netbox/extras/tables.py
  46. 5 4
      netbox/extras/tests/test_changelog.py
  47. 17 17
      netbox/extras/tests/test_customfields.py
  48. 2 2
      netbox/extras/tests/test_views.py
  49. 4 3
      netbox/extras/webhooks.py
  50. 2 1
      netbox/extras/webhooks_worker.py
  51. 7 7
      netbox/ipam/api/serializers.py
  52. 130 0
      netbox/ipam/choices.py
  53. 0 97
      netbox/ipam/constants.py
  54. 5 5
      netbox/ipam/filters.py
  55. 3 3
      netbox/ipam/fixtures/ipam.json
  56. 15 15
      netbox/ipam/forms.py
  57. 37 0
      netbox/ipam/migrations/0028_3569_prefix_fields.py
  58. 69 0
      netbox/ipam/migrations/0029_3569_ipaddress_fields.py
  59. 36 0
      netbox/ipam/migrations/0030_3569_vlan_fields.py
  60. 35 0
      netbox/ipam/migrations/0031_3569_service_fields.py
  61. 74 25
      netbox/ipam/models.py
  62. 9 9
      netbox/ipam/tests/test_api.py
  63. 3 3
      netbox/ipam/tests/test_models.py
  64. 4 4
      netbox/ipam/tests/test_views.py
  65. 6 6
      netbox/ipam/views.py
  66. 14 1
      netbox/utilities/api.py
  67. 36 0
      netbox/utilities/choices.py
  68. 12 6
      netbox/utilities/utils.py
  69. 5 5
      netbox/virtualization/api/serializers.py
  70. 24 0
      netbox/virtualization/choices.py
  71. 0 15
      netbox/virtualization/constants.py
  72. 2 2
      netbox/virtualization/filters.py
  73. 12 12
      netbox/virtualization/forms.py
  74. 36 0
      netbox/virtualization/migrations/0011_3569_virtualmachine_fields.py
  75. 12 5
      netbox/virtualization/models.py
  76. 8 8
      netbox/virtualization/tests/test_api.py

+ 2 - 2
netbox/circuits/api/serializers.py

@@ -1,7 +1,7 @@
 from rest_framework import serializers
 from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
-from circuits.constants import CIRCUIT_STATUS_CHOICES
+from circuits.choices import CircuitStatusChoices
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
 from dcim.api.serializers import ConnectedEndpointSerializer
@@ -41,7 +41,7 @@ class CircuitTypeSerializer(ValidatedModelSerializer):
 
 class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
     provider = NestedProviderSerializer()
-    status = ChoiceField(choices=CIRCUIT_STATUS_CHOICES, required=False)
+    status = ChoiceField(choices=CircuitStatusChoices, required=False)
     type = NestedCircuitTypeSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tags = TagListSerializerField(required=False)

+ 48 - 0
netbox/circuits/choices.py

@@ -0,0 +1,48 @@
+from utilities.choices import ChoiceSet
+
+
+#
+# Circuits
+#
+
+class CircuitStatusChoices(ChoiceSet):
+
+    STATUS_DEPROVISIONING = 'deprovisioning'
+    STATUS_ACTIVE = 'active'
+    STATUS_PLANNED = 'planned'
+    STATUS_PROVISIONING = 'provisioning'
+    STATUS_OFFLINE = 'offline'
+    STATUS_DECOMMISSIONED = 'decommissioned'
+
+    CHOICES = (
+        (STATUS_PLANNED, 'Planned'),
+        (STATUS_PROVISIONING, 'Provisioning'),
+        (STATUS_ACTIVE, 'Active'),
+        (STATUS_OFFLINE, 'Offline'),
+        (STATUS_DEPROVISIONING, 'Deprovisioning'),
+        (STATUS_DECOMMISSIONED, 'Decommissioned'),
+    )
+
+    LEGACY_MAP = {
+        STATUS_DEPROVISIONING: 0,
+        STATUS_ACTIVE: 1,
+        STATUS_PLANNED: 2,
+        STATUS_PROVISIONING: 3,
+        STATUS_OFFLINE: 4,
+        STATUS_DECOMMISSIONED: 5,
+    }
+
+
+#
+# CircuitTerminations
+#
+
+class CircuitTerminationSideChoices(ChoiceSet):
+
+    SIDE_A = 'A'
+    SIDE_Z = 'Z'
+
+    CHOICES = (
+        (SIDE_A, 'A'),
+        (SIDE_Z, 'Z')
+    )

+ 0 - 23
netbox/circuits/constants.py

@@ -1,23 +0,0 @@
-# Circuit statuses
-CIRCUIT_STATUS_DEPROVISIONING = 0
-CIRCUIT_STATUS_ACTIVE = 1
-CIRCUIT_STATUS_PLANNED = 2
-CIRCUIT_STATUS_PROVISIONING = 3
-CIRCUIT_STATUS_OFFLINE = 4
-CIRCUIT_STATUS_DECOMMISSIONED = 5
-CIRCUIT_STATUS_CHOICES = [
-    [CIRCUIT_STATUS_PLANNED, 'Planned'],
-    [CIRCUIT_STATUS_PROVISIONING, 'Provisioning'],
-    [CIRCUIT_STATUS_ACTIVE, 'Active'],
-    [CIRCUIT_STATUS_OFFLINE, 'Offline'],
-    [CIRCUIT_STATUS_DEPROVISIONING, 'Deprovisioning'],
-    [CIRCUIT_STATUS_DECOMMISSIONED, 'Decommissioned'],
-]
-
-# CircuitTermination sides
-TERM_SIDE_A = 'A'
-TERM_SIDE_Z = 'Z'
-TERM_SIDE_CHOICES = (
-    (TERM_SIDE_A, 'A'),
-    (TERM_SIDE_Z, 'Z'),
-)

+ 2 - 2
netbox/circuits/filters.py

@@ -5,7 +5,7 @@ from dcim.models import Region, Site
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
-from .constants import *
+from .choices import *
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 
 
@@ -84,7 +84,7 @@ class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilter
         label='Circuit type (slug)',
     )
     status = django_filters.MultipleChoiceFilter(
-        choices=CIRCUIT_STATUS_CHOICES,
+        choices=CircuitStatusChoices,
         null_value=None
     )
     site_id = django_filters.ModelMultipleChoiceFilter(

+ 4 - 4
netbox/circuits/forms.py

@@ -9,7 +9,7 @@ from utilities.forms import (
     APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField,
     FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple
 )
-from .constants import *
+from .choices import CircuitStatusChoices
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 
 
@@ -194,7 +194,7 @@ class CircuitCSVForm(forms.ModelForm):
         }
     )
     status = CSVChoiceField(
-        choices=CIRCUIT_STATUS_CHOICES,
+        choices=CircuitStatusChoices,
         required=False,
         help_text='Operational status'
     )
@@ -235,7 +235,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
         )
     )
     status = forms.ChoiceField(
-        choices=add_blank_choice(CIRCUIT_STATUS_CHOICES),
+        choices=add_blank_choice(CircuitStatusChoices),
         required=False,
         initial='',
         widget=StaticSelect2()
@@ -292,7 +292,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
         )
     )
     status = forms.MultipleChoiceField(
-        choices=CIRCUIT_STATUS_CHOICES,
+        choices=CircuitStatusChoices,
         required=False,
         widget=StaticSelect2Multiple()
     )

+ 39 - 0
netbox/circuits/migrations/0016_3569_circuit_fields.py

@@ -0,0 +1,39 @@
+from django.db import migrations, models
+
+
+CIRCUIT_STATUS_CHOICES = (
+    (0, 'deprovisioning'),
+    (1, 'active'),
+    (2, 'planned'),
+    (3, 'provisioning'),
+    (4, 'offline'),
+    (5, 'decommissioned')
+)
+
+
+def circuit_status_to_slug(apps, schema_editor):
+    Circuit = apps.get_model('circuits', 'Circuit')
+    for id, slug in CIRCUIT_STATUS_CHOICES:
+        Circuit.objects.filter(status=str(id)).update(status=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('circuits', '0015_custom_tag_models'),
+    ]
+
+    operations = [
+
+        # Circuit.status
+        migrations.AlterField(
+            model_name='circuit',
+            name='status',
+            field=models.CharField(default='active', max_length=50),
+        ),
+        migrations.RunPython(
+            code=circuit_status_to_slug
+        ),
+
+    ]

+ 17 - 7
netbox/circuits/models.py

@@ -3,13 +3,13 @@ from django.db import models
 from django.urls import reverse
 from taggit.managers import TaggableManager
 
-from dcim.constants import CONNECTION_STATUS_CHOICES, STATUS_CLASSES
+from dcim.constants import CONNECTION_STATUS_CHOICES
 from dcim.fields import ASNField
 from dcim.models import CableTermination
 from extras.models import CustomFieldModel, ObjectChange, TaggedItem
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object
-from .constants import *
+from .choices import *
 
 
 class Provider(ChangeLoggedModel, CustomFieldModel):
@@ -132,9 +132,10 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
         on_delete=models.PROTECT,
         related_name='circuits'
     )
-    status = models.PositiveSmallIntegerField(
-        choices=CIRCUIT_STATUS_CHOICES,
-        default=CIRCUIT_STATUS_ACTIVE
+    status = models.CharField(
+        max_length=50,
+        choices=CircuitStatusChoices,
+        default=CircuitStatusChoices.STATUS_ACTIVE
     )
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
@@ -171,6 +172,15 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
         'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
     ]
 
+    STATUS_CLASS_MAP = {
+        CircuitStatusChoices.STATUS_DEPROVISIONING: 'warning',
+        CircuitStatusChoices.STATUS_ACTIVE: 'success',
+        CircuitStatusChoices.STATUS_PLANNED: 'info',
+        CircuitStatusChoices.STATUS_PROVISIONING: 'primary',
+        CircuitStatusChoices.STATUS_OFFLINE: 'danger',
+        CircuitStatusChoices.STATUS_DECOMMISSIONED: 'default',
+    }
+
     class Meta:
         ordering = ['provider', 'cid']
         unique_together = ['provider', 'cid']
@@ -195,7 +205,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
         )
 
     def get_status_class(self):
-        return STATUS_CLASSES[self.status]
+        return self.STATUS_CLASS_MAP.get(self.status)
 
     def _get_termination(self, side):
         for ct in self.terminations.all():
@@ -220,7 +230,7 @@ class CircuitTermination(CableTermination):
     )
     term_side = models.CharField(
         max_length=1,
-        choices=TERM_SIDE_CHOICES,
+        choices=CircuitTerminationSideChoices,
         verbose_name='Termination'
     )
     site = models.ForeignKey(

+ 28 - 13
netbox/circuits/tests/test_api.py

@@ -1,9 +1,9 @@
 from django.urls import reverse
 from rest_framework import status
 
-from circuits.constants import CIRCUIT_STATUS_ACTIVE, TERM_SIDE_A, TERM_SIDE_Z
+from circuits.choices import *
 from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
-from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
+from dcim.models import Site
 from extras.constants import GRAPH_TYPE_PROVIDER
 from extras.models import Graph
 from utilities.testing import APITestCase
@@ -250,7 +250,7 @@ class CircuitTest(APITestCase):
             'cid': 'TEST0004',
             'provider': self.provider1.pk,
             'type': self.circuittype1.pk,
-            'status': CIRCUIT_STATUS_ACTIVE,
+            'status': CircuitStatusChoices.STATUS_ACTIVE,
         }
 
         url = reverse('circuits-api:circuit-list')
@@ -270,19 +270,19 @@ class CircuitTest(APITestCase):
                 'cid': 'TEST0004',
                 'provider': self.provider1.pk,
                 'type': self.circuittype1.pk,
-                'status': CIRCUIT_STATUS_ACTIVE,
+                'status': CircuitStatusChoices.STATUS_ACTIVE,
             },
             {
                 'cid': 'TEST0005',
                 'provider': self.provider1.pk,
                 'type': self.circuittype1.pk,
-                'status': CIRCUIT_STATUS_ACTIVE,
+                'status': CircuitStatusChoices.STATUS_ACTIVE,
             },
             {
                 'cid': 'TEST0006',
                 'provider': self.provider1.pk,
                 'type': self.circuittype1.pk,
-                'status': CIRCUIT_STATUS_ACTIVE,
+                'status': CircuitStatusChoices.STATUS_ACTIVE,
             },
         ]
 
@@ -336,16 +336,28 @@ class CircuitTerminationTest(APITestCase):
         self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype)
         self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype)
         self.circuittermination1 = CircuitTermination.objects.create(
-            circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
+            circuit=self.circuit1,
+            term_side=CircuitTerminationSideChoices.SIDE_A,
+            site=self.site1,
+            port_speed=1000000
         )
         self.circuittermination2 = CircuitTermination.objects.create(
-            circuit=self.circuit1, term_side=TERM_SIDE_Z, site=self.site2, port_speed=1000000
+            circuit=self.circuit1,
+            term_side=CircuitTerminationSideChoices.SIDE_Z,
+            site=self.site2,
+            port_speed=1000000
         )
         self.circuittermination3 = CircuitTermination.objects.create(
-            circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
+            circuit=self.circuit2,
+            term_side=CircuitTerminationSideChoices.SIDE_A,
+            site=self.site1,
+            port_speed=1000000
         )
         self.circuittermination4 = CircuitTermination.objects.create(
-            circuit=self.circuit2, term_side=TERM_SIDE_Z, site=self.site2, port_speed=1000000
+            circuit=self.circuit2,
+            term_side=CircuitTerminationSideChoices.SIDE_Z,
+            site=self.site2,
+            port_speed=1000000
         )
 
     def test_get_circuittermination(self):
@@ -366,7 +378,7 @@ class CircuitTerminationTest(APITestCase):
 
         data = {
             'circuit': self.circuit3.pk,
-            'term_side': TERM_SIDE_A,
+            'term_side': CircuitTerminationSideChoices.SIDE_A,
             'site': self.site1.pk,
             'port_speed': 1000000,
         }
@@ -385,12 +397,15 @@ class CircuitTerminationTest(APITestCase):
     def test_update_circuittermination(self):
 
         circuittermination5 = CircuitTermination.objects.create(
-            circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
+            circuit=self.circuit3,
+            term_side=CircuitTerminationSideChoices.SIDE_A,
+            site=self.site1,
+            port_speed=1000000
         )
 
         data = {
             'circuit': self.circuit3.pk,
-            'term_side': TERM_SIDE_Z,
+            'term_side': CircuitTerminationSideChoices.SIDE_Z,
             'site': self.site2.pk,
             'port_speed': 1000000,
         }

+ 9 - 5
netbox/circuits/views.py

@@ -12,7 +12,7 @@ from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 from . import filters, forms, tables
-from .constants import TERM_SIDE_A, TERM_SIDE_Z
+from .choices import CircuitTerminationSideChoices
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 
 
@@ -151,12 +151,12 @@ class CircuitView(PermissionRequiredMixin, View):
         termination_a = CircuitTermination.objects.prefetch_related(
             'site__region', 'connected_endpoint__device'
         ).filter(
-            circuit=circuit, term_side=TERM_SIDE_A
+            circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
         ).first()
         termination_z = CircuitTermination.objects.prefetch_related(
             'site__region', 'connected_endpoint__device'
         ).filter(
-            circuit=circuit, term_side=TERM_SIDE_Z
+            circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
         ).first()
 
         return render(request, 'circuits/circuit.html', {
@@ -212,8 +212,12 @@ class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 def circuit_terminations_swap(request, pk):
 
     circuit = get_object_or_404(Circuit, pk=pk)
-    termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first()
-    termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first()
+    termination_a = CircuitTermination.objects.filter(
+        circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
+    ).first()
+    termination_z = CircuitTermination.objects.filter(
+        circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
+    ).first()
     if not termination_a and not termination_z:
         messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
         return redirect('circuits:circuit', pk=circuit.pk)

+ 34 - 34
netbox/dcim/api/serializers.py

@@ -68,7 +68,7 @@ class RegionSerializer(serializers.ModelSerializer):
 
 
 class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
-    status = ChoiceField(choices=SITE_STATUS_CHOICES, required=False)
+    status = ChoiceField(choices=SiteStatusChoices, required=False)
     region = NestedRegionSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     time_zone = TimeZoneField(required=False)
@@ -115,11 +115,11 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
     site = NestedSiteSerializer()
     group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
-    status = ChoiceField(choices=RACK_STATUS_CHOICES, required=False)
+    status = ChoiceField(choices=RackStatusChoices, required=False)
     role = NestedRackRoleSerializer(required=False, allow_null=True)
-    type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False, allow_null=True)
-    width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False)
-    outer_unit = ChoiceField(choices=RACK_DIMENSION_UNIT_CHOICES, required=False)
+    type = ChoiceField(choices=RackTypeChoices, required=False, allow_null=True)
+    width = ChoiceField(choices=RackWidthChoices, required=False)
+    outer_unit = ChoiceField(choices=RackDimensionUnitChoices, required=False)
     tags = TagListSerializerField(required=False)
     device_count = serializers.IntegerField(read_only=True)
     powerfeed_count = serializers.IntegerField(read_only=True)
@@ -187,7 +187,7 @@ class ManufacturerSerializer(ValidatedModelSerializer):
 
 class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
     manufacturer = NestedManufacturerSerializer()
-    subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False, allow_null=True)
+    subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, required=False, allow_null=True)
     tags = TagListSerializerField(required=False)
     device_count = serializers.IntegerField(read_only=True)
 
@@ -202,7 +202,7 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class ConsolePortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     type = ChoiceField(
-        choices=ConsolePortTypes.CHOICES,
+        choices=ConsolePortTypeChoices,
         required=False
     )
 
@@ -214,7 +214,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
 class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     type = ChoiceField(
-        choices=ConsolePortTypes.CHOICES,
+        choices=ConsolePortTypeChoices,
         required=False
     )
 
@@ -226,7 +226,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
 class PowerPortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     type = ChoiceField(
-        choices=PowerPortTypes.CHOICES,
+        choices=PowerPortTypeChoices,
         required=False
     )
 
@@ -238,14 +238,14 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
 class PowerOutletTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     type = ChoiceField(
-        choices=PowerOutletTypes.CHOICES,
+        choices=PowerOutletTypeChoices,
         required=False
     )
     power_port = PowerPortTemplateSerializer(
         required=False
     )
     feed_leg = ChoiceField(
-        choices=POWERFEED_LEG_CHOICES,
+        choices=PowerOutletFeedLegChoices,
         required=False,
         allow_null=True
     )
@@ -257,7 +257,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
 
 class InterfaceTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
-    type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
+    type = ChoiceField(choices=InterfaceTypeChoices, required=False)
 
     class Meta:
         model = InterfaceTemplate
@@ -266,7 +266,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
 
 class RearPortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
-    type = ChoiceField(choices=PORT_TYPE_CHOICES)
+    type = ChoiceField(choices=PortTypeChoices)
 
     class Meta:
         model = RearPortTemplate
@@ -275,7 +275,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
 
 class FrontPortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
-    type = ChoiceField(choices=PORT_TYPE_CHOICES)
+    type = ChoiceField(choices=PortTypeChoices)
     rear_port = NestedRearPortTemplateSerializer()
 
     class Meta:
@@ -324,8 +324,8 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer()
     rack = NestedRackSerializer(required=False, allow_null=True)
-    face = ChoiceField(choices=RACK_FACE_CHOICES, required=False, allow_null=True)
-    status = ChoiceField(choices=DEVICE_STATUS_CHOICES, required=False)
+    face = ChoiceField(choices=DeviceFaceChoices, required=False, allow_null=True)
+    status = ChoiceField(choices=DeviceStatusChoices, required=False)
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
@@ -388,7 +388,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
 class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(
-        choices=ConsolePortTypes.CHOICES,
+        choices=ConsolePortTypeChoices,
         required=False
     )
     cable = NestedCableSerializer(read_only=True)
@@ -405,7 +405,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
 class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(
-        choices=ConsolePortTypes.CHOICES,
+        choices=ConsolePortTypeChoices,
         required=False
     )
     cable = NestedCableSerializer(read_only=True)
@@ -422,14 +422,14 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
 class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(
-        choices=PowerOutletTypes.CHOICES,
+        choices=PowerOutletTypeChoices,
         required=False
     )
     power_port = NestedPowerPortSerializer(
         required=False
     )
     feed_leg = ChoiceField(
-        choices=POWERFEED_LEG_CHOICES,
+        choices=PowerOutletFeedLegChoices,
         required=False,
         allow_null=True
     )
@@ -451,7 +451,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
 class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(
-        choices=PowerPortTypes.CHOICES,
+        choices=PowerPortTypeChoices,
         required=False
     )
     cable = NestedCableSerializer(read_only=True)
@@ -467,9 +467,9 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
 
 class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
-    type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
+    type = ChoiceField(choices=InterfaceTypeChoices, required=False)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
-    mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
+    mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
@@ -511,7 +511,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
 
 class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer):
     device = NestedDeviceSerializer()
-    type = ChoiceField(choices=PORT_TYPE_CHOICES)
+    type = ChoiceField(choices=PortTypeChoices)
     cable = NestedCableSerializer(read_only=True)
     tags = TagListSerializerField(required=False)
 
@@ -533,7 +533,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
 
 class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer):
     device = NestedDeviceSerializer()
-    type = ChoiceField(choices=PORT_TYPE_CHOICES)
+    type = ChoiceField(choices=PortTypeChoices)
     rear_port = FrontPortRearPortSerializer()
     cable = NestedCableSerializer(read_only=True)
     tags = TagListSerializerField(required=False)
@@ -586,7 +586,7 @@ class CableSerializer(ValidatedModelSerializer):
     termination_a = serializers.SerializerMethodField(read_only=True)
     termination_b = serializers.SerializerMethodField(read_only=True)
     status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False)
-    length_unit = ChoiceField(choices=CABLE_LENGTH_UNIT_CHOICES, required=False, allow_null=True)
+    length_unit = ChoiceField(choices=CableLengthUnitChoices, required=False, allow_null=True)
 
     class Meta:
         model = Cable
@@ -691,20 +691,20 @@ class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
         default=None
     )
     type = ChoiceField(
-        choices=POWERFEED_TYPE_CHOICES,
-        default=POWERFEED_TYPE_PRIMARY
+        choices=PowerFeedTypeChoices,
+        default=PowerFeedTypeChoices.TYPE_PRIMARY
     )
     status = ChoiceField(
-        choices=POWERFEED_STATUS_CHOICES,
-        default=POWERFEED_STATUS_ACTIVE
+        choices=PowerFeedStatusChoices,
+        default=PowerFeedStatusChoices.STATUS_ACTIVE
     )
     supply = ChoiceField(
-        choices=POWERFEED_SUPPLY_CHOICES,
-        default=POWERFEED_SUPPLY_AC
+        choices=PowerFeedSupplyChoices,
+        default=PowerFeedSupplyChoices.SUPPLY_AC
     )
     phase = ChoiceField(
-        choices=POWERFEED_PHASE_CHOICES,
-        default=POWERFEED_PHASE_SINGLE
+        choices=PowerFeedPhaseChoices,
+        default=PowerFeedPhaseChoices.PHASE_SINGLE
     )
     tags = TagListSerializerField(
         required=False

+ 493 - 120
netbox/dcim/choices.py

@@ -1,14 +1,187 @@
-from .constants import *
+from utilities.choices import ChoiceSet
 
 
 #
-# Console port type values
+# Sites
 #
 
-class ConsolePortTypes:
-    """
-    ConsolePort/ConsoleServerPort.type slugs
-    """
+class SiteStatusChoices(ChoiceSet):
+
+    STATUS_ACTIVE = 'active'
+    STATUS_PLANNED = 'planned'
+    STATUS_RETIRED = 'retired'
+
+    CHOICES = (
+        (STATUS_ACTIVE, 'Active'),
+        (STATUS_PLANNED, 'Planned'),
+        (STATUS_RETIRED, 'Retired'),
+    )
+
+    LEGACY_MAP = {
+        STATUS_ACTIVE: 1,
+        STATUS_PLANNED: 2,
+        STATUS_RETIRED: 4,
+    }
+
+
+#
+# Racks
+#
+
+class RackTypeChoices(ChoiceSet):
+
+    TYPE_2POST = '2-post-frame'
+    TYPE_4POST = '4-post-frame'
+    TYPE_CABINET = '4-post-cabinet'
+    TYPE_WALLFRAME = 'wall-frame'
+    TYPE_WALLCABINET = 'wall-cabinet'
+
+    CHOICES = (
+        (TYPE_2POST, '2-post frame'),
+        (TYPE_4POST, '4-post frame'),
+        (TYPE_CABINET, '4-post cabinet'),
+        (TYPE_WALLFRAME, 'Wall-mounted frame'),
+        (TYPE_WALLCABINET, 'Wall-mounted cabinet'),
+    )
+
+    LEGACY_MAP = {
+        TYPE_2POST: 100,
+        TYPE_4POST: 200,
+        TYPE_CABINET: 300,
+        TYPE_WALLFRAME: 1000,
+        TYPE_WALLCABINET: 1100,
+    }
+
+
+class RackWidthChoices(ChoiceSet):
+
+    WIDTH_19IN = 19
+    WIDTH_23IN = 23
+
+    CHOICES = (
+        (WIDTH_19IN, '19 inches'),
+        (WIDTH_23IN, '23 inches'),
+    )
+
+
+class RackStatusChoices(ChoiceSet):
+
+    STATUS_RESERVED = 'reserved'
+    STATUS_AVAILABLE = 'available'
+    STATUS_PLANNED = 'planned'
+    STATUS_ACTIVE = 'active'
+    STATUS_DEPRECATED = 'deprecated'
+
+    CHOICES = (
+        (STATUS_RESERVED, 'Reserved'),
+        (STATUS_AVAILABLE, 'Available'),
+        (STATUS_PLANNED, 'Planned'),
+        (STATUS_ACTIVE, 'Active'),
+        (STATUS_DEPRECATED, 'Deprecated'),
+    )
+
+    LEGACY_MAP = {
+        STATUS_RESERVED: 0,
+        STATUS_AVAILABLE: 1,
+        STATUS_PLANNED: 2,
+        STATUS_ACTIVE: 3,
+        STATUS_DEPRECATED: 4,
+    }
+
+
+class RackDimensionUnitChoices(ChoiceSet):
+
+    UNIT_MILLIMETER = 'mm'
+    UNIT_INCH = 'in'
+
+    CHOICES = (
+        (UNIT_MILLIMETER, 'Millimeters'),
+        (UNIT_INCH, 'Inches'),
+    )
+
+    LEGACY_MAP = {
+        UNIT_MILLIMETER: 1000,
+        UNIT_INCH: 2000,
+    }
+
+
+#
+# DeviceTypes
+#
+
+class SubdeviceRoleChoices(ChoiceSet):
+
+    ROLE_PARENT = 'parent'
+    ROLE_CHILD = 'child'
+
+    CHOICES = (
+        (ROLE_PARENT, 'Parent'),
+        (ROLE_CHILD, 'Child'),
+    )
+
+    LEGACY_MAP = {
+        ROLE_PARENT: True,
+        ROLE_CHILD: False,
+    }
+
+
+#
+# Devices
+#
+
+class DeviceFaceChoices(ChoiceSet):
+
+    FACE_FRONT = 'front'
+    FACE_REAR = 'rear'
+
+    CHOICES = (
+        (FACE_FRONT, 'Front'),
+        (FACE_REAR, 'Rear'),
+    )
+
+    LEGACY_MAP = {
+        FACE_FRONT: 0,
+        FACE_REAR: 1,
+    }
+
+
+class DeviceStatusChoices(ChoiceSet):
+
+    STATUS_OFFLINE = 'offline'
+    STATUS_ACTIVE = 'active'
+    STATUS_PLANNED = 'planned'
+    STATUS_STAGED = 'staged'
+    STATUS_FAILED = 'failed'
+    STATUS_INVENTORY = 'inventory'
+    STATUS_DECOMMISSIONING = 'decommissioning'
+
+    CHOICES = (
+        (STATUS_OFFLINE, 'Offline'),
+        (STATUS_ACTIVE, 'Active'),
+        (STATUS_PLANNED, 'Planned'),
+        (STATUS_STAGED, 'Staged'),
+        (STATUS_FAILED, 'Failed'),
+        (STATUS_INVENTORY, 'Inventory'),
+        (STATUS_DECOMMISSIONING, 'Decommissioning'),
+    )
+
+    LEGACY_MAP = {
+        STATUS_OFFLINE: 0,
+        STATUS_ACTIVE: 1,
+        STATUS_PLANNED: 2,
+        STATUS_STAGED: 3,
+        STATUS_FAILED: 4,
+        STATUS_INVENTORY: 5,
+        STATUS_DECOMMISSIONING: 6,
+    }
+
+
+#
+# ConsolePorts
+#
+
+class ConsolePortTypeChoices(ChoiceSet):
+
     TYPE_DE9 = 'de-9'
     TYPE_DB25 = 'db-25'
     TYPE_RJ45 = 'rj-45'
@@ -43,10 +216,11 @@ class ConsolePortTypes:
 
 
 #
-# Power port types
+# PowerPorts
 #
 
-class PowerPortTypes:
+class PowerPortTypeChoices(ChoiceSet):
+
     # TODO: Add more power port types
     # IEC 60320
     TYPE_IEC_C6 = 'iec-60320-c6'
@@ -130,10 +304,11 @@ class PowerPortTypes:
 
 
 #
-# Power outlet types
+# PowerOutlets
 #
 
-class PowerOutletTypes:
+class PowerOutletTypeChoices(ChoiceSet):
+
     # TODO: Add more power outlet types
     # IEC 60320
     TYPE_IEC_C5 = 'iec-60320-c5'
@@ -216,14 +391,31 @@ class PowerOutletTypes:
     )
 
 
+class PowerOutletFeedLegChoices(ChoiceSet):
+
+    FEED_LEG_A = 'A'
+    FEED_LEG_B = 'B'
+    FEED_LEG_C = 'C'
+
+    CHOICES = (
+        (FEED_LEG_A, 'A'),
+        (FEED_LEG_B, 'B'),
+        (FEED_LEG_C, 'C'),
+    )
+
+    LEGACY_MAP = {
+        FEED_LEG_A: 1,
+        FEED_LEG_B: 2,
+        FEED_LEG_C: 3,
+    }
+
+
 #
-# Interface type values
+# Interfaces
 #
 
-class InterfaceTypes:
-    """
-    Interface.type slugs
-    """
+class InterfaceTypeChoices(ChoiceSet):
+
     # Virtual
     TYPE_VIRTUAL = 'virtual'
     TYPE_LAG = 'lag'
@@ -315,7 +507,7 @@ class InterfaceTypes:
     # Other
     TYPE_OTHER = 'other'
 
-    TYPE_CHOICES = (
+    CHOICES = (
         (
             'Virtual interfaces',
             (
@@ -444,93 +636,105 @@ class InterfaceTypes:
         ),
     )
 
-    @classmethod
-    def slug_to_integer(cls, slug):
-        """
-        Provide backward-compatible mapping of the type slug to integer.
-        """
-        return {
-            # Slug: integer
-            cls.TYPE_VIRTUAL: IFACE_TYPE_VIRTUAL,
-            cls.TYPE_LAG: IFACE_TYPE_LAG,
-            cls.TYPE_100ME_FIXED: IFACE_TYPE_100ME_FIXED,
-            cls.TYPE_1GE_FIXED: IFACE_TYPE_1GE_FIXED,
-            cls.TYPE_1GE_GBIC: IFACE_TYPE_1GE_GBIC,
-            cls.TYPE_1GE_SFP: IFACE_TYPE_1GE_SFP,
-            cls.TYPE_2GE_FIXED: IFACE_TYPE_2GE_FIXED,
-            cls.TYPE_5GE_FIXED: IFACE_TYPE_5GE_FIXED,
-            cls.TYPE_10GE_FIXED: IFACE_TYPE_10GE_FIXED,
-            cls.TYPE_10GE_CX4: IFACE_TYPE_10GE_CX4,
-            cls.TYPE_10GE_SFP_PLUS: IFACE_TYPE_10GE_SFP_PLUS,
-            cls.TYPE_10GE_XFP: IFACE_TYPE_10GE_XFP,
-            cls.TYPE_10GE_XENPAK: IFACE_TYPE_10GE_XENPAK,
-            cls.TYPE_10GE_X2: IFACE_TYPE_10GE_X2,
-            cls.TYPE_25GE_SFP28: IFACE_TYPE_25GE_SFP28,
-            cls.TYPE_40GE_QSFP_PLUS: IFACE_TYPE_40GE_QSFP_PLUS,
-            cls.TYPE_50GE_QSFP28: IFACE_TYPE_50GE_QSFP28,
-            cls.TYPE_100GE_CFP: IFACE_TYPE_100GE_CFP,
-            cls.TYPE_100GE_CFP2: IFACE_TYPE_100GE_CFP2,
-            cls.TYPE_100GE_CFP4: IFACE_TYPE_100GE_CFP4,
-            cls.TYPE_100GE_CPAK: IFACE_TYPE_100GE_CPAK,
-            cls.TYPE_100GE_QSFP28: IFACE_TYPE_100GE_QSFP28,
-            cls.TYPE_200GE_CFP2: IFACE_TYPE_200GE_CFP2,
-            cls.TYPE_200GE_QSFP56: IFACE_TYPE_200GE_QSFP56,
-            cls.TYPE_400GE_QSFP_DD: IFACE_TYPE_400GE_QSFP_DD,
-            cls.TYPE_80211A: IFACE_TYPE_80211A,
-            cls.TYPE_80211G: IFACE_TYPE_80211G,
-            cls.TYPE_80211N: IFACE_TYPE_80211N,
-            cls.TYPE_80211AC: IFACE_TYPE_80211AC,
-            cls.TYPE_80211AD: IFACE_TYPE_80211AD,
-            cls.TYPE_GSM: IFACE_TYPE_GSM,
-            cls.TYPE_CDMA: IFACE_TYPE_CDMA,
-            cls.TYPE_LTE: IFACE_TYPE_LTE,
-            cls.TYPE_SONET_OC3: IFACE_TYPE_SONET_OC3,
-            cls.TYPE_SONET_OC12: IFACE_TYPE_SONET_OC12,
-            cls.TYPE_SONET_OC48: IFACE_TYPE_SONET_OC48,
-            cls.TYPE_SONET_OC192: IFACE_TYPE_SONET_OC192,
-            cls.TYPE_SONET_OC768: IFACE_TYPE_SONET_OC768,
-            cls.TYPE_SONET_OC1920: IFACE_TYPE_SONET_OC1920,
-            cls.TYPE_SONET_OC3840: IFACE_TYPE_SONET_OC3840,
-            cls.TYPE_1GFC_SFP: IFACE_TYPE_1GFC_SFP,
-            cls.TYPE_2GFC_SFP: IFACE_TYPE_2GFC_SFP,
-            cls.TYPE_4GFC_SFP: IFACE_TYPE_4GFC_SFP,
-            cls.TYPE_8GFC_SFP_PLUS: IFACE_TYPE_8GFC_SFP_PLUS,
-            cls.TYPE_16GFC_SFP_PLUS: IFACE_TYPE_16GFC_SFP_PLUS,
-            cls.TYPE_32GFC_SFP28: IFACE_TYPE_32GFC_SFP28,
-            cls.TYPE_128GFC_QSFP28: IFACE_TYPE_128GFC_QSFP28,
-            cls.TYPE_INFINIBAND_SDR: IFACE_TYPE_INFINIBAND_SDR,
-            cls.TYPE_INFINIBAND_DDR: IFACE_TYPE_INFINIBAND_DDR,
-            cls.TYPE_INFINIBAND_QDR: IFACE_TYPE_INFINIBAND_QDR,
-            cls.TYPE_INFINIBAND_FDR10: IFACE_TYPE_INFINIBAND_FDR10,
-            cls.TYPE_INFINIBAND_FDR: IFACE_TYPE_INFINIBAND_FDR,
-            cls.TYPE_INFINIBAND_EDR: IFACE_TYPE_INFINIBAND_EDR,
-            cls.TYPE_INFINIBAND_HDR: IFACE_TYPE_INFINIBAND_HDR,
-            cls.TYPE_INFINIBAND_NDR: IFACE_TYPE_INFINIBAND_NDR,
-            cls.TYPE_INFINIBAND_XDR: IFACE_TYPE_INFINIBAND_XDR,
-            cls.TYPE_T1: IFACE_TYPE_T1,
-            cls.TYPE_E1: IFACE_TYPE_E1,
-            cls.TYPE_T3: IFACE_TYPE_T3,
-            cls.TYPE_E3: IFACE_TYPE_E3,
-            cls.TYPE_STACKWISE: IFACE_TYPE_STACKWISE,
-            cls.TYPE_STACKWISE_PLUS: IFACE_TYPE_STACKWISE_PLUS,
-            cls.TYPE_FLEXSTACK: IFACE_TYPE_FLEXSTACK,
-            cls.TYPE_FLEXSTACK_PLUS: IFACE_TYPE_FLEXSTACK_PLUS,
-            cls.TYPE_JUNIPER_VCP: IFACE_TYPE_JUNIPER_VCP,
-            cls.TYPE_SUMMITSTACK: IFACE_TYPE_SUMMITSTACK,
-            cls.TYPE_SUMMITSTACK128: IFACE_TYPE_SUMMITSTACK128,
-            cls.TYPE_SUMMITSTACK256: IFACE_TYPE_SUMMITSTACK256,
-            cls.TYPE_SUMMITSTACK512: IFACE_TYPE_SUMMITSTACK512,
-        }.get(slug)
+    LEGACY_MAP = {
+        TYPE_VIRTUAL: 0,
+        TYPE_LAG: 200,
+        TYPE_100ME_FIXED: 800,
+        TYPE_1GE_FIXED: 1000,
+        TYPE_1GE_GBIC: 1050,
+        TYPE_1GE_SFP: 1100,
+        TYPE_2GE_FIXED: 1120,
+        TYPE_5GE_FIXED: 1130,
+        TYPE_10GE_FIXED: 1150,
+        TYPE_10GE_CX4: 1170,
+        TYPE_10GE_SFP_PLUS: 1200,
+        TYPE_10GE_XFP: 1300,
+        TYPE_10GE_XENPAK: 1310,
+        TYPE_10GE_X2: 1320,
+        TYPE_25GE_SFP28: 1350,
+        TYPE_40GE_QSFP_PLUS: 1400,
+        TYPE_50GE_QSFP28: 1420,
+        TYPE_100GE_CFP: 1500,
+        TYPE_100GE_CFP2: 1510,
+        TYPE_100GE_CFP4: 1520,
+        TYPE_100GE_CPAK: 1550,
+        TYPE_100GE_QSFP28: 1600,
+        TYPE_200GE_CFP2: 1650,
+        TYPE_200GE_QSFP56: 1700,
+        TYPE_400GE_QSFP_DD: 1750,
+        TYPE_400GE_OSFP: 1800,
+        TYPE_80211A: 2600,
+        TYPE_80211G: 2610,
+        TYPE_80211N: 2620,
+        TYPE_80211AC: 2630,
+        TYPE_80211AD: 2640,
+        TYPE_GSM: 2810,
+        TYPE_CDMA: 2820,
+        TYPE_LTE: 2830,
+        TYPE_SONET_OC3: 6100,
+        TYPE_SONET_OC12: 6200,
+        TYPE_SONET_OC48: 6300,
+        TYPE_SONET_OC192: 6400,
+        TYPE_SONET_OC768: 6500,
+        TYPE_SONET_OC1920: 6600,
+        TYPE_SONET_OC3840: 6700,
+        TYPE_1GFC_SFP: 3010,
+        TYPE_2GFC_SFP: 3020,
+        TYPE_4GFC_SFP: 3040,
+        TYPE_8GFC_SFP_PLUS: 3080,
+        TYPE_16GFC_SFP_PLUS: 3160,
+        TYPE_32GFC_SFP28: 3320,
+        TYPE_128GFC_QSFP28: 3400,
+        TYPE_INFINIBAND_SDR: 7010,
+        TYPE_INFINIBAND_DDR: 7020,
+        TYPE_INFINIBAND_QDR: 7030,
+        TYPE_INFINIBAND_FDR10: 7040,
+        TYPE_INFINIBAND_FDR: 7050,
+        TYPE_INFINIBAND_EDR: 7060,
+        TYPE_INFINIBAND_HDR: 7070,
+        TYPE_INFINIBAND_NDR: 7080,
+        TYPE_INFINIBAND_XDR: 7090,
+        TYPE_T1: 4000,
+        TYPE_E1: 4010,
+        TYPE_T3: 4040,
+        TYPE_E3: 4050,
+        TYPE_STACKWISE: 5000,
+        TYPE_STACKWISE_PLUS: 5050,
+        TYPE_FLEXSTACK: 5100,
+        TYPE_FLEXSTACK_PLUS: 5150,
+        TYPE_JUNIPER_VCP: 5200,
+        TYPE_SUMMITSTACK: 5300,
+        TYPE_SUMMITSTACK128: 5310,
+        TYPE_SUMMITSTACK256: 5320,
+        TYPE_SUMMITSTACK512: 5330,
+    }
+
+
+class InterfaceModeChoices(ChoiceSet):
+
+    MODE_ACCESS = 'access'
+    MODE_TAGGED = 'tagged'
+    MODE_TAGGED_ALL = 'tagged-all'
+
+    CHOICES = (
+        (MODE_ACCESS, 'Access'),
+        (MODE_TAGGED, 'Tagged'),
+        (MODE_TAGGED_ALL, 'Tagged (All)'),
+    )
+
+    LEGACY_MAP = {
+        MODE_ACCESS: 100,
+        MODE_TAGGED: 200,
+        MODE_TAGGED_ALL: 300,
+    }
 
 
 #
-# Port type values
+# FrontPorts/RearPorts
 #
 
-class PortTypes:
-    """
-    FrontPort/RearPort.type slugs
-    """
+class PortTypeChoices(ChoiceSet):
+
     TYPE_8P8C = '8p8c'
     TYPE_110_PUNCH = '110-punch'
     TYPE_BNC = 'bnc'
@@ -545,7 +749,7 @@ class PortTypes:
     TYPE_LSH = 'lsh'
     TYPE_LSH_APC = 'lsh-apc'
 
-    TYPE_CHOICES = (
+    CHOICES = (
         (
             'Copper',
             (
@@ -571,24 +775,193 @@ class PortTypes:
         )
     )
 
-    @classmethod
-    def slug_to_integer(cls, slug):
-        """
-        Provide backward-compatible mapping of the type slug to integer.
-        """
-        return {
-            # Slug: integer
-            cls.TYPE_8P8C: PORT_TYPE_8P8C,
-            cls.TYPE_110_PUNCH: PORT_TYPE_8P8C,
-            cls.TYPE_BNC: PORT_TYPE_BNC,
-            cls.TYPE_ST: PORT_TYPE_ST,
-            cls.TYPE_SC: PORT_TYPE_SC,
-            cls.TYPE_SC_APC: PORT_TYPE_SC_APC,
-            cls.TYPE_FC: PORT_TYPE_FC,
-            cls.TYPE_LC: PORT_TYPE_LC,
-            cls.TYPE_LC_APC: PORT_TYPE_LC_APC,
-            cls.TYPE_MTRJ: PORT_TYPE_MTRJ,
-            cls.TYPE_MPO: PORT_TYPE_MPO,
-            cls.TYPE_LSH: PORT_TYPE_LSH,
-            cls.TYPE_LSH_APC: PORT_TYPE_LSH_APC,
-        }.get(slug)
+    LEGACY_MAP = {
+        TYPE_8P8C: 1000,
+        TYPE_110_PUNCH: 1100,
+        TYPE_BNC: 1200,
+        TYPE_ST: 2000,
+        TYPE_SC: 2100,
+        TYPE_SC_APC: 2110,
+        TYPE_FC: 2200,
+        TYPE_LC: 2300,
+        TYPE_LC_APC: 2310,
+        TYPE_MTRJ: 2400,
+        TYPE_MPO: 2500,
+        TYPE_LSH: 2600,
+        TYPE_LSH_APC: 2610,
+    }
+
+
+#
+# Cables
+#
+
+class CableTypeChoices(ChoiceSet):
+
+    TYPE_CAT3 = 'cat3'
+    TYPE_CAT5 = 'cat5'
+    TYPE_CAT5E = 'cat5e'
+    TYPE_CAT6 = 'cat6'
+    TYPE_CAT6A = 'cat6a'
+    TYPE_CAT7 = 'cat7'
+    TYPE_DAC_ACTIVE = 'dac-active'
+    TYPE_DAC_PASSIVE = 'dac-passive'
+    TYPE_COAXIAL = 'coaxial'
+    TYPE_MMF = 'mmf'
+    TYPE_MMF_OM1 = 'mmf-om1'
+    TYPE_MMF_OM2 = 'mmf-om2'
+    TYPE_MMF_OM3 = 'mmf-om3'
+    TYPE_MMF_OM4 = 'mmf-om4'
+    TYPE_SMF = 'smf'
+    TYPE_SMF_OS1 = 'smf-os1'
+    TYPE_SMF_OS2 = 'smf-os2'
+    TYPE_AOC = 'aoc'
+    TYPE_POWER = 'power'
+
+    CHOICES = (
+        (
+            'Copper', (
+                (TYPE_CAT3, 'CAT3'),
+                (TYPE_CAT5, 'CAT5'),
+                (TYPE_CAT5E, 'CAT5e'),
+                (TYPE_CAT6, 'CAT6'),
+                (TYPE_CAT6A, 'CAT6a'),
+                (TYPE_CAT7, 'CAT7'),
+                (TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'),
+                (TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'),
+                (TYPE_COAXIAL, 'Coaxial'),
+            ),
+        ),
+        (
+            'Fiber', (
+                (TYPE_MMF, 'Multimode Fiber'),
+                (TYPE_MMF_OM1, 'Multimode Fiber (OM1)'),
+                (TYPE_MMF_OM2, 'Multimode Fiber (OM2)'),
+                (TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
+                (TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
+                (TYPE_SMF, 'Singlemode Fiber'),
+                (TYPE_SMF_OS1, 'Singlemode Fiber (OS1)'),
+                (TYPE_SMF_OS2, 'Singlemode Fiber (OS2)'),
+                (TYPE_AOC, 'Active Optical Cabling (AOC)'),
+            ),
+        ),
+        (TYPE_POWER, 'Power'),
+    )
+
+    LEGACY_MAP = {
+        TYPE_CAT3: 1300,
+        TYPE_CAT5: 1500,
+        TYPE_CAT5E: 1510,
+        TYPE_CAT6: 1600,
+        TYPE_CAT6A: 1610,
+        TYPE_CAT7: 1700,
+        TYPE_DAC_ACTIVE: 1800,
+        TYPE_DAC_PASSIVE: 1810,
+        TYPE_COAXIAL: 1900,
+        TYPE_MMF: 3000,
+        TYPE_MMF_OM1: 3010,
+        TYPE_MMF_OM2: 3020,
+        TYPE_MMF_OM3: 3030,
+        TYPE_MMF_OM4: 3040,
+        TYPE_SMF: 3500,
+        TYPE_SMF_OS1: 3510,
+        TYPE_SMF_OS2: 3520,
+        TYPE_AOC: 3800,
+        TYPE_POWER: 5000,
+    }
+
+
+class CableLengthUnitChoices(ChoiceSet):
+
+    UNIT_METER = 'm'
+    UNIT_CENTIMETER = 'cm'
+    UNIT_FOOT = 'ft'
+    UNIT_INCH = 'in'
+
+    CHOICES = (
+        (UNIT_METER, 'Meters'),
+        (UNIT_CENTIMETER, 'Centimeters'),
+        (UNIT_FOOT, 'Feet'),
+        (UNIT_INCH, 'Inches'),
+    )
+
+    LEGACY_MAP = {
+        UNIT_METER: 1200,
+        UNIT_CENTIMETER: 1100,
+        UNIT_FOOT: 2100,
+        UNIT_INCH: 2000,
+    }
+
+
+#
+# PowerFeeds
+#
+
+class PowerFeedStatusChoices(ChoiceSet):
+
+    STATUS_OFFLINE = 'offline'
+    STATUS_ACTIVE = 'active'
+    STATUS_PLANNED = 'planned'
+    STATUS_FAILED = 'failed'
+
+    CHOICES = (
+        (STATUS_OFFLINE, 'Offline'),
+        (STATUS_ACTIVE, 'Active'),
+        (STATUS_PLANNED, 'Planned'),
+        (STATUS_FAILED, 'Failed'),
+    )
+
+    LEGACY_MAP = {
+        STATUS_OFFLINE: 0,
+        STATUS_ACTIVE: 1,
+        STATUS_PLANNED: 2,
+        STATUS_FAILED: 4,
+    }
+
+
+class PowerFeedTypeChoices(ChoiceSet):
+
+    TYPE_PRIMARY = 'primary'
+    TYPE_REDUNDANT = 'redundant'
+
+    CHOICES = (
+        (TYPE_PRIMARY, 'Primary'),
+        (TYPE_REDUNDANT, 'Redundant'),
+    )
+
+    LEGACY_MAP = {
+        TYPE_PRIMARY: 1,
+        TYPE_REDUNDANT: 2,
+    }
+
+
+class PowerFeedSupplyChoices(ChoiceSet):
+
+    SUPPLY_AC = 'ac'
+    SUPPLY_DC = 'dc'
+
+    CHOICES = (
+        (SUPPLY_AC, 'AC'),
+        (SUPPLY_DC, 'DC'),
+    )
+
+    LEGACY_MAP = {
+        SUPPLY_AC: 1,
+        SUPPLY_DC: 2,
+    }
+
+
+class PowerFeedPhaseChoices(ChoiceSet):
+
+    PHASE_SINGLE = 'single-phase'
+    PHASE_3PHASE = 'three-phase'
+
+    CHOICES = (
+        (PHASE_SINGLE, 'Single phase'),
+        (PHASE_3PHASE, 'Three-phase'),
+    )
+
+    LEGACY_MAP = {
+        PHASE_SINGLE: 1,
+        PHASE_3PHASE: 3,
+    }

+ 9 - 469
netbox/dcim/constants.py

@@ -1,381 +1,25 @@
-# Rack types
-RACK_TYPE_2POST = 100
-RACK_TYPE_4POST = 200
-RACK_TYPE_CABINET = 300
-RACK_TYPE_WALLFRAME = 1000
-RACK_TYPE_WALLCABINET = 1100
-RACK_TYPE_CHOICES = (
-    (RACK_TYPE_2POST, '2-post frame'),
-    (RACK_TYPE_4POST, '4-post frame'),
-    (RACK_TYPE_CABINET, '4-post cabinet'),
-    (RACK_TYPE_WALLFRAME, 'Wall-mounted frame'),
-    (RACK_TYPE_WALLCABINET, 'Wall-mounted cabinet'),
-)
+from .choices import InterfaceTypeChoices
 
-# Rack widths
-RACK_WIDTH_19IN = 19
-RACK_WIDTH_23IN = 23
-RACK_WIDTH_CHOICES = (
-    (RACK_WIDTH_19IN, '19 inches'),
-    (RACK_WIDTH_23IN, '23 inches'),
-)
-
-# Rack faces
-RACK_FACE_FRONT = 0
-RACK_FACE_REAR = 1
-RACK_FACE_CHOICES = [
-    [RACK_FACE_FRONT, 'Front'],
-    [RACK_FACE_REAR, 'Rear'],
-]
-
-# Rack statuses
-RACK_STATUS_RESERVED = 0
-RACK_STATUS_AVAILABLE = 1
-RACK_STATUS_PLANNED = 2
-RACK_STATUS_ACTIVE = 3
-RACK_STATUS_DEPRECATED = 4
-RACK_STATUS_CHOICES = [
-    [RACK_STATUS_ACTIVE, 'Active'],
-    [RACK_STATUS_PLANNED, 'Planned'],
-    [RACK_STATUS_RESERVED, 'Reserved'],
-    [RACK_STATUS_AVAILABLE, 'Available'],
-    [RACK_STATUS_DEPRECATED, 'Deprecated'],
-]
-
-# Device rack position
-DEVICE_POSITION_CHOICES = [
-    # Rack.u_height is limited to 100
-    (i, 'Unit {}'.format(i)) for i in range(1, 101)
-]
-
-# Parent/child device roles
-SUBDEVICE_ROLE_PARENT = True
-SUBDEVICE_ROLE_CHILD = False
-SUBDEVICE_ROLE_CHOICES = (
-    (None, 'None'),
-    (SUBDEVICE_ROLE_PARENT, 'Parent'),
-    (SUBDEVICE_ROLE_CHILD, 'Child'),
-)
 
 #
-# Numeric interface types
+# Interface type groups
 #
 
-# Virtual
-IFACE_TYPE_VIRTUAL = 0
-IFACE_TYPE_LAG = 200
-# Ethernet
-IFACE_TYPE_100ME_FIXED = 800
-IFACE_TYPE_1GE_FIXED = 1000
-IFACE_TYPE_1GE_GBIC = 1050
-IFACE_TYPE_1GE_SFP = 1100
-IFACE_TYPE_2GE_FIXED = 1120
-IFACE_TYPE_5GE_FIXED = 1130
-IFACE_TYPE_10GE_FIXED = 1150
-IFACE_TYPE_10GE_CX4 = 1170
-IFACE_TYPE_10GE_SFP_PLUS = 1200
-IFACE_TYPE_10GE_XFP = 1300
-IFACE_TYPE_10GE_XENPAK = 1310
-IFACE_TYPE_10GE_X2 = 1320
-IFACE_TYPE_25GE_SFP28 = 1350
-IFACE_TYPE_40GE_QSFP_PLUS = 1400
-IFACE_TYPE_50GE_QSFP28 = 1420
-IFACE_TYPE_100GE_CFP = 1500
-IFACE_TYPE_100GE_CFP2 = 1510
-IFACE_TYPE_100GE_CFP4 = 1520
-IFACE_TYPE_100GE_CPAK = 1550
-IFACE_TYPE_100GE_QSFP28 = 1600
-IFACE_TYPE_200GE_CFP2 = 1650
-IFACE_TYPE_200GE_QSFP56 = 1700
-IFACE_TYPE_400GE_QSFP_DD = 1750
-IFACE_TYPE_400GE_OSFP = 1800
-# Wireless
-IFACE_TYPE_80211A = 2600
-IFACE_TYPE_80211G = 2610
-IFACE_TYPE_80211N = 2620
-IFACE_TYPE_80211AC = 2630
-IFACE_TYPE_80211AD = 2640
-# Cellular
-IFACE_TYPE_GSM = 2810
-IFACE_TYPE_CDMA = 2820
-IFACE_TYPE_LTE = 2830
-# SONET
-IFACE_TYPE_SONET_OC3 = 6100
-IFACE_TYPE_SONET_OC12 = 6200
-IFACE_TYPE_SONET_OC48 = 6300
-IFACE_TYPE_SONET_OC192 = 6400
-IFACE_TYPE_SONET_OC768 = 6500
-IFACE_TYPE_SONET_OC1920 = 6600
-IFACE_TYPE_SONET_OC3840 = 6700
-# Fibrechannel
-IFACE_TYPE_1GFC_SFP = 3010
-IFACE_TYPE_2GFC_SFP = 3020
-IFACE_TYPE_4GFC_SFP = 3040
-IFACE_TYPE_8GFC_SFP_PLUS = 3080
-IFACE_TYPE_16GFC_SFP_PLUS = 3160
-IFACE_TYPE_32GFC_SFP28 = 3320
-IFACE_TYPE_128GFC_QSFP28 = 3400
-# InfiniBand
-IFACE_TYPE_INFINIBAND_SDR = 7010
-IFACE_TYPE_INFINIBAND_DDR = 7020
-IFACE_TYPE_INFINIBAND_QDR = 7030
-IFACE_TYPE_INFINIBAND_FDR10 = 7040
-IFACE_TYPE_INFINIBAND_FDR = 7050
-IFACE_TYPE_INFINIBAND_EDR = 7060
-IFACE_TYPE_INFINIBAND_HDR = 7070
-IFACE_TYPE_INFINIBAND_NDR = 7080
-IFACE_TYPE_INFINIBAND_XDR = 7090
-# Serial
-IFACE_TYPE_T1 = 4000
-IFACE_TYPE_E1 = 4010
-IFACE_TYPE_T3 = 4040
-IFACE_TYPE_E3 = 4050
-# Stacking
-IFACE_TYPE_STACKWISE = 5000
-IFACE_TYPE_STACKWISE_PLUS = 5050
-IFACE_TYPE_FLEXSTACK = 5100
-IFACE_TYPE_FLEXSTACK_PLUS = 5150
-IFACE_TYPE_JUNIPER_VCP = 5200
-IFACE_TYPE_SUMMITSTACK = 5300
-IFACE_TYPE_SUMMITSTACK128 = 5310
-IFACE_TYPE_SUMMITSTACK256 = 5320
-IFACE_TYPE_SUMMITSTACK512 = 5330
-
-# Other
-IFACE_TYPE_OTHER = 32767
-
-IFACE_TYPE_CHOICES = [
-    [
-        'Virtual interfaces',
-        [
-            [IFACE_TYPE_VIRTUAL, 'Virtual'],
-            [IFACE_TYPE_LAG, 'Link Aggregation Group (LAG)'],
-        ],
-    ],
-    [
-        'Ethernet (fixed)',
-        [
-            [IFACE_TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'],
-            [IFACE_TYPE_1GE_FIXED, '1000BASE-T (1GE)'],
-            [IFACE_TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'],
-            [IFACE_TYPE_5GE_FIXED, '5GBASE-T (5GE)'],
-            [IFACE_TYPE_10GE_FIXED, '10GBASE-T (10GE)'],
-            [IFACE_TYPE_10GE_CX4, '10GBASE-CX4 (10GE)'],
-        ]
-    ],
-    [
-        'Ethernet (modular)',
-        [
-            [IFACE_TYPE_1GE_GBIC, 'GBIC (1GE)'],
-            [IFACE_TYPE_1GE_SFP, 'SFP (1GE)'],
-            [IFACE_TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'],
-            [IFACE_TYPE_10GE_XFP, 'XFP (10GE)'],
-            [IFACE_TYPE_10GE_XENPAK, 'XENPAK (10GE)'],
-            [IFACE_TYPE_10GE_X2, 'X2 (10GE)'],
-            [IFACE_TYPE_25GE_SFP28, 'SFP28 (25GE)'],
-            [IFACE_TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
-            [IFACE_TYPE_50GE_QSFP28, 'QSFP28 (50GE)'],
-            [IFACE_TYPE_100GE_CFP, 'CFP (100GE)'],
-            [IFACE_TYPE_100GE_CFP2, 'CFP2 (100GE)'],
-            [IFACE_TYPE_200GE_CFP2, 'CFP2 (200GE)'],
-            [IFACE_TYPE_100GE_CFP4, 'CFP4 (100GE)'],
-            [IFACE_TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'],
-            [IFACE_TYPE_100GE_QSFP28, 'QSFP28 (100GE)'],
-            [IFACE_TYPE_200GE_QSFP56, 'QSFP56 (200GE)'],
-            [IFACE_TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'],
-            [IFACE_TYPE_400GE_OSFP, 'OSFP (400GE)'],
-        ]
-    ],
-    [
-        'Wireless',
-        [
-            [IFACE_TYPE_80211A, 'IEEE 802.11a'],
-            [IFACE_TYPE_80211G, 'IEEE 802.11b/g'],
-            [IFACE_TYPE_80211N, 'IEEE 802.11n'],
-            [IFACE_TYPE_80211AC, 'IEEE 802.11ac'],
-            [IFACE_TYPE_80211AD, 'IEEE 802.11ad'],
-        ]
-    ],
-    [
-        'Cellular',
-        [
-            [IFACE_TYPE_GSM, 'GSM'],
-            [IFACE_TYPE_CDMA, 'CDMA'],
-            [IFACE_TYPE_LTE, 'LTE'],
-        ]
-    ],
-    [
-        'SONET',
-        [
-            [IFACE_TYPE_SONET_OC3, 'OC-3/STM-1'],
-            [IFACE_TYPE_SONET_OC12, 'OC-12/STM-4'],
-            [IFACE_TYPE_SONET_OC48, 'OC-48/STM-16'],
-            [IFACE_TYPE_SONET_OC192, 'OC-192/STM-64'],
-            [IFACE_TYPE_SONET_OC768, 'OC-768/STM-256'],
-            [IFACE_TYPE_SONET_OC1920, 'OC-1920/STM-640'],
-            [IFACE_TYPE_SONET_OC3840, 'OC-3840/STM-1234'],
-        ]
-    ],
-    [
-        'FibreChannel',
-        [
-            [IFACE_TYPE_1GFC_SFP, 'SFP (1GFC)'],
-            [IFACE_TYPE_2GFC_SFP, 'SFP (2GFC)'],
-            [IFACE_TYPE_4GFC_SFP, 'SFP (4GFC)'],
-            [IFACE_TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
-            [IFACE_TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
-            [IFACE_TYPE_32GFC_SFP28, 'SFP28 (32GFC)'],
-            [IFACE_TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'],
-        ]
-    ],
-    [
-        'InfiniBand',
-        [
-            [IFACE_TYPE_INFINIBAND_SDR, 'SDR (2 Gbps)'],
-            [IFACE_TYPE_INFINIBAND_DDR, 'DDR (4 Gbps)'],
-            [IFACE_TYPE_INFINIBAND_QDR, 'QDR (8 Gbps)'],
-            [IFACE_TYPE_INFINIBAND_FDR10, 'FDR10 (10 Gbps)'],
-            [IFACE_TYPE_INFINIBAND_FDR, 'FDR (13.5 Gbps)'],
-            [IFACE_TYPE_INFINIBAND_EDR, 'EDR (25 Gbps)'],
-            [IFACE_TYPE_INFINIBAND_HDR, 'HDR (50 Gbps)'],
-            [IFACE_TYPE_INFINIBAND_NDR, 'NDR (100 Gbps)'],
-            [IFACE_TYPE_INFINIBAND_XDR, 'XDR (250 Gbps)'],
-        ]
-    ],
-    [
-        'Serial',
-        [
-            [IFACE_TYPE_T1, 'T1 (1.544 Mbps)'],
-            [IFACE_TYPE_E1, 'E1 (2.048 Mbps)'],
-            [IFACE_TYPE_T3, 'T3 (45 Mbps)'],
-            [IFACE_TYPE_E3, 'E3 (34 Mbps)'],
-        ]
-    ],
-    [
-        'Stacking',
-        [
-            [IFACE_TYPE_STACKWISE, 'Cisco StackWise'],
-            [IFACE_TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'],
-            [IFACE_TYPE_FLEXSTACK, 'Cisco FlexStack'],
-            [IFACE_TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
-            [IFACE_TYPE_JUNIPER_VCP, 'Juniper VCP'],
-            [IFACE_TYPE_SUMMITSTACK, 'Extreme SummitStack'],
-            [IFACE_TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'],
-            [IFACE_TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'],
-            [IFACE_TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'],
-        ]
-    ],
-    [
-        'Other',
-        [
-            [IFACE_TYPE_OTHER, 'Other'],
-        ]
-    ],
-]
-
 VIRTUAL_IFACE_TYPES = [
-    IFACE_TYPE_VIRTUAL,
-    IFACE_TYPE_LAG,
+    InterfaceTypeChoices.TYPE_VIRTUAL,
+    InterfaceTypeChoices.TYPE_LAG,
 ]
 
 WIRELESS_IFACE_TYPES = [
-    IFACE_TYPE_80211A,
-    IFACE_TYPE_80211G,
-    IFACE_TYPE_80211N,
-    IFACE_TYPE_80211AC,
-    IFACE_TYPE_80211AD,
+    InterfaceTypeChoices.TYPE_80211A,
+    InterfaceTypeChoices.TYPE_80211G,
+    InterfaceTypeChoices.TYPE_80211N,
+    InterfaceTypeChoices.TYPE_80211AC,
+    InterfaceTypeChoices.TYPE_80211AD,
 ]
 
 NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
 
-IFACE_MODE_ACCESS = 100
-IFACE_MODE_TAGGED = 200
-IFACE_MODE_TAGGED_ALL = 300
-IFACE_MODE_CHOICES = [
-    [IFACE_MODE_ACCESS, 'Access'],
-    [IFACE_MODE_TAGGED, 'Tagged'],
-    [IFACE_MODE_TAGGED_ALL, 'Tagged All'],
-]
-
-# Pass-through port types
-PORT_TYPE_8P8C = 1000
-PORT_TYPE_110_PUNCH = 1100
-PORT_TYPE_BNC = 1200
-PORT_TYPE_ST = 2000
-PORT_TYPE_SC = 2100
-PORT_TYPE_SC_APC = 2110
-PORT_TYPE_FC = 2200
-PORT_TYPE_LC = 2300
-PORT_TYPE_LC_APC = 2310
-PORT_TYPE_MTRJ = 2400
-PORT_TYPE_MPO = 2500
-PORT_TYPE_LSH = 2600
-PORT_TYPE_LSH_APC = 2610
-PORT_TYPE_CHOICES = [
-    [
-        'Copper',
-        [
-            [PORT_TYPE_8P8C, '8P8C'],
-            [PORT_TYPE_110_PUNCH, '110 Punch'],
-            [PORT_TYPE_BNC, 'BNC'],
-        ],
-    ],
-    [
-        'Fiber Optic',
-        [
-            [PORT_TYPE_FC, 'FC'],
-            [PORT_TYPE_LC, 'LC'],
-            [PORT_TYPE_LC_APC, 'LC/APC'],
-            [PORT_TYPE_LSH, 'LSH'],
-            [PORT_TYPE_LSH_APC, 'LSH/APC'],
-            [PORT_TYPE_MPO, 'MPO'],
-            [PORT_TYPE_MTRJ, 'MTRJ'],
-            [PORT_TYPE_SC, 'SC'],
-            [PORT_TYPE_SC_APC, 'SC/APC'],
-            [PORT_TYPE_ST, 'ST'],
-        ]
-    ]
-]
-
-# Device statuses
-DEVICE_STATUS_OFFLINE = 0
-DEVICE_STATUS_ACTIVE = 1
-DEVICE_STATUS_PLANNED = 2
-DEVICE_STATUS_STAGED = 3
-DEVICE_STATUS_FAILED = 4
-DEVICE_STATUS_INVENTORY = 5
-DEVICE_STATUS_DECOMMISSIONING = 6
-DEVICE_STATUS_CHOICES = [
-    [DEVICE_STATUS_ACTIVE, 'Active'],
-    [DEVICE_STATUS_OFFLINE, 'Offline'],
-    [DEVICE_STATUS_PLANNED, 'Planned'],
-    [DEVICE_STATUS_STAGED, 'Staged'],
-    [DEVICE_STATUS_FAILED, 'Failed'],
-    [DEVICE_STATUS_INVENTORY, 'Inventory'],
-    [DEVICE_STATUS_DECOMMISSIONING, 'Decommissioning'],
-]
-
-# Site statuses
-SITE_STATUS_ACTIVE = 1
-SITE_STATUS_PLANNED = 2
-SITE_STATUS_RETIRED = 4
-SITE_STATUS_CHOICES = [
-    [SITE_STATUS_ACTIVE, 'Active'],
-    [SITE_STATUS_PLANNED, 'Planned'],
-    [SITE_STATUS_RETIRED, 'Retired'],
-]
-
-# Bootstrap CSS classes for device/rack statuses
-STATUS_CLASSES = {
-    0: 'warning',
-    1: 'success',
-    2: 'info',
-    3: 'primary',
-    4: 'danger',
-    5: 'default',
-    6: 'warning',
-}
-
 # Console/power/interface connection statuses
 CONNECTION_STATUS_PLANNED = False
 CONNECTION_STATUS_CONNECTED = True
@@ -390,56 +34,6 @@ CABLE_TERMINATION_TYPES = [
     'circuittermination',
 ]
 
-# Cable types
-CABLE_TYPE_CAT3 = 1300
-CABLE_TYPE_CAT5 = 1500
-CABLE_TYPE_CAT5E = 1510
-CABLE_TYPE_CAT6 = 1600
-CABLE_TYPE_CAT6A = 1610
-CABLE_TYPE_CAT7 = 1700
-CABLE_TYPE_DAC_ACTIVE = 1800
-CABLE_TYPE_DAC_PASSIVE = 1810
-CABLE_TYPE_COAXIAL = 1900
-CABLE_TYPE_MMF = 3000
-CABLE_TYPE_MMF_OM1 = 3010
-CABLE_TYPE_MMF_OM2 = 3020
-CABLE_TYPE_MMF_OM3 = 3030
-CABLE_TYPE_MMF_OM4 = 3040
-CABLE_TYPE_SMF = 3500
-CABLE_TYPE_SMF_OS1 = 3510
-CABLE_TYPE_SMF_OS2 = 3520
-CABLE_TYPE_AOC = 3800
-CABLE_TYPE_POWER = 5000
-CABLE_TYPE_CHOICES = (
-    (
-        'Copper', (
-            (CABLE_TYPE_CAT3, 'CAT3'),
-            (CABLE_TYPE_CAT5, 'CAT5'),
-            (CABLE_TYPE_CAT5E, 'CAT5e'),
-            (CABLE_TYPE_CAT6, 'CAT6'),
-            (CABLE_TYPE_CAT6A, 'CAT6a'),
-            (CABLE_TYPE_CAT7, 'CAT7'),
-            (CABLE_TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'),
-            (CABLE_TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'),
-            (CABLE_TYPE_COAXIAL, 'Coaxial'),
-        ),
-    ),
-    (
-        'Fiber', (
-            (CABLE_TYPE_MMF, 'Multimode Fiber'),
-            (CABLE_TYPE_MMF_OM1, 'Multimode Fiber (OM1)'),
-            (CABLE_TYPE_MMF_OM2, 'Multimode Fiber (OM2)'),
-            (CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
-            (CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
-            (CABLE_TYPE_SMF, 'Singlemode Fiber'),
-            (CABLE_TYPE_SMF_OS1, 'Singlemode Fiber (OS1)'),
-            (CABLE_TYPE_SMF_OS2, 'Singlemode Fiber (OS2)'),
-            (CABLE_TYPE_AOC, 'Active Optical Cabling (AOC)'),
-        ),
-    ),
-    (CABLE_TYPE_POWER, 'Power'),
-)
-
 CABLE_TERMINATION_TYPE_CHOICES = {
     # (API endpoint, human-friendly name)
     'consoleport': ('console-ports', 'Console port'),
@@ -461,57 +55,3 @@ COMPATIBLE_TERMINATION_TYPES = {
     'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
     'circuittermination': ['interface', 'frontport', 'rearport'],
 }
-
-LENGTH_UNIT_METER = 1200
-LENGTH_UNIT_CENTIMETER = 1100
-LENGTH_UNIT_MILLIMETER = 1000
-LENGTH_UNIT_FOOT = 2100
-LENGTH_UNIT_INCH = 2000
-CABLE_LENGTH_UNIT_CHOICES = (
-    (LENGTH_UNIT_METER, 'Meters'),
-    (LENGTH_UNIT_CENTIMETER, 'Centimeters'),
-    (LENGTH_UNIT_FOOT, 'Feet'),
-    (LENGTH_UNIT_INCH, 'Inches'),
-)
-RACK_DIMENSION_UNIT_CHOICES = (
-    (LENGTH_UNIT_MILLIMETER, 'Millimeters'),
-    (LENGTH_UNIT_INCH, 'Inches'),
-)
-
-# Power feeds
-POWERFEED_TYPE_PRIMARY = 1
-POWERFEED_TYPE_REDUNDANT = 2
-POWERFEED_TYPE_CHOICES = (
-    (POWERFEED_TYPE_PRIMARY, 'Primary'),
-    (POWERFEED_TYPE_REDUNDANT, 'Redundant'),
-)
-POWERFEED_SUPPLY_AC = 1
-POWERFEED_SUPPLY_DC = 2
-POWERFEED_SUPPLY_CHOICES = (
-    (POWERFEED_SUPPLY_AC, 'AC'),
-    (POWERFEED_SUPPLY_DC, 'DC'),
-)
-POWERFEED_PHASE_SINGLE = 1
-POWERFEED_PHASE_3PHASE = 3
-POWERFEED_PHASE_CHOICES = (
-    (POWERFEED_PHASE_SINGLE, 'Single phase'),
-    (POWERFEED_PHASE_3PHASE, 'Three-phase'),
-)
-POWERFEED_STATUS_OFFLINE = 0
-POWERFEED_STATUS_ACTIVE = 1
-POWERFEED_STATUS_PLANNED = 2
-POWERFEED_STATUS_FAILED = 4
-POWERFEED_STATUS_CHOICES = (
-    (POWERFEED_STATUS_ACTIVE, 'Active'),
-    (POWERFEED_STATUS_OFFLINE, 'Offline'),
-    (POWERFEED_STATUS_PLANNED, 'Planned'),
-    (POWERFEED_STATUS_FAILED, 'Failed'),
-)
-POWERFEED_LEG_A = 1
-POWERFEED_LEG_B = 2
-POWERFEED_LEG_C = 3
-POWERFEED_LEG_CHOICES = (
-    (POWERFEED_LEG_A, 'A'),
-    (POWERFEED_LEG_B, 'B'),
-    (POWERFEED_LEG_C, 'C'),
-)

+ 9 - 9
netbox/dcim/filters.py

@@ -49,7 +49,7 @@ class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
         label='Search',
     )
     status = django_filters.MultipleChoiceFilter(
-        choices=SITE_STATUS_CHOICES,
+        choices=SiteStatusChoices,
         null_value=None
     )
     region_id = TreeNodeMultipleChoiceFilter(
@@ -147,7 +147,7 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
         label='Group',
     )
     status = django_filters.MultipleChoiceFilter(
-        choices=RACK_STATUS_CHOICES,
+        choices=RackStatusChoices,
         null_value=None
     )
     role_id = django_filters.ModelMultipleChoiceFilter(
@@ -511,7 +511,7 @@ class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilter
         label='Device model (slug)',
     )
     status = django_filters.MultipleChoiceFilter(
-        choices=DEVICE_STATUS_CHOICES,
+        choices=DeviceStatusChoices,
         null_value=None
     )
     is_full_depth = django_filters.BooleanFilter(
@@ -663,7 +663,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
 
 class ConsolePortFilter(DeviceComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
-        choices=ConsolePortTypes.CHOICES,
+        choices=ConsolePortTypeChoices,
         null_value=None
     )
     cabled = django_filters.BooleanFilter(
@@ -679,7 +679,7 @@ class ConsolePortFilter(DeviceComponentFilterSet):
 
 class ConsoleServerPortFilter(DeviceComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
-        choices=ConsolePortTypes.CHOICES,
+        choices=ConsolePortTypeChoices,
         null_value=None
     )
     cabled = django_filters.BooleanFilter(
@@ -695,7 +695,7 @@ class ConsoleServerPortFilter(DeviceComponentFilterSet):
 
 class PowerPortFilter(DeviceComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
-        choices=PowerPortTypes.CHOICES,
+        choices=PowerPortTypeChoices,
         null_value=None
     )
     cabled = django_filters.BooleanFilter(
@@ -711,7 +711,7 @@ class PowerPortFilter(DeviceComponentFilterSet):
 
 class PowerOutletFilter(DeviceComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
-        choices=PowerOutletTypes.CHOICES,
+        choices=PowerOutletTypeChoices,
         null_value=None
     )
     cabled = django_filters.BooleanFilter(
@@ -789,7 +789,7 @@ class InterfaceFilter(django_filters.FilterSet):
         label='Assigned VID'
     )
     type = django_filters.MultipleChoiceFilter(
-        choices=IFACE_TYPE_CHOICES,
+        choices=InterfaceTypeChoices,
         null_value=None
     )
 
@@ -980,7 +980,7 @@ class CableFilter(django_filters.FilterSet):
         label='Search',
     )
     type = django_filters.MultipleChoiceFilter(
-        choices=CABLE_TYPE_CHOICES
+        choices=CableTypeChoices
     )
     status = django_filters.MultipleChoiceFilter(
         choices=CONNECTION_STATUS_CHOICES

+ 11 - 11
netbox/dcim/fixtures/dcim.json

@@ -1910,7 +1910,7 @@
         "site": 1,
         "rack": 1,
         "position": 1,
-        "face": 0,
+        "face": "front",
         "status": true,
         "primary_ip4": 1,
         "primary_ip6": null,
@@ -1931,7 +1931,7 @@
         "site": 1,
         "rack": 1,
         "position": 17,
-        "face": 0,
+        "face": "rear",
         "status": true,
         "primary_ip4": 5,
         "primary_ip6": null,
@@ -1952,7 +1952,7 @@
         "site": 1,
         "rack": 1,
         "position": 33,
-        "face": 0,
+        "face": "rear",
         "status": true,
         "primary_ip4": null,
         "primary_ip6": null,
@@ -1973,7 +1973,7 @@
         "site": 1,
         "rack": 1,
         "position": 34,
-        "face": 0,
+        "face": "rear",
         "status": true,
         "primary_ip4": null,
         "primary_ip6": null,
@@ -1994,7 +1994,7 @@
         "site": 1,
         "rack": 2,
         "position": 34,
-        "face": 0,
+        "face": "rear",
         "status": true,
         "primary_ip4": null,
         "primary_ip6": null,
@@ -2015,7 +2015,7 @@
         "site": 1,
         "rack": 2,
         "position": 33,
-        "face": 0,
+        "face": "rear",
         "status": true,
         "primary_ip4": null,
         "primary_ip6": null,
@@ -2036,7 +2036,7 @@
         "site": 1,
         "rack": 2,
         "position": 1,
-        "face": 0,
+        "face": "rear",
         "status": true,
         "primary_ip4": 3,
         "primary_ip6": null,
@@ -2057,7 +2057,7 @@
         "site": 1,
         "rack": 2,
         "position": 17,
-        "face": 0,
+        "face": "rear",
         "status": true,
         "primary_ip4": 19,
         "primary_ip6": null,
@@ -2078,7 +2078,7 @@
         "site": 1,
         "rack": 1,
         "position": 42,
-        "face": 0,
+        "face": "rear",
         "status": true,
         "primary_ip4": null,
         "primary_ip6": null,
@@ -2099,7 +2099,7 @@
         "site": 1,
         "rack": 1,
         "position": null,
-        "face": null,
+        "face": "",
         "status": true,
         "primary_ip4": null,
         "primary_ip6": null,
@@ -2120,7 +2120,7 @@
         "site": 1,
         "rack": 2,
         "position": null,
-        "face": null,
+        "face": "",
         "status": true,
         "primary_ip4": null,
         "primary_ip6": null,

+ 82 - 99
netbox/dcim/forms.py

@@ -93,13 +93,13 @@ class InterfaceCommonForm:
         tagged_vlans = self.cleaned_data['tagged_vlans']
 
         # Untagged interfaces cannot be assigned tagged VLANs
-        if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
+        if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
             raise forms.ValidationError({
                 'mode': "An access interface cannot have tagged VLANs assigned."
             })
 
         # Remove all tagged VLAN assignments from "tagged all" interfaces
-        elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
+        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
             self.cleaned_data['tagged_vlans'] = []
 
 
@@ -250,7 +250,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
 class SiteCSVForm(forms.ModelForm):
     status = CSVChoiceField(
-        choices=SITE_STATUS_CHOICES,
+        choices=SiteStatusChoices,
         required=False,
         help_text='Operational status'
     )
@@ -289,7 +289,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         widget=forms.MultipleHiddenInput
     )
     status = forms.ChoiceField(
-        choices=add_blank_choice(SITE_STATUS_CHOICES),
+        choices=add_blank_choice(SiteStatusChoices),
         required=False,
         initial='',
         widget=StaticSelect2()
@@ -338,7 +338,7 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
         label='Search'
     )
     status = forms.MultipleChoiceField(
-        choices=SITE_STATUS_CHOICES,
+        choices=SiteStatusChoices,
         required=False,
         widget=StaticSelect2Multiple()
     )
@@ -500,7 +500,7 @@ class RackCSVForm(forms.ModelForm):
         }
     )
     status = CSVChoiceField(
-        choices=RACK_STATUS_CHOICES,
+        choices=RackStatusChoices,
         required=False,
         help_text='Operational status'
     )
@@ -514,19 +514,16 @@ class RackCSVForm(forms.ModelForm):
         }
     )
     type = CSVChoiceField(
-        choices=RACK_TYPE_CHOICES,
+        choices=RackTypeChoices,
         required=False,
         help_text='Rack type'
     )
     width = forms.ChoiceField(
-        choices=(
-            (RACK_WIDTH_19IN, '19'),
-            (RACK_WIDTH_23IN, '23'),
-        ),
+        choices=RackWidthChoices,
         help_text='Rail-to-rail width (in inches)'
     )
     outer_unit = CSVChoiceField(
-        choices=RACK_DIMENSION_UNIT_CHOICES,
+        choices=RackDimensionUnitChoices,
         required=False,
         help_text='Unit for outer dimensions'
     )
@@ -598,7 +595,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         )
     )
     status = forms.ChoiceField(
-        choices=add_blank_choice(RACK_STATUS_CHOICES),
+        choices=add_blank_choice(RackStatusChoices),
         required=False,
         initial='',
         widget=StaticSelect2()
@@ -620,12 +617,12 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         required=False
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(RACK_TYPE_CHOICES),
+        choices=add_blank_choice(RackTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
     width = forms.ChoiceField(
-        choices=add_blank_choice(RACK_WIDTH_CHOICES),
+        choices=add_blank_choice(RackWidthChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -647,7 +644,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         min_value=1
     )
     outer_unit = forms.ChoiceField(
-        choices=add_blank_choice(RACK_DIMENSION_UNIT_CHOICES),
+        choices=add_blank_choice(RackDimensionUnitChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -692,7 +689,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
         )
     )
     status = forms.MultipleChoiceField(
-        choices=RACK_STATUS_CHOICES,
+        choices=RackStatusChoices,
         required=False,
         widget=StaticSelect2Multiple()
     )
@@ -909,12 +906,10 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
             value_field="slug",
         )
     )
-    subdevice_role = forms.NullBooleanField(
+    subdevice_role = forms.MultipleChoiceField(
+        choices=add_blank_choice(SubdeviceRoleChoices),
         required=False,
-        label='Subdevice role',
-        widget=StaticSelect2(
-            choices=add_blank_choice(SUBDEVICE_ROLE_CHOICES)
-        )
+        widget=StaticSelect2Multiple()
     )
     console_ports = forms.NullBooleanField(
         required=False,
@@ -981,7 +976,7 @@ class ConsolePortTemplateCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=ConsolePortTypes.CHOICES,
+        choices=ConsolePortTypeChoices,
         widget=StaticSelect2()
     )
 
@@ -1003,7 +998,7 @@ class ConsoleServerPortTemplateCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypes.CHOICES),
+        choices=add_blank_choice(ConsolePortTypeChoices),
         widget=StaticSelect2()
     )
 
@@ -1025,7 +1020,7 @@ class PowerPortTemplateCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(PowerPortTypes.CHOICES),
+        choices=add_blank_choice(PowerPortTypeChoices),
         required=False
     )
     maximum_draw = forms.IntegerField(
@@ -1067,7 +1062,7 @@ class PowerOutletTemplateCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletTypes.CHOICES),
+        choices=add_blank_choice(PowerOutletTypeChoices),
         required=False
     )
     power_port = forms.ModelChoiceField(
@@ -1075,7 +1070,7 @@ class PowerOutletTemplateCreateForm(ComponentForm):
         required=False
     )
     feed_leg = forms.ChoiceField(
-        choices=add_blank_choice(POWERFEED_LEG_CHOICES),
+        choices=add_blank_choice(PowerOutletFeedLegChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -1108,7 +1103,7 @@ class InterfaceTemplateCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=IFACE_TYPE_CHOICES,
+        choices=InterfaceTypeChoices,
         widget=StaticSelect2()
     )
     mgmt_only = forms.BooleanField(
@@ -1123,7 +1118,7 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
         widget=forms.MultipleHiddenInput()
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(IFACE_TYPE_CHOICES),
+        choices=add_blank_choice(InterfaceTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -1165,7 +1160,7 @@ class FrontPortTemplateCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=PORT_TYPE_CHOICES,
+        choices=PortTypeChoices,
         widget=StaticSelect2()
     )
     rear_port_set = forms.MultipleChoiceField(
@@ -1235,7 +1230,7 @@ class RearPortTemplateCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=PORT_TYPE_CHOICES,
+        choices=PortTypeChoices,
         widget=StaticSelect2(),
     )
     positions = forms.IntegerField(
@@ -1334,7 +1329,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
 
 class InterfaceTemplateImportForm(ComponentTemplateImportForm):
     type = forms.ChoiceField(
-        choices=InterfaceTypes.TYPE_CHOICES
+        choices=InterfaceTypeChoices.CHOICES
     )
 
     class Meta:
@@ -1343,15 +1338,10 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
             'device_type', 'name', 'type', 'mgmt_only',
         ]
 
-    def clean_type(self):
-        # Convert slug value to field integer value
-        slug = self.cleaned_data['type']
-        return InterfaceTypes.slug_to_integer(slug)
-
 
 class FrontPortTemplateImportForm(ComponentTemplateImportForm):
     type = forms.ChoiceField(
-        choices=PortTypes.TYPE_CHOICES
+        choices=PortTypeChoices.CHOICES
     )
     rear_port = forms.ModelChoiceField(
         queryset=RearPortTemplate.objects.all(),
@@ -1365,15 +1355,10 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
             'device_type', 'name', 'type', 'rear_port', 'rear_port_position',
         ]
 
-    def clean_type(self):
-        # Convert slug value to field integer value
-        slug = self.cleaned_data['type']
-        return PortTypes.slug_to_integer(slug)
-
 
 class RearPortTemplateImportForm(ComponentTemplateImportForm):
     type = forms.ChoiceField(
-        choices=PortTypes.TYPE_CHOICES
+        choices=PortTypeChoices.CHOICES
     )
 
     class Meta:
@@ -1382,11 +1367,6 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm):
             'device_type', 'name', 'type', 'positions',
         ]
 
-    def clean_type(self):
-        # Convert slug value to field integer value
-        slug = self.cleaned_data['type']
-        return PortTypes.slug_to_integer(slug)
-
 
 class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
 
@@ -1702,7 +1682,7 @@ class BaseDeviceCSVForm(forms.ModelForm):
         }
     )
     status = CSVChoiceField(
-        choices=DEVICE_STATUS_CHOICES,
+        choices=DeviceStatusChoices,
         help_text='Operational status'
     )
 
@@ -1746,7 +1726,7 @@ class DeviceCSVForm(BaseDeviceCSVForm):
         help_text='Name of parent rack'
     )
     face = CSVChoiceField(
-        choices=RACK_FACE_CHOICES,
+        choices=DeviceFaceChoices,
         required=False,
         help_text='Mounted rack face'
     )
@@ -1870,7 +1850,7 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         )
     )
     status = forms.ChoiceField(
-        choices=add_blank_choice(DEVICE_STATUS_CHOICES),
+        choices=add_blank_choice(DeviceStatusChoices),
         required=False,
         initial='',
         widget=StaticSelect2()
@@ -1981,7 +1961,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
         )
     )
     status = forms.MultipleChoiceField(
-        choices=DEVICE_STATUS_CHOICES,
+        choices=DeviceStatusChoices,
         required=False,
         widget=StaticSelect2Multiple()
     )
@@ -2063,7 +2043,7 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
 
 class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
     type = forms.ChoiceField(
-        choices=IFACE_TYPE_CHOICES,
+        choices=InterfaceTypeChoices,
         widget=StaticSelect2()
     )
     enabled = forms.BooleanField(
@@ -2115,7 +2095,7 @@ class ConsolePortCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypes.CHOICES),
+        choices=add_blank_choice(ConsolePortTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -2172,7 +2152,7 @@ class ConsoleServerPortCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypes.CHOICES),
+        choices=add_blank_choice(ConsolePortTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -2191,7 +2171,7 @@ class ConsoleServerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditF
         widget=forms.MultipleHiddenInput()
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypes.CHOICES),
+        choices=add_blank_choice(ConsolePortTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -2264,7 +2244,7 @@ class PowerPortCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(PowerPortTypes.CHOICES),
+        choices=add_blank_choice(PowerPortTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -2344,7 +2324,7 @@ class PowerOutletCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletTypes.CHOICES),
+        choices=add_blank_choice(PowerOutletTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -2353,7 +2333,7 @@ class PowerOutletCreateForm(ComponentForm):
         required=False
     )
     feed_leg = forms.ChoiceField(
-        choices=add_blank_choice(POWERFEED_LEG_CHOICES),
+        choices=add_blank_choice(PowerOutletFeedLegChoices),
         required=False
     )
     description = forms.CharField(
@@ -2391,7 +2371,7 @@ class PowerOutletCSVForm(forms.ModelForm):
         }
     )
     feed_leg = CSVChoiceField(
-        choices=POWERFEED_LEG_CHOICES,
+        choices=PowerOutletFeedLegChoices,
         required=False,
     )
 
@@ -2428,11 +2408,11 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         widget=forms.MultipleHiddenInput()
     )
     type = forms.ChoiceField(
-        choices=PowerOutletTypes.CHOICES,
+        choices=PowerOutletTypeChoices,
         required=False
     )
     feed_leg = forms.ChoiceField(
-        choices=add_blank_choice(POWERFEED_LEG_CHOICES),
+        choices=add_blank_choice(PowerOutletFeedLegChoices),
         required=False,
     )
     power_port = forms.ModelChoiceField(
@@ -2529,12 +2509,14 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
         if self.is_bound:
             device = Device.objects.get(pk=self.data['device'])
             self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[device, device.get_vc_master()], type=IFACE_TYPE_LAG
+                device__in=[device, device.get_vc_master()],
+                type=InterfaceTypeChoices.TYPE_LAG
             )
         else:
             device = self.instance.device
             self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[self.instance.device, self.instance.device.get_vc_master()], type=IFACE_TYPE_LAG
+                device__in=[self.instance.device, self.instance.device.get_vc_master()],
+                type=InterfaceTypeChoices.TYPE_LAG
             )
 
         # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
@@ -2573,7 +2555,7 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=IFACE_TYPE_CHOICES,
+        choices=InterfaceTypeChoices,
         widget=StaticSelect2(),
     )
     enabled = forms.BooleanField(
@@ -2605,7 +2587,7 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
         required=False
     )
     mode = forms.ChoiceField(
-        choices=add_blank_choice(IFACE_MODE_CHOICES),
+        choices=add_blank_choice(InterfaceModeChoices),
         required=False,
         widget=StaticSelect2(),
     )
@@ -2642,7 +2624,8 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
         # Limit LAG choices to interfaces belonging to this device (or its VC master)
         if self.parent is not None:
             self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[self.parent, self.parent.get_vc_master()], type=IFACE_TYPE_LAG
+                device__in=[self.parent, self.parent.get_vc_master()],
+                type=InterfaceTypeChoices.TYPE_LAG
             )
         else:
             self.fields['lag'].queryset = Interface.objects.none()
@@ -2707,10 +2690,10 @@ class InterfaceCSVForm(forms.ModelForm):
         }
     )
     type = CSVChoiceField(
-        choices=IFACE_TYPE_CHOICES,
+        choices=InterfaceTypeChoices,
     )
     mode = CSVChoiceField(
-        choices=IFACE_MODE_CHOICES,
+        choices=InterfaceModeChoices,
         required=False,
     )
 
@@ -2732,7 +2715,7 @@ class InterfaceCSVForm(forms.ModelForm):
 
         if device:
             self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[device, device.get_vc_master()], type=IFACE_TYPE_LAG
+                device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG
             )
         else:
             self.fields['lag'].queryset = Interface.objects.none()
@@ -2751,7 +2734,7 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo
         widget=forms.MultipleHiddenInput()
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(IFACE_TYPE_CHOICES),
+        choices=add_blank_choice(InterfaceTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -2785,7 +2768,7 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo
         required=False
     )
     mode = forms.ChoiceField(
-        choices=add_blank_choice(IFACE_MODE_CHOICES),
+        choices=add_blank_choice(InterfaceModeChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -2821,7 +2804,7 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo
         if device is not None:
             self.fields['lag'].queryset = Interface.objects.filter(
                 device__in=[device, device.get_vc_master()],
-                type=IFACE_TYPE_LAG
+                type=InterfaceTypeChoices.TYPE_LAG
             )
         else:
             self.fields['lag'].choices = []
@@ -2911,7 +2894,7 @@ class FrontPortCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=PORT_TYPE_CHOICES,
+        choices=PortTypeChoices,
         widget=StaticSelect2(),
     )
     rear_port_set = forms.MultipleChoiceField(
@@ -2983,7 +2966,7 @@ class FrontPortCSVForm(forms.ModelForm):
         }
     )
     type = CSVChoiceField(
-        choices=PORT_TYPE_CHOICES,
+        choices=PortTypeChoices,
     )
 
     class Meta:
@@ -3019,7 +3002,7 @@ class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         widget=forms.MultipleHiddenInput()
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(PORT_TYPE_CHOICES),
+        choices=add_blank_choice(PortTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -3077,7 +3060,7 @@ class RearPortCreateForm(ComponentForm):
         label='Name'
     )
     type = forms.ChoiceField(
-        choices=PORT_TYPE_CHOICES,
+        choices=PortTypeChoices,
         widget=StaticSelect2(),
     )
     positions = forms.IntegerField(
@@ -3101,7 +3084,7 @@ class RearPortCSVForm(forms.ModelForm):
         }
     )
     type = CSVChoiceField(
-        choices=PORT_TYPE_CHOICES,
+        choices=PortTypeChoices,
     )
 
     class Meta:
@@ -3115,7 +3098,7 @@ class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         widget=forms.MultipleHiddenInput()
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(PORT_TYPE_CHOICES),
+        choices=add_blank_choice(PortTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -3449,12 +3432,12 @@ class CableCSVForm(forms.ModelForm):
         help_text='Connection status'
     )
     type = CSVChoiceField(
-        choices=CABLE_TYPE_CHOICES,
+        choices=CableTypeChoices,
         required=False,
         help_text='Cable type'
     )
     length_unit = CSVChoiceField(
-        choices=CABLE_LENGTH_UNIT_CHOICES,
+        choices=CableLengthUnitChoices,
         required=False,
         help_text='Length unit'
     )
@@ -3534,7 +3517,7 @@ class CableBulkEditForm(BootstrapMixin, BulkEditForm):
         widget=forms.MultipleHiddenInput
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(CABLE_TYPE_CHOICES),
+        choices=add_blank_choice(CableTypeChoices),
         required=False,
         initial='',
         widget=StaticSelect2()
@@ -3559,7 +3542,7 @@ class CableBulkEditForm(BootstrapMixin, BulkEditForm):
         required=False
     )
     length_unit = forms.ChoiceField(
-        choices=add_blank_choice(CABLE_LENGTH_UNIT_CHOICES),
+        choices=add_blank_choice(CableLengthUnitChoices),
         required=False,
         initial='',
         widget=StaticSelect2()
@@ -3608,7 +3591,7 @@ class CableFilterForm(BootstrapMixin, forms.Form):
         )
     )
     type = forms.MultipleChoiceField(
-        choices=add_blank_choice(CABLE_TYPE_CHOICES),
+        choices=add_blank_choice(CableTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -3677,7 +3660,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
             rack=device_bay.device.rack,
             parent_bay__isnull=True,
             device_type__u_height=0,
-            device_type__subdevice_role=SUBDEVICE_ROLE_CHILD
+            device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
         ).exclude(pk=device_bay.device.pk)
 
 
@@ -3725,7 +3708,7 @@ class DeviceBayCSVForm(forms.ModelForm):
                 rack=device.rack,
                 parent_bay__isnull=True,
                 device_type__u_height=0,
-                device_type__subdevice_role=SUBDEVICE_ROLE_CHILD
+                device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
             ).exclude(pk=device.pk)
         else:
             self.fields['installed_device'].queryset = Interface.objects.none()
@@ -4214,22 +4197,22 @@ class PowerFeedCSVForm(forms.ModelForm):
         help_text="Rack name (optional)"
     )
     status = CSVChoiceField(
-        choices=POWERFEED_STATUS_CHOICES,
+        choices=PowerFeedStatusChoices,
         required=False,
         help_text='Operational status'
     )
     type = CSVChoiceField(
-        choices=POWERFEED_TYPE_CHOICES,
+        choices=PowerFeedTypeChoices,
         required=False,
         help_text='Primary or redundant'
     )
     supply = CSVChoiceField(
-        choices=POWERFEED_SUPPLY_CHOICES,
+        choices=PowerFeedSupplyChoices,
         required=False,
         help_text='AC/DC'
     )
     phase = CSVChoiceField(
-        choices=POWERFEED_PHASE_CHOICES,
+        choices=PowerFeedPhaseChoices,
         required=False,
         help_text='Single or three-phase'
     )
@@ -4289,25 +4272,25 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
         )
     )
     status = forms.ChoiceField(
-        choices=add_blank_choice(POWERFEED_STATUS_CHOICES),
+        choices=add_blank_choice(PowerFeedStatusChoices),
         required=False,
         initial='',
         widget=StaticSelect2()
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(POWERFEED_TYPE_CHOICES),
+        choices=add_blank_choice(PowerFeedTypeChoices),
         required=False,
         initial='',
         widget=StaticSelect2()
     )
     supply = forms.ChoiceField(
-        choices=add_blank_choice(POWERFEED_SUPPLY_CHOICES),
+        choices=add_blank_choice(PowerFeedSupplyChoices),
         required=False,
         initial='',
         widget=StaticSelect2()
     )
     phase = forms.ChoiceField(
-        choices=add_blank_choice(POWERFEED_PHASE_CHOICES),
+        choices=add_blank_choice(PowerFeedPhaseChoices),
         required=False,
         initial='',
         widget=StaticSelect2()
@@ -4368,22 +4351,22 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm):
         )
     )
     status = forms.MultipleChoiceField(
-        choices=POWERFEED_STATUS_CHOICES,
+        choices=PowerFeedStatusChoices,
         required=False,
         widget=StaticSelect2Multiple()
     )
     type = forms.ChoiceField(
-        choices=add_blank_choice(POWERFEED_TYPE_CHOICES),
+        choices=add_blank_choice(PowerFeedTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
     supply = forms.ChoiceField(
-        choices=add_blank_choice(POWERFEED_SUPPLY_CHOICES),
+        choices=add_blank_choice(PowerFeedSupplyChoices),
         required=False,
         widget=StaticSelect2()
     )
     phase = forms.ChoiceField(
-        choices=add_blank_choice(POWERFEED_PHASE_CHOICES),
+        choices=add_blank_choice(PowerFeedPhaseChoices),
         required=False,
         widget=StaticSelect2()
     )

+ 35 - 0
netbox/dcim/migrations/0078_3569_site_fields.py

@@ -0,0 +1,35 @@
+from django.db import migrations, models
+
+SITE_STATUS_CHOICES = (
+    (1, 'active'),
+    (2, 'planned'),
+    (4, 'retired'),
+)
+
+
+def site_status_to_slug(apps, schema_editor):
+    Site = apps.get_model('dcim', 'Site')
+    for id, slug in SITE_STATUS_CHOICES:
+        Site.objects.filter(status=str(id)).update(status=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('dcim', '0077_power_types'),
+    ]
+
+    operations = [
+
+        # Site.status
+        migrations.AlterField(
+            model_name='site',
+            name='status',
+            field=models.CharField(default='active', max_length=50),
+        ),
+        migrations.RunPython(
+            code=site_status_to_slug
+        ),
+
+    ]

+ 92 - 0
netbox/dcim/migrations/0079_3569_rack_fields.py

@@ -0,0 +1,92 @@
+from django.db import migrations, models
+
+RACK_TYPE_CHOICES = (
+    (100, '2-post-frame'),
+    (200, '4-post-frame'),
+    (300, '4-post-cabinet'),
+    (1000, 'wall-frame'),
+    (1100, 'wall-cabinet'),
+)
+
+RACK_STATUS_CHOICES = (
+    (0, 'reserved'),
+    (1, 'available'),
+    (2, 'planned'),
+    (3, 'active'),
+    (4, 'deprecated'),
+)
+
+RACK_DIMENSION_CHOICES = (
+    (1000, 'mm'),
+    (2000, 'in'),
+)
+
+
+def rack_type_to_slug(apps, schema_editor):
+    Rack = apps.get_model('dcim', 'Rack')
+    for id, slug in RACK_TYPE_CHOICES:
+        Rack.objects.filter(type=str(id)).update(type=slug)
+
+
+def rack_status_to_slug(apps, schema_editor):
+    Rack = apps.get_model('dcim', 'Rack')
+    for id, slug in RACK_STATUS_CHOICES:
+        Rack.objects.filter(status=str(id)).update(status=slug)
+
+
+def rack_outer_unit_to_slug(apps, schema_editor):
+    Rack = apps.get_model('dcim', 'Rack')
+    for id, slug in RACK_DIMENSION_CHOICES:
+        Rack.objects.filter(status=str(id)).update(status=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('dcim', '0078_3569_site_fields'),
+    ]
+
+    operations = [
+
+        # Rack.type
+        migrations.AlterField(
+            model_name='rack',
+            name='type',
+            field=models.CharField(blank=True, default='', max_length=50),
+        ),
+        migrations.RunPython(
+            code=rack_type_to_slug
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='type',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+
+        # Rack.status
+        migrations.AlterField(
+            model_name='rack',
+            name='status',
+            field=models.CharField(default='active', max_length=50),
+        ),
+        migrations.RunPython(
+            code=rack_status_to_slug
+        ),
+
+        # Rack.outer_unit
+        migrations.AlterField(
+            model_name='rack',
+            name='outer_unit',
+            field=models.CharField(blank=True, default='', max_length=50),
+        ),
+        migrations.RunPython(
+            code=rack_outer_unit_to_slug
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='outer_unit',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+
+    ]

+ 39 - 0
netbox/dcim/migrations/0080_3569_devicetype_fields.py

@@ -0,0 +1,39 @@
+from django.db import migrations, models
+
+SUBDEVICE_ROLE_CHOICES = (
+    ('true', 'parent'),
+    ('false', 'child'),
+)
+
+
+def devicetype_subdevicerole_to_slug(apps, schema_editor):
+    DeviceType = apps.get_model('dcim', 'DeviceType')
+    for boolean, slug in SUBDEVICE_ROLE_CHOICES:
+        DeviceType.objects.filter(subdevice_role=boolean).update(subdevice_role=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('dcim', '0079_3569_rack_fields'),
+    ]
+
+    operations = [
+
+        # DeviceType.subdevice_role
+        migrations.AlterField(
+            model_name='devicetype',
+            name='subdevice_role',
+            field=models.CharField(blank=True, default='', max_length=50),
+        ),
+        migrations.RunPython(
+            code=devicetype_subdevicerole_to_slug
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='subdevice_role',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+
+    ]

+ 65 - 0
netbox/dcim/migrations/0081_3569_device_fields.py

@@ -0,0 +1,65 @@
+from django.db import migrations, models
+
+DEVICE_FACE_CHOICES = (
+    (0, 'front'),
+    (1, 'rear'),
+)
+
+DEVICE_STATUS_CHOICES = (
+    (0, 'offline'),
+    (1, 'active'),
+    (2, 'planned'),
+    (3, 'staged'),
+    (4, 'failed'),
+    (5, 'inventory'),
+    (6, 'decommissioning'),
+)
+
+
+def device_face_to_slug(apps, schema_editor):
+    Device = apps.get_model('dcim', 'Device')
+    for id, slug in DEVICE_FACE_CHOICES:
+        Device.objects.filter(face=str(id)).update(face=slug)
+
+
+def device_status_to_slug(apps, schema_editor):
+    Device = apps.get_model('dcim', 'Device')
+    for id, slug in DEVICE_STATUS_CHOICES:
+        Device.objects.filter(status=str(id)).update(status=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('dcim', '0080_3569_devicetype_fields'),
+    ]
+
+    operations = [
+
+        # Device.face
+        migrations.AlterField(
+            model_name='device',
+            name='face',
+            field=models.CharField(blank=True, default='', max_length=50),
+        ),
+        migrations.RunPython(
+            code=device_face_to_slug
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='face',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+
+        # Device.status
+        migrations.AlterField(
+            model_name='device',
+            name='status',
+            field=models.CharField(default='active', max_length=50),
+        ),
+        migrations.RunPython(
+            code=device_status_to_slug
+        ),
+
+    ]

+ 147 - 0
netbox/dcim/migrations/0082_3569_interface_fields.py

@@ -0,0 +1,147 @@
+from django.db import migrations, models
+
+
+INTERFACE_TYPE_CHOICES = (
+    (0, 'virtual'),
+    (200, 'lag'),
+    (800, '100base-tx'),
+    (1000, '1000base-t'),
+    (1050, '1000base-x-gbic'),
+    (1100, '1000base-x-sfp'),
+    (1120, '2.5gbase-t'),
+    (1130, '5gbase-t'),
+    (1150, '10gbase-t'),
+    (1170, '10gbase-cx4'),
+    (1200, '10gbase-x-sfpp'),
+    (1300, '10gbase-x-xfp'),
+    (1310, '10gbase-x-xenpak'),
+    (1320, '10gbase-x-x2'),
+    (1350, '25gbase-x-sfp28'),
+    (1400, '40gbase-x-qsfpp'),
+    (1420, '50gbase-x-sfp28'),
+    (1500, '100gbase-x-cfp'),
+    (1510, '100gbase-x-cfp2'),
+    (1520, '100gbase-x-cfp4'),
+    (1550, '100gbase-x-cpak'),
+    (1600, '100gbase-x-qsfp28'),
+    (1650, '200gbase-x-cfp2'),
+    (1700, '200gbase-x-qsfp56'),
+    (1750, '400gbase-x-qsfpdd'),
+    (1800, '400gbase-x-osfp'),
+    (2600, 'ieee802.11a'),
+    (2610, 'ieee802.11g'),
+    (2620, 'ieee802.11n'),
+    (2630, 'ieee802.11ac'),
+    (2640, 'ieee802.11ad'),
+    (2810, 'gsm'),
+    (2820, 'cdma'),
+    (2830, 'lte'),
+    (6100, 'sonet-oc3'),
+    (6200, 'sonet-oc12'),
+    (6300, 'sonet-oc48'),
+    (6400, 'sonet-oc192'),
+    (6500, 'sonet-oc768'),
+    (6600, 'sonet-oc1920'),
+    (6700, 'sonet-oc3840'),
+    (3010, '1gfc-sfp'),
+    (3020, '2gfc-sfp'),
+    (3040, '4gfc-sfp'),
+    (3080, '8gfc-sfpp'),
+    (3160, '16gfc-sfpp'),
+    (3320, '32gfc-sfp28'),
+    (3400, '128gfc-sfp28'),
+    (7010, 'inifiband-sdr'),
+    (7020, 'inifiband-ddr'),
+    (7030, 'inifiband-qdr'),
+    (7040, 'inifiband-fdr10'),
+    (7050, 'inifiband-fdr'),
+    (7060, 'inifiband-edr'),
+    (7070, 'inifiband-hdr'),
+    (7080, 'inifiband-ndr'),
+    (7090, 'inifiband-xdr'),
+    (4000, 't1'),
+    (4010, 'e1'),
+    (4040, 't3'),
+    (4050, 'e3'),
+    (5000, 'cisco-stackwise'),
+    (5050, 'cisco-stackwise-plus'),
+    (5100, 'cisco-flexstack'),
+    (5150, 'cisco-flexstack-plus'),
+    (5200, 'juniper-vcp'),
+    (5300, 'extreme-summitstack'),
+    (5310, 'extreme-summitstack-128'),
+    (5320, 'extreme-summitstack-256'),
+    (5330, 'extreme-summitstack-512'),
+)
+
+
+INTERFACE_MODE_CHOICES = (
+    (100, 'access'),
+    (200, 'tagged'),
+    (300, 'tagged-all'),
+)
+
+
+def interfacetemplate_type_to_slug(apps, schema_editor):
+    InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate')
+    for id, slug in INTERFACE_TYPE_CHOICES:
+        InterfaceTemplate.objects.filter(type=id).update(type=slug)
+
+
+def interface_type_to_slug(apps, schema_editor):
+    Interface = apps.get_model('dcim', 'Interface')
+    for id, slug in INTERFACE_TYPE_CHOICES:
+        Interface.objects.filter(type=id).update(type=slug)
+
+
+def interface_mode_to_slug(apps, schema_editor):
+    Interface = apps.get_model('dcim', 'Interface')
+    for id, slug in INTERFACE_MODE_CHOICES:
+        Interface.objects.filter(mode=id).update(mode=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('dcim', '0081_3569_device_fields'),
+    ]
+
+    operations = [
+
+        # InterfaceTemplate.type
+        migrations.AlterField(
+            model_name='interfacetemplate',
+            name='type',
+            field=models.CharField(max_length=50),
+        ),
+        migrations.RunPython(
+            code=interfacetemplate_type_to_slug
+        ),
+
+        # Interface.type
+        migrations.AlterField(
+            model_name='interface',
+            name='type',
+            field=models.CharField(max_length=50),
+        ),
+        migrations.RunPython(
+            code=interface_type_to_slug
+        ),
+
+        # Interface.mode
+        migrations.AlterField(
+            model_name='interface',
+            name='mode',
+            field=models.CharField(blank=True, default='', max_length=50),
+        ),
+        migrations.RunPython(
+            code=interface_mode_to_slug
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='mode',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+
+    ]

+ 93 - 0
netbox/dcim/migrations/0082_3569_port_fields.py

@@ -0,0 +1,93 @@
+from django.db import migrations, models
+
+
+PORT_TYPE_CHOICES = (
+    (1000, '8p8c'),
+    (1100, '110-punch'),
+    (1200, 'bnc'),
+    (2000, 'st'),
+    (2100, 'sc'),
+    (2110, 'sc-apc'),
+    (2200, 'fc'),
+    (2300, 'lc'),
+    (2310, 'lc-apc'),
+    (2400, 'mtrj'),
+    (2500, 'mpo'),
+    (2600, 'lsh'),
+    (2610, 'lsh-apc'),
+)
+
+
+def frontporttemplate_type_to_slug(apps, schema_editor):
+    FrontPortTemplate = apps.get_model('dcim', 'FrontPortTemplate')
+    for id, slug in PORT_TYPE_CHOICES:
+        FrontPortTemplate.objects.filter(type=id).update(type=slug)
+
+
+def rearporttemplate_type_to_slug(apps, schema_editor):
+    RearPortTemplate = apps.get_model('dcim', 'RearPortTemplate')
+    for id, slug in PORT_TYPE_CHOICES:
+        RearPortTemplate.objects.filter(type=id).update(type=slug)
+
+
+def frontport_type_to_slug(apps, schema_editor):
+    FrontPort = apps.get_model('dcim', 'FrontPort')
+    for id, slug in PORT_TYPE_CHOICES:
+        FrontPort.objects.filter(type=id).update(type=slug)
+
+
+def rearport_type_to_slug(apps, schema_editor):
+    RearPort = apps.get_model('dcim', 'RearPort')
+    for id, slug in PORT_TYPE_CHOICES:
+        RearPort.objects.filter(type=id).update(type=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('dcim', '0082_3569_interface_fields'),
+    ]
+
+    operations = [
+
+        # FrontPortTemplate.type
+        migrations.AlterField(
+            model_name='frontporttemplate',
+            name='type',
+            field=models.CharField(max_length=50),
+        ),
+        migrations.RunPython(
+            code=frontporttemplate_type_to_slug
+        ),
+
+        # RearPortTemplate.type
+        migrations.AlterField(
+            model_name='rearporttemplate',
+            name='type',
+            field=models.CharField(max_length=50),
+        ),
+        migrations.RunPython(
+            code=rearporttemplate_type_to_slug
+        ),
+
+        # FrontPort.type
+        migrations.AlterField(
+            model_name='frontport',
+            name='type',
+            field=models.CharField(max_length=50),
+        ),
+        migrations.RunPython(
+            code=frontport_type_to_slug
+        ),
+
+        # RearPort.type
+        migrations.AlterField(
+            model_name='rearport',
+            name='type',
+            field=models.CharField(max_length=50),
+        ),
+        migrations.RunPython(
+            code=rearport_type_to_slug
+        ),
+    ]

+ 85 - 0
netbox/dcim/migrations/0083_3569_cable_fields.py

@@ -0,0 +1,85 @@
+from django.db import migrations, models
+
+
+CABLE_TYPE_CHOICES = (
+    (1300, 'cat3'),
+    (1500, 'cat5'),
+    (1510, 'cat5e'),
+    (1600, 'cat6'),
+    (1610, 'cat6a'),
+    (1700, 'cat7'),
+    (1800, 'dac-active'),
+    (1810, 'dac-passive'),
+    (1900, 'coaxial'),
+    (3000, 'mmf'),
+    (3010, 'mmf-om1'),
+    (3020, 'mmf-om2'),
+    (3030, 'mmf-om3'),
+    (3040, 'mmf-om4'),
+    (3500, 'smf'),
+    (3510, 'smf-os1'),
+    (3520, 'smf-os2'),
+    (3800, 'aoc'),
+    (5000, 'power'),
+)
+
+CABLE_LENGTH_UNIT_CHOICES = (
+    (1200, 'm'),
+    (1100, 'cm'),
+    (2100, 'ft'),
+    (2000, 'in'),
+)
+
+
+def cable_type_to_slug(apps, schema_editor):
+    Cable = apps.get_model('dcim', 'Cable')
+    for id, slug in CABLE_TYPE_CHOICES:
+        Cable.objects.filter(type=id).update(type=slug)
+
+
+def cable_length_unit_to_slug(apps, schema_editor):
+    Cable = apps.get_model('dcim', 'Cable')
+    for id, slug in CABLE_LENGTH_UNIT_CHOICES:
+        Cable.objects.filter(length_unit=id).update(length_unit=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('dcim', '0082_3569_port_fields'),
+    ]
+
+    operations = [
+
+        # Cable.type
+        migrations.AlterField(
+            model_name='cable',
+            name='type',
+            field=models.CharField(blank=True, default='', max_length=50),
+        ),
+        migrations.RunPython(
+            code=cable_type_to_slug
+        ),
+        migrations.AlterField(
+            model_name='cable',
+            name='type',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+
+        # Cable.length_unit
+        migrations.AlterField(
+            model_name='cable',
+            name='length_unit',
+            field=models.CharField(blank=True, default='', max_length=50),
+        ),
+        migrations.RunPython(
+            code=cable_length_unit_to_slug
+        ),
+        migrations.AlterField(
+            model_name='cable',
+            name='length_unit',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+
+    ]

+ 100 - 0
netbox/dcim/migrations/0084_3569_powerfeed_fields.py

@@ -0,0 +1,100 @@
+from django.db import migrations, models
+
+
+POWERFEED_STATUS_CHOICES = (
+    (0, 'offline'),
+    (1, 'active'),
+    (2, 'planned'),
+    (4, 'failed'),
+)
+
+POWERFEED_TYPE_CHOICES = (
+    (1, 'primary'),
+    (2, 'redundant'),
+)
+
+POWERFEED_SUPPLY_CHOICES = (
+    (1, 'ac'),
+    (2, 'dc'),
+)
+
+POWERFEED_PHASE_CHOICES = (
+    (1, 'single-phase'),
+    (3, 'three-phase'),
+)
+
+
+def powerfeed_status_to_slug(apps, schema_editor):
+    PowerFeed = apps.get_model('dcim', 'PowerFeed')
+    for id, slug in POWERFEED_STATUS_CHOICES:
+        PowerFeed.objects.filter(status=id).update(status=slug)
+
+
+def powerfeed_type_to_slug(apps, schema_editor):
+    PowerFeed = apps.get_model('dcim', 'PowerFeed')
+    for id, slug in POWERFEED_TYPE_CHOICES:
+        PowerFeed.objects.filter(type=id).update(type=slug)
+
+
+def powerfeed_supply_to_slug(apps, schema_editor):
+    PowerFeed = apps.get_model('dcim', 'PowerFeed')
+    for id, slug in POWERFEED_SUPPLY_CHOICES:
+        PowerFeed.objects.filter(supply=id).update(supply=slug)
+
+
+def powerfeed_phase_to_slug(apps, schema_editor):
+    PowerFeed = apps.get_model('dcim', 'PowerFeed')
+    for id, slug in POWERFEED_PHASE_CHOICES:
+        PowerFeed.objects.filter(phase=id).update(phase=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('dcim', '0083_3569_cable_fields'),
+    ]
+
+    operations = [
+
+        # PowerFeed.status
+        migrations.AlterField(
+            model_name='powerfeed',
+            name='status',
+            field=models.CharField(default='active', max_length=50),
+        ),
+        migrations.RunPython(
+            code=powerfeed_status_to_slug
+        ),
+
+        # PowerFeed.type
+        migrations.AlterField(
+            model_name='powerfeed',
+            name='type',
+            field=models.CharField(default='primary', max_length=50),
+        ),
+        migrations.RunPython(
+            code=powerfeed_type_to_slug
+        ),
+
+        # PowerFeed.supply
+        migrations.AlterField(
+            model_name='powerfeed',
+            name='supply',
+            field=models.CharField(default='ac', max_length=50),
+        ),
+        migrations.RunPython(
+            code=powerfeed_supply_to_slug
+        ),
+
+        # PowerFeed.phase
+        migrations.AlterField(
+            model_name='powerfeed',
+            name='phase',
+            field=models.CharField(default='single-phase', max_length=50),
+        ),
+        migrations.RunPython(
+            code=powerfeed_phase_to_slug
+        ),
+
+    ]

+ 62 - 0
netbox/dcim/migrations/0085_3569_poweroutlet_fields.py

@@ -0,0 +1,62 @@
+from django.db import migrations, models
+
+
+POWEROUTLET_FEED_LEG_CHOICES_CHOICES = (
+    (1, 'A'),
+    (2, 'B'),
+    (3, 'C'),
+)
+
+
+def poweroutlettemplate_feed_leg_to_slug(apps, schema_editor):
+    PowerOutletTemplate = apps.get_model('dcim', 'PowerOutletTemplate')
+    for id, slug in POWEROUTLET_FEED_LEG_CHOICES_CHOICES:
+        PowerOutletTemplate.objects.filter(feed_leg=id).update(feed_leg=slug)
+
+
+def poweroutlet_feed_leg_to_slug(apps, schema_editor):
+    PowerOutlet = apps.get_model('dcim', 'PowerOutlet')
+    for id, slug in POWEROUTLET_FEED_LEG_CHOICES_CHOICES:
+        PowerOutlet.objects.filter(feed_leg=id).update(feed_leg=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('dcim', '0084_3569_powerfeed_fields'),
+    ]
+
+    operations = [
+
+        # PowerOutletTemplate.feed_leg
+        migrations.AlterField(
+            model_name='poweroutlettemplate',
+            name='feed_leg',
+            field=models.CharField(blank=True, default='', max_length=50),
+        ),
+        migrations.RunPython(
+            code=poweroutlettemplate_feed_leg_to_slug
+        ),
+        migrations.AlterField(
+            model_name='poweroutlettemplate',
+            name='feed_leg',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+
+        # PowerOutlet.feed_leg
+        migrations.AlterField(
+            model_name='poweroutlet',
+            name='feed_leg',
+            field=models.CharField(blank=True, default='', max_length=50),
+        ),
+        migrations.RunPython(
+            code=poweroutlet_feed_leg_to_slug
+        ),
+        migrations.AlterField(
+            model_name='poweroutlet',
+            name='feed_leg',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+
+    ]

+ 151 - 102
netbox/dcim/models.py

@@ -245,9 +245,10 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     slug = models.SlugField(
         unique=True
     )
-    status = models.PositiveSmallIntegerField(
-        choices=SITE_STATUS_CHOICES,
-        default=SITE_STATUS_ACTIVE
+    status = models.CharField(
+        max_length=50,
+        choices=SiteStatusChoices,
+        default=SiteStatusChoices.STATUS_ACTIVE
     )
     region = models.ForeignKey(
         to='dcim.Region',
@@ -331,6 +332,12 @@ class Site(ChangeLoggedModel, CustomFieldModel):
         'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
     ]
 
+    STATUS_CLASS_MAP = {
+        SiteStatusChoices.STATUS_ACTIVE: 'success',
+        SiteStatusChoices.STATUS_PLANNED: 'info',
+        SiteStatusChoices.STATUS_RETIRED: 'danger',
+    }
+
     class Meta:
         ordering = ['name']
 
@@ -362,7 +369,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
         )
 
     def get_status_class(self):
-        return STATUS_CLASSES[self.status]
+        return self.STATUS_CLASS_MAP.get(self.status)
 
 
 #
@@ -473,9 +480,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         blank=True,
         null=True
     )
-    status = models.PositiveSmallIntegerField(
-        choices=RACK_STATUS_CHOICES,
-        default=RACK_STATUS_ACTIVE
+    status = models.CharField(
+        max_length=50,
+        choices=RackStatusChoices,
+        default=RackStatusChoices.STATUS_ACTIVE
     )
     role = models.ForeignKey(
         to='dcim.RackRole',
@@ -497,15 +505,15 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         verbose_name='Asset tag',
         help_text='A unique tag used to identify this rack'
     )
-    type = models.PositiveSmallIntegerField(
-        choices=RACK_TYPE_CHOICES,
+    type = models.CharField(
+        choices=RackTypeChoices,
+        max_length=50,
         blank=True,
-        null=True,
         verbose_name='Type'
     )
     width = models.PositiveSmallIntegerField(
-        choices=RACK_WIDTH_CHOICES,
-        default=RACK_WIDTH_19IN,
+        choices=RackWidthChoices,
+        default=RackWidthChoices.WIDTH_19IN,
         verbose_name='Width',
         help_text='Rail-to-rail width'
     )
@@ -527,10 +535,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         blank=True,
         null=True
     )
-    outer_unit = models.PositiveSmallIntegerField(
-        choices=RACK_DIMENSION_UNIT_CHOICES,
+    outer_unit = models.CharField(
+        max_length=50,
+        choices=RackDimensionUnitChoices,
         blank=True,
-        null=True
     )
     comments = models.TextField(
         blank=True
@@ -552,6 +560,14 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
     ]
 
+    STATUS_CLASS_MAP = {
+        RackStatusChoices.STATUS_RESERVED: 'warning',
+        RackStatusChoices.STATUS_AVAILABLE: 'success',
+        RackStatusChoices.STATUS_PLANNED: 'info',
+        RackStatusChoices.STATUS_ACTIVE: 'primary',
+        RackStatusChoices.STATUS_DEPRECATED: 'danger',
+    }
+
     class Meta:
         ordering = ['site', 'group', 'name']
         unique_together = [
@@ -568,10 +584,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
     def clean(self):
 
         # Validate outer dimensions and unit
-        if (self.outer_width is not None or self.outer_depth is not None) and self.outer_unit is None:
+        if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
             raise ValidationError("Must specify a unit when setting an outer width/depth")
         elif self.outer_width is None and self.outer_depth is None:
-            self.outer_unit = None
+            self.outer_unit = ''
 
         if self.pk:
             # Validate that Rack is tall enough to house the installed Devices
@@ -644,9 +660,9 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         return ""
 
     def get_status_class(self):
-        return STATUS_CLASSES[self.status]
+        return self.STATUS_CLASS_MAP.get(self.status)
 
-    def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False):
+    def get_rack_units(self, face=DeviceFaceChoices.FACE_FRONT, exclude=None, remove_redundant=False):
         """
         Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
         Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
@@ -678,10 +694,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         return [u for u in elevation.values()]
 
     def get_front_elevation(self):
-        return self.get_rack_units(face=RACK_FACE_FRONT, remove_redundant=True)
+        return self.get_rack_units(face=DeviceFaceChoices.FACE_FRONT, remove_redundant=True)
 
     def get_rear_elevation(self):
-        return self.get_rack_units(face=RACK_FACE_REAR, remove_redundant=True)
+        return self.get_rack_units(face=DeviceFaceChoices.FACE_REAR, remove_redundant=True)
 
     def get_available_units(self, u_height=1, rack_face=None, exclude=list()):
         """
@@ -910,12 +926,13 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
         verbose_name='Is full depth',
         help_text='Device consumes both front and rear rack faces'
     )
-    subdevice_role = models.NullBooleanField(
-        default=None,
+    subdevice_role = models.CharField(
+        max_length=50,
+        choices=SubdeviceRoleChoices,
+        blank=True,
         verbose_name='Parent/child status',
-        choices=SUBDEVICE_ROLE_CHOICES,
-        help_text='Parent devices house child devices in device bays. Select '
-                  '"None" if this device type is neither a parent nor a child.'
+        help_text='Parent devices house child devices in device bays. Leave blank '
+                  'if this device type is neither a parent nor a child.'
     )
     comments = models.TextField(
         blank=True
@@ -959,7 +976,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
             self.part_number,
             self.u_height,
             self.is_full_depth,
-            self.get_subdevice_role_display() if self.subdevice_role else None,
+            self.get_subdevice_role_display(),
             self.comments,
         )
 
@@ -979,13 +996,15 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
                                     "{}U".format(d, d.rack, self.u_height)
                     })
 
-        if self.subdevice_role != SUBDEVICE_ROLE_PARENT and self.device_bay_templates.count():
+        if (
+                self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
+        ) and self.device_bay_templates.count():
             raise ValidationError({
                 'subdevice_role': "Must delete all device bay templates associated with this device before "
                                   "declassifying it as a parent device."
             })
 
-        if self.u_height and self.subdevice_role == SUBDEVICE_ROLE_CHILD:
+        if self.u_height and self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD:
             raise ValidationError({
                 'u_height': "Child device types must be 0U."
             })
@@ -996,11 +1015,11 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
 
     @property
     def is_parent_device(self):
-        return bool(self.subdevice_role)
+        return self.subdevice_role == SubdeviceRoleChoices.ROLE_PARENT
 
     @property
     def is_child_device(self):
-        return bool(self.subdevice_role is False)
+        return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
 
 
 class ConsolePortTemplate(ComponentTemplateModel):
@@ -1017,7 +1036,7 @@ class ConsolePortTemplate(ComponentTemplateModel):
     )
     type = models.CharField(
         max_length=50,
-        choices=ConsolePortTypes.CHOICES,
+        choices=ConsolePortTypeChoices,
         blank=True
     )
 
@@ -1052,7 +1071,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
     )
     type = models.CharField(
         max_length=50,
-        choices=ConsolePortTypes.CHOICES,
+        choices=ConsolePortTypeChoices,
         blank=True
     )
 
@@ -1087,7 +1106,7 @@ class PowerPortTemplate(ComponentTemplateModel):
     )
     type = models.CharField(
         max_length=50,
-        choices=PowerPortTypes.CHOICES,
+        choices=PowerPortTypeChoices,
         blank=True
     )
     maximum_draw = models.PositiveSmallIntegerField(
@@ -1135,7 +1154,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
     )
     type = models.CharField(
         max_length=50,
-        choices=PowerOutletTypes.CHOICES,
+        choices=PowerOutletTypeChoices,
         blank=True
     )
     power_port = models.ForeignKey(
@@ -1145,10 +1164,10 @@ class PowerOutletTemplate(ComponentTemplateModel):
         null=True,
         related_name='poweroutlet_templates'
     )
-    feed_leg = models.PositiveSmallIntegerField(
-        choices=POWERFEED_LEG_CHOICES,
+    feed_leg = models.CharField(
+        max_length=50,
+        choices=PowerOutletFeedLegChoices,
         blank=True,
-        null=True,
         help_text="Phase (for three-phase feeds)"
     )
 
@@ -1194,9 +1213,9 @@ class InterfaceTemplate(ComponentTemplateModel):
     name = models.CharField(
         max_length=64
     )
-    type = models.PositiveSmallIntegerField(
-        choices=IFACE_TYPE_CHOICES,
-        default=IFACE_TYPE_10GE_SFP_PLUS
+    type = models.CharField(
+        max_length=50,
+        choices=InterfaceTypeChoices
     )
     mgmt_only = models.BooleanField(
         default=False,
@@ -1233,8 +1252,9 @@ class FrontPortTemplate(ComponentTemplateModel):
     name = models.CharField(
         max_length=64
     )
-    type = models.PositiveSmallIntegerField(
-        choices=PORT_TYPE_CHOICES
+    type = models.CharField(
+        max_length=50,
+        choices=PortTypeChoices
     )
     rear_port = models.ForeignKey(
         to='dcim.RearPortTemplate',
@@ -1300,8 +1320,9 @@ class RearPortTemplate(ComponentTemplateModel):
     name = models.CharField(
         max_length=64
     )
-    type = models.PositiveSmallIntegerField(
-        choices=PORT_TYPE_CHOICES
+    type = models.CharField(
+        max_length=50,
+        choices=PortTypeChoices
     )
     positions = models.PositiveSmallIntegerField(
         default=1,
@@ -1526,16 +1547,16 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         verbose_name='Position (U)',
         help_text='The lowest-numbered unit occupied by the device'
     )
-    face = models.PositiveSmallIntegerField(
+    face = models.CharField(
+        max_length=50,
         blank=True,
-        null=True,
-        choices=RACK_FACE_CHOICES,
+        choices=DeviceFaceChoices,
         verbose_name='Rack face'
     )
-    status = models.PositiveSmallIntegerField(
-        choices=DEVICE_STATUS_CHOICES,
-        default=DEVICE_STATUS_ACTIVE,
-        verbose_name='Status'
+    status = models.CharField(
+        max_length=50,
+        choices=DeviceStatusChoices,
+        default=DeviceStatusChoices.STATUS_ACTIVE
     )
     primary_ip4 = models.OneToOneField(
         to='ipam.IPAddress',
@@ -1597,6 +1618,16 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
     ]
 
+    STATUS_CLASS_MAP = {
+        DeviceStatusChoices.STATUS_OFFLINE: 'warning',
+        DeviceStatusChoices.STATUS_ACTIVE: 'success',
+        DeviceStatusChoices.STATUS_PLANNED: 'info',
+        DeviceStatusChoices.STATUS_STAGED: 'primary',
+        DeviceStatusChoices.STATUS_FAILED: 'danger',
+        DeviceStatusChoices.STATUS_INVENTORY: 'default',
+        DeviceStatusChoices.STATUS_DECOMMISSIONING: 'warning',
+    }
+
     class Meta:
         ordering = ['name']
         unique_together = [
@@ -1625,7 +1656,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
             })
 
         if self.rack is None:
-            if self.face is not None:
+            if self.face:
                 raise ValidationError({
                     'face': "Cannot select a rack face without assigning a rack.",
                 })
@@ -1635,7 +1666,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
                 })
 
         # Validate position/face combination
-        if self.position and self.face is None:
+        if self.position and not self.face:
             raise ValidationError({
                 'face': "Must specify rack face when defining rack position.",
             })
@@ -1850,7 +1881,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         return Device.objects.filter(parent_bay__device=self.pk)
 
     def get_status_class(self):
-        return STATUS_CLASSES[self.status]
+        return self.STATUS_CLASS_MAP.get(self.status)
 
 
 #
@@ -1871,7 +1902,7 @@ class ConsolePort(CableTermination, ComponentModel):
     )
     type = models.CharField(
         max_length=50,
-        choices=ConsolePortTypes.CHOICES,
+        choices=ConsolePortTypeChoices,
         blank=True
     )
     connected_endpoint = models.OneToOneField(
@@ -1928,7 +1959,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
     )
     type = models.CharField(
         max_length=50,
-        choices=ConsolePortTypes.CHOICES,
+        choices=ConsolePortTypeChoices,
         blank=True
     )
     connection_status = models.NullBooleanField(
@@ -1977,7 +2008,7 @@ class PowerPort(CableTermination, ComponentModel):
     )
     type = models.CharField(
         max_length=50,
-        choices=PowerPortTypes.CHOICES,
+        choices=PowerPortTypeChoices,
         blank=True
     )
     maximum_draw = models.PositiveSmallIntegerField(
@@ -2077,8 +2108,8 @@ class PowerPort(CableTermination, ComponentModel):
             }
 
             # Calculate per-leg aggregates for three-phase feeds
-            if self._connected_powerfeed and self._connected_powerfeed.phase == POWERFEED_PHASE_3PHASE:
-                for leg, leg_name in POWERFEED_LEG_CHOICES:
+            if self._connected_powerfeed and self._connected_powerfeed.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
+                for leg, leg_name in PowerOutletFeedLegChoices:
                     outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
                     utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
                         maximum_draw_total=Sum('maximum_draw'),
@@ -2120,7 +2151,7 @@ class PowerOutlet(CableTermination, ComponentModel):
     )
     type = models.CharField(
         max_length=50,
-        choices=PowerOutletTypes.CHOICES,
+        choices=PowerOutletTypeChoices,
         blank=True
     )
     power_port = models.ForeignKey(
@@ -2130,10 +2161,10 @@ class PowerOutlet(CableTermination, ComponentModel):
         null=True,
         related_name='poweroutlets'
     )
-    feed_leg = models.PositiveSmallIntegerField(
-        choices=POWERFEED_LEG_CHOICES,
+    feed_leg = models.CharField(
+        max_length=50,
+        choices=PowerOutletFeedLegChoices,
         blank=True,
-        null=True,
         help_text="Phase (for three-phase feeds)"
     )
     connection_status = models.NullBooleanField(
@@ -2226,9 +2257,9 @@ class Interface(CableTermination, ComponentModel):
         blank=True,
         verbose_name='Parent LAG'
     )
-    type = models.PositiveSmallIntegerField(
-        choices=IFACE_TYPE_CHOICES,
-        default=IFACE_TYPE_10GE_SFP_PLUS
+    type = models.CharField(
+        max_length=50,
+        choices=InterfaceTypeChoices
     )
     enabled = models.BooleanField(
         default=True
@@ -2249,10 +2280,10 @@ class Interface(CableTermination, ComponentModel):
         verbose_name='OOB Management',
         help_text='This interface is used only for out-of-band management'
     )
-    mode = models.PositiveSmallIntegerField(
-        choices=IFACE_MODE_CHOICES,
+    mode = models.CharField(
+        max_length=50,
+        choices=InterfaceModeChoices,
         blank=True,
-        null=True
     )
     untagged_vlan = models.ForeignKey(
         to='ipam.VLAN',
@@ -2311,7 +2342,7 @@ class Interface(CableTermination, ComponentModel):
             raise ValidationError("An interface must belong to either a device or a virtual machine.")
 
         # VM interfaces must be virtual
-        if self.virtual_machine and self.type is not IFACE_TYPE_VIRTUAL:
+        if self.virtual_machine and self.type is not InterfaceTypeChoices.TYPE_VIRTUAL:
             raise ValidationError({
                 'type': "Virtual machines can only have virtual interfaces."
             })
@@ -2340,7 +2371,7 @@ class Interface(CableTermination, ComponentModel):
             })
 
         # Only a LAG can have LAG members
-        if self.type != IFACE_TYPE_LAG and self.member_interfaces.exists():
+        if self.type != InterfaceTypeChoices.TYPE_LAG and self.member_interfaces.exists():
             raise ValidationError({
                 'type': "Cannot change interface type; it has LAG members ({}).".format(
                     ", ".join([iface.name for iface in self.member_interfaces.all()])
@@ -2361,7 +2392,7 @@ class Interface(CableTermination, ComponentModel):
             self.untagged_vlan = None
 
         # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
-        if self.pk and self.mode is not IFACE_MODE_TAGGED:
+        if self.pk and self.mode is not InterfaceModeChoices.MODE_TAGGED:
             self.tagged_vlans.clear()
 
         return super().save(*args, **kwargs)
@@ -2423,7 +2454,7 @@ class Interface(CableTermination, ComponentModel):
 
     @property
     def is_lag(self):
-        return self.type == IFACE_TYPE_LAG
+        return self.type == InterfaceTypeChoices.TYPE_LAG
 
     @property
     def count_ipaddresses(self):
@@ -2446,8 +2477,9 @@ class FrontPort(CableTermination, ComponentModel):
     name = models.CharField(
         max_length=64
     )
-    type = models.PositiveSmallIntegerField(
-        choices=PORT_TYPE_CHOICES
+    type = models.CharField(
+        max_length=50,
+        choices=PortTypeChoices
     )
     rear_port = models.ForeignKey(
         to='dcim.RearPort',
@@ -2513,8 +2545,9 @@ class RearPort(CableTermination, ComponentModel):
     name = models.CharField(
         max_length=64
     )
-    type = models.PositiveSmallIntegerField(
-        choices=PORT_TYPE_CHOICES
+    type = models.CharField(
+        max_length=50,
+        choices=PortTypeChoices
     )
     positions = models.PositiveSmallIntegerField(
         default=1,
@@ -2776,10 +2809,10 @@ class Cable(ChangeLoggedModel):
         ct_field='termination_b_type',
         fk_field='termination_b_id'
     )
-    type = models.PositiveSmallIntegerField(
-        choices=CABLE_TYPE_CHOICES,
-        blank=True,
-        null=True
+    type = models.CharField(
+        max_length=50,
+        choices=CableTypeChoices,
+        blank=True
     )
     status = models.BooleanField(
         choices=CONNECTION_STATUS_CHOICES,
@@ -2796,10 +2829,10 @@ class Cable(ChangeLoggedModel):
         blank=True,
         null=True
     )
-    length_unit = models.PositiveSmallIntegerField(
-        choices=CABLE_LENGTH_UNIT_CHOICES,
+    length_unit = models.CharField(
+        max_length=50,
+        choices=CableLengthUnitChoices,
         blank=True,
-        null=True
     )
     # Stores the normalized length (in meters) for database ordering
     _abs_length = models.DecimalField(
@@ -2927,10 +2960,10 @@ class Cable(ChangeLoggedModel):
             ))
 
         # Validate length and length_unit
-        if self.length is not None and self.length_unit is None:
+        if self.length is not None and not self.length_unit:
             raise ValidationError("Must specify a unit when setting a cable length")
         elif self.length is None:
-            self.length_unit = None
+            self.length_unit = ''
 
     def save(self, *args, **kwargs):
 
@@ -3074,21 +3107,25 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
     name = models.CharField(
         max_length=50
     )
-    status = models.PositiveSmallIntegerField(
-        choices=POWERFEED_STATUS_CHOICES,
-        default=POWERFEED_STATUS_ACTIVE
+    status = models.CharField(
+        max_length=50,
+        choices=PowerFeedStatusChoices,
+        default=PowerFeedStatusChoices.STATUS_ACTIVE
     )
-    type = models.PositiveSmallIntegerField(
-        choices=POWERFEED_TYPE_CHOICES,
-        default=POWERFEED_TYPE_PRIMARY
+    type = models.CharField(
+        max_length=50,
+        choices=PowerFeedTypeChoices,
+        default=PowerFeedTypeChoices.TYPE_PRIMARY
     )
-    supply = models.PositiveSmallIntegerField(
-        choices=POWERFEED_SUPPLY_CHOICES,
-        default=POWERFEED_SUPPLY_AC
+    supply = models.CharField(
+        max_length=50,
+        choices=PowerFeedSupplyChoices,
+        default=PowerFeedSupplyChoices.SUPPLY_AC
     )
-    phase = models.PositiveSmallIntegerField(
-        choices=POWERFEED_PHASE_CHOICES,
-        default=POWERFEED_PHASE_SINGLE
+    phase = models.CharField(
+        max_length=50,
+        choices=PowerFeedPhaseChoices,
+        default=PowerFeedPhaseChoices.PHASE_SINGLE
     )
     voltage = models.PositiveSmallIntegerField(
         validators=[MinValueValidator(1)],
@@ -3123,6 +3160,18 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
         'amperage', 'max_utilization', 'comments',
     ]
 
+    STATUS_CLASS_MAP = {
+        PowerFeedStatusChoices.STATUS_OFFLINE: 'warning',
+        PowerFeedStatusChoices.STATUS_ACTIVE: 'success',
+        PowerFeedStatusChoices.STATUS_PLANNED: 'info',
+        PowerFeedStatusChoices.STATUS_FAILED: 'danger',
+    }
+
+    TYPE_CLASS_MAP = {
+        PowerFeedTypeChoices.TYPE_PRIMARY: 'success',
+        PowerFeedTypeChoices.TYPE_REDUNDANT: 'info',
+    }
+
     class Meta:
         ordering = ['power_panel', 'name']
         unique_together = ['power_panel', 'name']
@@ -3162,7 +3211,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
 
         # Cache the available_power property on the instance
         kva = self.voltage * self.amperage * (self.max_utilization / 100)
-        if self.phase == POWERFEED_PHASE_3PHASE:
+        if self.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
             self.available_power = round(kva * 1.732)
         else:
             self.available_power = round(kva)
@@ -3170,7 +3219,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
         super().save(*args, **kwargs)
 
     def get_type_class(self):
-        return STATUS_CLASSES[self.type]
+        return self.TYPE_CLASS_MAP.get(self.type)
 
     def get_status_class(self):
-        return STATUS_CLASSES[self.status]
+        return self.STATUS_CLASS_MAP.get(self.status)

+ 0 - 8
netbox/dcim/tables.py

@@ -156,10 +156,6 @@ DEVICE_PRIMARY_IP = """
 {{ record.primary_ip4.address.ip|default:"" }}
 """
 
-SUBDEVICE_ROLE_TEMPLATE = """
-{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %}
-"""
-
 DEVICETYPE_INSTANCES_TEMPLATE = """
 <a href="{% url 'dcim:device_list' %}?manufacturer_id={{ record.manufacturer_id }}&device_type_id={{ record.pk }}">{{ record.instance_count }}</a>
 """
@@ -391,10 +387,6 @@ class DeviceTypeTable(BaseTable):
         verbose_name='Device Type'
     )
     is_full_depth = BooleanColumn(verbose_name='Full Depth')
-    subdevice_role = tables.TemplateColumn(
-        template_code=SUBDEVICE_ROLE_TEMPLATE,
-        verbose_name='Subdevice Role'
-    )
     instance_count = tables.TemplateColumn(
         template_code=DEVICETYPE_INSTANCES_TEMPLATE,
         verbose_name='Instances'

+ 44 - 43
netbox/dcim/tests/test_api.py

@@ -3,6 +3,7 @@ from netaddr import IPNetwork
 from rest_framework import status
 
 from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
+from dcim.choices import *
 from dcim.constants import *
 from dcim.models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
@@ -180,7 +181,7 @@ class SiteTest(APITestCase):
             'name': 'Test Site 4',
             'slug': 'test-site-4',
             'region': self.region1.pk,
-            'status': SITE_STATUS_ACTIVE,
+            'status': SiteStatusChoices.STATUS_ACTIVE,
         }
 
         url = reverse('dcim-api:site-list')
@@ -200,19 +201,19 @@ class SiteTest(APITestCase):
                 'name': 'Test Site 4',
                 'slug': 'test-site-4',
                 'region': self.region1.pk,
-                'status': SITE_STATUS_ACTIVE,
+                'status': SiteStatusChoices.STATUS_ACTIVE,
             },
             {
                 'name': 'Test Site 5',
                 'slug': 'test-site-5',
                 'region': self.region1.pk,
-                'status': SITE_STATUS_ACTIVE,
+                'status': SiteStatusChoices.STATUS_ACTIVE,
             },
             {
                 'name': 'Test Site 6',
                 'slug': 'test-site-6',
                 'region': self.region1.pk,
-                'status': SITE_STATUS_ACTIVE,
+                'status': SiteStatusChoices.STATUS_ACTIVE,
             },
         ]
 
@@ -2473,7 +2474,7 @@ class InterfaceTest(APITestCase):
         data = {
             'device': self.device.pk,
             'name': 'Test Interface 4',
-            'mode': IFACE_MODE_TAGGED,
+            'mode': InterfaceModeChoices.MODE_TAGGED,
             'untagged_vlan': self.vlan3.id,
             'tagged_vlans': [self.vlan1.id, self.vlan2.id],
         }
@@ -2520,21 +2521,21 @@ class InterfaceTest(APITestCase):
             {
                 'device': self.device.pk,
                 'name': 'Test Interface 4',
-                'mode': IFACE_MODE_TAGGED,
+                'mode': InterfaceModeChoices.MODE_TAGGED,
                 'untagged_vlan': self.vlan2.id,
                 'tagged_vlans': [self.vlan1.id],
             },
             {
                 'device': self.device.pk,
                 'name': 'Test Interface 5',
-                'mode': IFACE_MODE_TAGGED,
+                'mode': InterfaceModeChoices.MODE_TAGGED,
                 'untagged_vlan': self.vlan2.id,
                 'tagged_vlans': [self.vlan1.id],
             },
             {
                 'device': self.device.pk,
                 'name': 'Test Interface 6',
-                'mode': IFACE_MODE_TAGGED,
+                'mode': InterfaceModeChoices.MODE_TAGGED,
                 'untagged_vlan': self.vlan2.id,
                 'tagged_vlans': [self.vlan1.id],
             },
@@ -2553,7 +2554,7 @@ class InterfaceTest(APITestCase):
     def test_update_interface(self):
 
         lag_interface = Interface.objects.create(
-            device=self.device, name='Test LAG Interface', type=IFACE_TYPE_LAG
+            device=self.device, name='Test LAG Interface', type=InterfaceTypeChoices.TYPE_LAG
         )
 
         data = {
@@ -2590,11 +2591,11 @@ class DeviceBayTest(APITestCase):
         manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
         self.devicetype1 = DeviceType.objects.create(
             manufacturer=manufacturer, model='Parent Device Type', slug='parent-device-type',
-            subdevice_role=SUBDEVICE_ROLE_PARENT
+            subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
         )
         self.devicetype2 = DeviceType.objects.create(
             manufacturer=manufacturer, model='Child Device Type', slug='child-device-type',
-            subdevice_role=SUBDEVICE_ROLE_CHILD
+            subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
         )
         devicerole = DeviceRole.objects.create(
             name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
@@ -2841,7 +2842,7 @@ class CableTest(APITestCase):
         )
         for device in [self.device1, self.device2]:
             for i in range(0, 10):
-                Interface(device=device, type=IFACE_TYPE_1GE_FIXED, name='eth{}'.format(i)).save()
+                Interface(device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth{}'.format(i)).save()
 
         self.cable1 = Cable(
             termination_a=self.device1.interfaces.get(name='eth0'),
@@ -3033,16 +3034,16 @@ class ConnectionTest(APITestCase):
             device=self.device2, name='Test Console Server Port 1'
         )
         rearport1 = RearPort.objects.create(
-            device=self.panel1, name='Test Rear Port 1', type=PORT_TYPE_8P8C
+            device=self.panel1, name='Test Rear Port 1', type=PortTypeChoices.TYPE_8P8C
         )
         frontport1 = FrontPort.objects.create(
-            device=self.panel1, name='Test Front Port 1', type=PORT_TYPE_8P8C, rear_port=rearport1
+            device=self.panel1, name='Test Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport1
         )
         rearport2 = RearPort.objects.create(
-            device=self.panel2, name='Test Rear Port 2', type=PORT_TYPE_8P8C
+            device=self.panel2, name='Test Rear Port 2', type=PortTypeChoices.TYPE_8P8C
         )
         frontport2 = FrontPort.objects.create(
-            device=self.panel2, name='Test Front Port 2', type=PORT_TYPE_8P8C, rear_port=rearport2
+            device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
         )
 
         url = reverse('dcim-api:cable-list')
@@ -3161,16 +3162,16 @@ class ConnectionTest(APITestCase):
             device=self.device2, name='Test Interface 2'
         )
         rearport1 = RearPort.objects.create(
-            device=self.panel1, name='Test Rear Port 1', type=PORT_TYPE_8P8C
+            device=self.panel1, name='Test Rear Port 1', type=PortTypeChoices.TYPE_8P8C
         )
         frontport1 = FrontPort.objects.create(
-            device=self.panel1, name='Test Front Port 1', type=PORT_TYPE_8P8C, rear_port=rearport1
+            device=self.panel1, name='Test Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport1
         )
         rearport2 = RearPort.objects.create(
-            device=self.panel2, name='Test Rear Port 2', type=PORT_TYPE_8P8C
+            device=self.panel2, name='Test Rear Port 2', type=PortTypeChoices.TYPE_8P8C
         )
         frontport2 = FrontPort.objects.create(
-            device=self.panel2, name='Test Front Port 2', type=PORT_TYPE_8P8C, rear_port=rearport2
+            device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
         )
 
         url = reverse('dcim-api:cable-list')
@@ -3272,16 +3273,16 @@ class ConnectionTest(APITestCase):
             circuit=circuit, term_side='A', site=self.site, port_speed=10000
         )
         rearport1 = RearPort.objects.create(
-            device=self.panel1, name='Test Rear Port 1', type=PORT_TYPE_8P8C
+            device=self.panel1, name='Test Rear Port 1', type=PortTypeChoices.TYPE_8P8C
         )
         frontport1 = FrontPort.objects.create(
-            device=self.panel1, name='Test Front Port 1', type=PORT_TYPE_8P8C, rear_port=rearport1
+            device=self.panel1, name='Test Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport1
         )
         rearport2 = RearPort.objects.create(
-            device=self.panel2, name='Test Rear Port 2', type=PORT_TYPE_8P8C
+            device=self.panel2, name='Test Rear Port 2', type=PortTypeChoices.TYPE_8P8C
         )
         frontport2 = FrontPort.objects.create(
-            device=self.panel2, name='Test Front Port 2', type=PORT_TYPE_8P8C, rear_port=rearport2
+            device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
         )
 
         url = reverse('dcim-api:cable-list')
@@ -3410,23 +3411,23 @@ class VirtualChassisTest(APITestCase):
             device_type=device_type, device_role=device_role, name='StackSwitch9', site=site
         )
         for i in range(0, 13):
-            Interface.objects.create(device=self.device1, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
+            Interface.objects.create(device=self.device1, name='1/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         for i in range(0, 13):
-            Interface.objects.create(device=self.device2, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
+            Interface.objects.create(device=self.device2, name='2/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         for i in range(0, 13):
-            Interface.objects.create(device=self.device3, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
+            Interface.objects.create(device=self.device3, name='3/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         for i in range(0, 13):
-            Interface.objects.create(device=self.device4, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
+            Interface.objects.create(device=self.device4, name='1/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         for i in range(0, 13):
-            Interface.objects.create(device=self.device5, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
+            Interface.objects.create(device=self.device5, name='2/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         for i in range(0, 13):
-            Interface.objects.create(device=self.device6, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
+            Interface.objects.create(device=self.device6, name='3/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         for i in range(0, 13):
-            Interface.objects.create(device=self.device7, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
+            Interface.objects.create(device=self.device7, name='1/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         for i in range(0, 13):
-            Interface.objects.create(device=self.device8, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
+            Interface.objects.create(device=self.device8, name='2/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         for i in range(0, 13):
-            Interface.objects.create(device=self.device9, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
+            Interface.objects.create(device=self.device9, name='3/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
 
         # Create two VirtualChassis with three members each
         self.vc1 = VirtualChassis.objects.create(master=self.device1, domain='test-domain-1')
@@ -3678,22 +3679,22 @@ class PowerFeedTest(APITestCase):
             site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 2'
         )
         self.powerfeed1 = PowerFeed.objects.create(
-            power_panel=self.powerpanel1, rack=self.rack1, name='Test Power Feed 1A', type=POWERFEED_TYPE_PRIMARY
+            power_panel=self.powerpanel1, rack=self.rack1, name='Test Power Feed 1A', type=PowerFeedTypeChoices.TYPE_PRIMARY
         )
         self.powerfeed2 = PowerFeed.objects.create(
-            power_panel=self.powerpanel2, rack=self.rack1, name='Test Power Feed 1B', type=POWERFEED_TYPE_REDUNDANT
+            power_panel=self.powerpanel2, rack=self.rack1, name='Test Power Feed 1B', type=PowerFeedTypeChoices.TYPE_REDUNDANT
         )
         self.powerfeed3 = PowerFeed.objects.create(
-            power_panel=self.powerpanel1, rack=self.rack2, name='Test Power Feed 2A', type=POWERFEED_TYPE_PRIMARY
+            power_panel=self.powerpanel1, rack=self.rack2, name='Test Power Feed 2A', type=PowerFeedTypeChoices.TYPE_PRIMARY
         )
         self.powerfeed4 = PowerFeed.objects.create(
-            power_panel=self.powerpanel2, rack=self.rack2, name='Test Power Feed 2B', type=POWERFEED_TYPE_REDUNDANT
+            power_panel=self.powerpanel2, rack=self.rack2, name='Test Power Feed 2B', type=PowerFeedTypeChoices.TYPE_REDUNDANT
         )
         self.powerfeed5 = PowerFeed.objects.create(
-            power_panel=self.powerpanel1, rack=self.rack3, name='Test Power Feed 3A', type=POWERFEED_TYPE_PRIMARY
+            power_panel=self.powerpanel1, rack=self.rack3, name='Test Power Feed 3A', type=PowerFeedTypeChoices.TYPE_PRIMARY
         )
         self.powerfeed6 = PowerFeed.objects.create(
-            power_panel=self.powerpanel2, rack=self.rack3, name='Test Power Feed 3B', type=POWERFEED_TYPE_REDUNDANT
+            power_panel=self.powerpanel2, rack=self.rack3, name='Test Power Feed 3B', type=PowerFeedTypeChoices.TYPE_REDUNDANT
         )
 
     def test_get_powerfeed(self):
@@ -3726,7 +3727,7 @@ class PowerFeedTest(APITestCase):
             'name': 'Test Power Feed 4A',
             'power_panel': self.powerpanel1.pk,
             'rack': self.rack4.pk,
-            'type': POWERFEED_TYPE_PRIMARY,
+            'type': PowerFeedTypeChoices.TYPE_PRIMARY,
         }
 
         url = reverse('dcim-api:powerfeed-list')
@@ -3746,13 +3747,13 @@ class PowerFeedTest(APITestCase):
                 'name': 'Test Power Feed 4A',
                 'power_panel': self.powerpanel1.pk,
                 'rack': self.rack4.pk,
-                'type': POWERFEED_TYPE_PRIMARY,
+                'type': PowerFeedTypeChoices.TYPE_PRIMARY,
             },
             {
                 'name': 'Test Power Feed 4B',
                 'power_panel': self.powerpanel1.pk,
                 'rack': self.rack4.pk,
-                'type': POWERFEED_TYPE_REDUNDANT,
+                'type': PowerFeedTypeChoices.TYPE_REDUNDANT,
             },
         ]
 
@@ -3769,7 +3770,7 @@ class PowerFeedTest(APITestCase):
         data = {
             'name': 'Test Power Feed X',
             'rack': self.rack4.pk,
-            'type': POWERFEED_TYPE_REDUNDANT,
+            'type': PowerFeedTypeChoices.TYPE_REDUNDANT,
         }
 
         url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})

+ 8 - 8
netbox/dcim/tests/test_forms.py

@@ -21,10 +21,10 @@ class DeviceTestCase(TestCase):
             'device_type': get_id(DeviceType, 'qfx5100-48s'),
             'site': get_id(Site, 'test1'),
             'rack': '1',
-            'face': RACK_FACE_FRONT,
+            'face': DeviceFaceChoices.FACE_FRONT,
             'position': 41,
             'platform': get_id(Platform, 'juniper-junos'),
-            'status': DEVICE_STATUS_ACTIVE,
+            'status': DeviceStatusChoices.STATUS_ACTIVE,
         })
         self.assertTrue(test.is_valid(), test.fields['position'].choices)
         self.assertTrue(test.save())
@@ -38,10 +38,10 @@ class DeviceTestCase(TestCase):
             'device_type': get_id(DeviceType, 'qfx5100-48s'),
             'site': get_id(Site, 'test1'),
             'rack': '1',
-            'face': RACK_FACE_FRONT,
+            'face': DeviceFaceChoices.FACE_FRONT,
             'position': 1,
             'platform': get_id(Platform, 'juniper-junos'),
-            'status': DEVICE_STATUS_ACTIVE,
+            'status': DeviceStatusChoices.STATUS_ACTIVE,
         })
         self.assertFalse(test.is_valid())
 
@@ -54,10 +54,10 @@ class DeviceTestCase(TestCase):
             'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
             'site': get_id(Site, 'test1'),
             'rack': '1',
-            'face': None,
+            'face': '',
             'position': None,
             'platform': None,
-            'status': DEVICE_STATUS_ACTIVE,
+            'status': DeviceStatusChoices.STATUS_ACTIVE,
         })
         self.assertTrue(test.is_valid())
         self.assertTrue(test.save())
@@ -71,10 +71,10 @@ class DeviceTestCase(TestCase):
             'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
             'site': get_id(Site, 'test1'),
             'rack': '1',
-            'face': RACK_FACE_REAR,
+            'face': DeviceFaceChoices.FACE_REAR,
             'position': None,
             'platform': None,
-            'status': DEVICE_STATUS_ACTIVE,
+            'status': DeviceStatusChoices.STATUS_ACTIVE,
         })
         self.assertTrue(test.is_valid())
         self.assertTrue(test.save())

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

@@ -87,7 +87,7 @@ class RackTestCase(TestCase):
             site=self.site1,
             rack=rack1,
             position=43,
-            face=RACK_FACE_FRONT,
+            face=DeviceFaceChoices.FACE_FRONT,
         )
         device1.save()
 
@@ -117,7 +117,7 @@ class RackTestCase(TestCase):
             site=self.site1,
             rack=self.rack,
             position=10,
-            face=RACK_FACE_REAR,
+            face=DeviceFaceChoices.FACE_REAR,
         )
         device1.save()
 
@@ -146,7 +146,7 @@ class RackTestCase(TestCase):
             site=self.site1,
             rack=self.rack,
             position=None,
-            face=None,
+            face='',
         )
         self.assertTrue(pdu)
 
@@ -187,20 +187,20 @@ class DeviceTestCase(TestCase):
             device_type=self.device_type,
             name='Power Outlet 1',
             power_port=ppt,
-            feed_leg=POWERFEED_LEG_A
+            feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
         ).save()
 
         InterfaceTemplate(
             device_type=self.device_type,
             name='Interface 1',
-            type=IFACE_TYPE_1GE_FIXED,
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
             mgmt_only=True
         ).save()
 
         rpt = RearPortTemplate(
             device_type=self.device_type,
             name='Rear Port 1',
-            type=PORT_TYPE_8P8C,
+            type=PortTypeChoices.TYPE_8P8C,
             positions=8
         )
         rpt.save()
@@ -208,7 +208,7 @@ class DeviceTestCase(TestCase):
         FrontPortTemplate(
             device_type=self.device_type,
             name='Front Port 1',
-            type=PORT_TYPE_8P8C,
+            type=PortTypeChoices.TYPE_8P8C,
             rear_port=rpt,
             rear_port_position=2
         ).save()
@@ -251,27 +251,27 @@ class DeviceTestCase(TestCase):
             device=d,
             name='Power Outlet 1',
             power_port=pp,
-            feed_leg=POWERFEED_LEG_A
+            feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
         )
 
         Interface.objects.get(
             device=d,
             name='Interface 1',
-            type=IFACE_TYPE_1GE_FIXED,
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
             mgmt_only=True
         )
 
         rp = RearPort.objects.get(
             device=d,
             name='Rear Port 1',
-            type=PORT_TYPE_8P8C,
+            type=PortTypeChoices.TYPE_8P8C,
             positions=8
         )
 
         FrontPort.objects.get(
             device=d,
             name='Front Port 1',
-            type=PORT_TYPE_8P8C,
+            type=PortTypeChoices.TYPE_8P8C,
             rear_port=rp,
             rear_port_position=2
         )
@@ -379,7 +379,7 @@ class CableTestCase(TestCase):
         """
         A cable cannot terminate to a virtual interface
         """
-        virtual_interface = Interface(device=self.device1, name="V1", type=IFACE_TYPE_VIRTUAL)
+        virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
         cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
         with self.assertRaises(ValidationError):
             cable.clean()
@@ -388,7 +388,7 @@ class CableTestCase(TestCase):
         """
         A cable cannot terminate to a wireless interface
         """
-        wireless_interface = Interface(device=self.device1, name="W1", type=IFACE_TYPE_80211A)
+        wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
         cable = Cable(termination_a=self.interface2, termination_b=wireless_interface)
         with self.assertRaises(ValidationError):
             cable.clean()
@@ -421,16 +421,16 @@ class CablePathTestCase(TestCase):
             device_type=devicetype, device_role=devicerole, name='Test Panel 2', site=site
         )
         self.rear_port1 = RearPort.objects.create(
-            device=self.panel1, name='Rear Port 1', type=PORT_TYPE_8P8C
+            device=self.panel1, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C
         )
         self.front_port1 = FrontPort.objects.create(
-            device=self.panel1, name='Front Port 1', type=PORT_TYPE_8P8C, rear_port=self.rear_port1
+            device=self.panel1, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=self.rear_port1
         )
         self.rear_port2 = RearPort.objects.create(
-            device=self.panel2, name='Rear Port 2', type=PORT_TYPE_8P8C
+            device=self.panel2, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C
         )
         self.front_port2 = FrontPort.objects.create(
-            device=self.panel2, name='Front Port 2', type=PORT_TYPE_8P8C, rear_port=self.rear_port2
+            device=self.panel2, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=self.rear_port2
         )
 
     def test_path_completion(self):

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

@@ -255,15 +255,15 @@ power-outlets:
   - name: Power Outlet 1
     type: iec-60320-c13
     power_port: Power Port 1
-    feed_leg: 1
+    feed_leg: A
   - name: Power Outlet 2
     type: iec-60320-c13
     power_port: Power Port 1
-    feed_leg: 1
+    feed_leg: A
   - name: Power Outlet 3
     type: iec-60320-c13
     power_port: Power Port 1
-    feed_leg: 1
+    feed_leg: A
 interfaces:
   - name: Interface 1
     type: 1000base-t
@@ -326,29 +326,29 @@ device-bays:
         self.assertEqual(dt.consoleport_templates.count(), 3)
         cp1 = ConsolePortTemplate.objects.first()
         self.assertEqual(cp1.name, 'Console Port 1')
-        self.assertEqual(cp1.type, ConsolePortTypes.TYPE_DE9)
+        self.assertEqual(cp1.type, ConsolePortTypeChoices.TYPE_DE9)
 
         self.assertEqual(dt.consoleserverport_templates.count(), 3)
         csp1 = ConsoleServerPortTemplate.objects.first()
         self.assertEqual(csp1.name, 'Console Server Port 1')
-        self.assertEqual(csp1.type, ConsolePortTypes.TYPE_RJ45)
+        self.assertEqual(csp1.type, ConsolePortTypeChoices.TYPE_RJ45)
 
         self.assertEqual(dt.powerport_templates.count(), 3)
         pp1 = PowerPortTemplate.objects.first()
         self.assertEqual(pp1.name, 'Power Port 1')
-        self.assertEqual(pp1.type, PowerPortTypes.TYPE_IEC_C14)
+        self.assertEqual(pp1.type, PowerPortTypeChoices.TYPE_IEC_C14)
 
         self.assertEqual(dt.poweroutlet_templates.count(), 3)
         po1 = PowerOutletTemplate.objects.first()
         self.assertEqual(po1.name, 'Power Outlet 1')
-        self.assertEqual(po1.type, PowerOutletTypes.TYPE_IEC_C13)
+        self.assertEqual(po1.type, PowerOutletTypeChoices.TYPE_IEC_C13)
         self.assertEqual(po1.power_port, pp1)
-        self.assertEqual(po1.feed_leg, POWERFEED_LEG_A)
+        self.assertEqual(po1.feed_leg, PowerOutletFeedLegChoices.FEED_LEG_A)
 
         self.assertEqual(dt.interface_templates.count(), 3)
         iface1 = InterfaceTemplate.objects.first()
         self.assertEqual(iface1.name, 'Interface 1')
-        self.assertEqual(iface1.type, IFACE_TYPE_1GE_FIXED)
+        self.assertEqual(iface1.type, InterfaceTypeChoices.TYPE_1GE_FIXED)
         self.assertTrue(iface1.mgmt_only)
 
         self.assertEqual(dt.rearport_templates.count(), 3)
@@ -514,28 +514,28 @@ class CableTestCase(TestCase):
         device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole)
         device2.save()
 
-        iface1 = Interface(device=device1, name='Interface 1', type=IFACE_TYPE_1GE_FIXED)
+        iface1 = Interface(device=device1, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         iface1.save()
-        iface2 = Interface(device=device1, name='Interface 2', type=IFACE_TYPE_1GE_FIXED)
+        iface2 = Interface(device=device1, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         iface2.save()
-        iface3 = Interface(device=device1, name='Interface 3', type=IFACE_TYPE_1GE_FIXED)
+        iface3 = Interface(device=device1, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         iface3.save()
-        iface4 = Interface(device=device2, name='Interface 1', type=IFACE_TYPE_1GE_FIXED)
+        iface4 = Interface(device=device2, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         iface4.save()
-        iface5 = Interface(device=device2, name='Interface 2', type=IFACE_TYPE_1GE_FIXED)
+        iface5 = Interface(device=device2, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         iface5.save()
-        iface6 = Interface(device=device2, name='Interface 3', type=IFACE_TYPE_1GE_FIXED)
+        iface6 = Interface(device=device2, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
         iface6.save()
 
-        Cable(termination_a=iface1, termination_b=iface4, type=CABLE_TYPE_CAT6).save()
-        Cable(termination_a=iface2, termination_b=iface5, type=CABLE_TYPE_CAT6).save()
-        Cable(termination_a=iface3, termination_b=iface6, type=CABLE_TYPE_CAT6).save()
+        Cable(termination_a=iface1, termination_b=iface4, type=CableTypeChoices.TYPE_CAT6).save()
+        Cable(termination_a=iface2, termination_b=iface5, type=CableTypeChoices.TYPE_CAT6).save()
+        Cable(termination_a=iface3, termination_b=iface6, type=CableTypeChoices.TYPE_CAT6).save()
 
     def test_cable_list(self):
 
         url = reverse('dcim:cable_list')
         params = {
-            "type": CABLE_TYPE_CAT6,
+            "type": CableTypeChoices.TYPE_CAT6,
         }
 
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))

+ 6 - 6
netbox/extras/api/customfields.py

@@ -5,7 +5,7 @@ from django.db import transaction
 from rest_framework import serializers
 from rest_framework.exceptions import ValidationError
 
-from extras.constants import *
+from extras.choices import *
 from extras.models import CustomField, CustomFieldChoice, CustomFieldValue
 from utilities.api import ValidatedModelSerializer
 
@@ -37,7 +37,7 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
             if value not in [None, '']:
 
                 # Validate integer
-                if cf.type == CF_TYPE_INTEGER:
+                if cf.type == CustomFieldTypeChoices.TYPE_INTEGER:
                     try:
                         int(value)
                     except ValueError:
@@ -46,13 +46,13 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
                         )
 
                 # Validate boolean
-                if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]:
+                if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
                     raise ValidationError(
                         "Invalid value for boolean field {}: {}".format(field_name, value)
                     )
 
                 # Validate date
-                if cf.type == CF_TYPE_DATE:
+                if cf.type == CustomFieldTypeChoices.TYPE_DATE:
                     try:
                         datetime.strptime(value, '%Y-%m-%d')
                     except ValueError:
@@ -61,7 +61,7 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
                         )
 
                 # Validate selected choice
-                if cf.type == CF_TYPE_SELECT:
+                if cf.type == CustomFieldTypeChoices.TYPE_SELECT:
                     try:
                         value = int(value)
                     except ValueError:
@@ -100,7 +100,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
             instance.custom_fields = {}
             for field in fields:
                 value = instance.cf.get(field.name)
-                if field.type == CF_TYPE_SELECT and value is not None:
+                if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None:
                     instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
                 else:
                     instance.custom_fields[field.name] = value

+ 4 - 3
netbox/extras/api/serializers.py

@@ -8,6 +8,7 @@ from dcim.api.nested_serializers import (
     NestedRegionSerializer, NestedSiteSerializer,
 )
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
+from extras.choices import *
 from extras.constants import *
 from extras.models import (
     ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
@@ -56,8 +57,8 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
 
 class ExportTemplateSerializer(ValidatedModelSerializer):
     template_language = ChoiceField(
-        choices=TEMPLATE_LANGUAGE_CHOICES,
-        default=TEMPLATE_LANGUAGE_JINJA2
+        choices=ExportTemplateLanguageChoices,
+        default=ExportTemplateLanguageChoices.LANGUAGE_JINJA2
     )
 
     class Meta:
@@ -255,7 +256,7 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
         read_only=True
     )
     action = ChoiceField(
-        choices=OBJECTCHANGE_ACTION_CHOICES,
+        choices=ObjectChangeActionChoices,
         read_only=True
     )
     changed_object_type = ContentTypeField(

+ 140 - 0
netbox/extras/choices.py

@@ -0,0 +1,140 @@
+from utilities.choices import ChoiceSet
+
+
+#
+# CustomFields
+#
+
+class CustomFieldTypeChoices(ChoiceSet):
+
+    TYPE_TEXT = 'text'
+    TYPE_INTEGER = 'integer'
+    TYPE_BOOLEAN = 'boolean'
+    TYPE_DATE = 'date'
+    TYPE_URL = 'url'
+    TYPE_SELECT = 'select'
+
+    CHOICES = (
+        (TYPE_TEXT, 'Text'),
+        (TYPE_INTEGER, 'Integer'),
+        (TYPE_BOOLEAN, 'Boolean (true/false)'),
+        (TYPE_DATE, 'Date'),
+        (TYPE_URL, 'URL'),
+        (TYPE_SELECT, 'Selection'),
+    )
+
+    LEGACY_MAP = {
+        TYPE_TEXT: 100,
+        TYPE_INTEGER: 200,
+        TYPE_BOOLEAN: 300,
+        TYPE_DATE: 400,
+        TYPE_URL: 500,
+        TYPE_SELECT: 600,
+    }
+
+
+class CustomFieldFilterLogicChoices(ChoiceSet):
+
+    FILTER_DISABLED = 'disabled'
+    FILTER_LOOSE = 'loose'
+    FILTER_EXACT = 'exact'
+
+    CHOICES = (
+        (FILTER_DISABLED, 'Disabled'),
+        (FILTER_LOOSE, 'Loose'),
+        (FILTER_EXACT, 'Exact'),
+    )
+
+    LEGACY_MAP = {
+        FILTER_DISABLED: 0,
+        FILTER_LOOSE: 1,
+        FILTER_EXACT: 2,
+    }
+
+
+#
+# CustomLinks
+#
+
+class CustomLinkButtonClassChoices(ChoiceSet):
+
+    CLASS_DEFAULT = 'default'
+    CLASS_PRIMARY = 'primary'
+    CLASS_SUCCESS = 'success'
+    CLASS_INFO = 'info'
+    CLASS_WARNING = 'warning'
+    CLASS_DANGER = 'danger'
+    CLASS_LINK = 'link'
+
+    CHOICES = (
+        (CLASS_DEFAULT, 'Default'),
+        (CLASS_PRIMARY, 'Primary (blue)'),
+        (CLASS_SUCCESS, 'Success (green)'),
+        (CLASS_INFO, 'Info (aqua)'),
+        (CLASS_WARNING, 'Warning (orange)'),
+        (CLASS_DANGER, 'Danger (red)'),
+        (CLASS_LINK, 'None (link)'),
+    )
+
+
+#
+# ObjectChanges
+#
+
+class ObjectChangeActionChoices(ChoiceSet):
+
+    ACTION_CREATE = 'create'
+    ACTION_UPDATE = 'update'
+    ACTION_DELETE = 'delete'
+
+    CHOICES = (
+        (ACTION_CREATE, 'Created'),
+        (ACTION_UPDATE, 'Updated'),
+        (ACTION_DELETE, 'Deleted'),
+    )
+
+    LEGACY_MAP = {
+        ACTION_CREATE: 1,
+        ACTION_UPDATE: 2,
+        ACTION_DELETE: 3,
+    }
+
+
+#
+# ExportTemplates
+#
+
+class ExportTemplateLanguageChoices(ChoiceSet):
+
+    LANGUAGE_DJANGO = 'django'
+    LANGUAGE_JINJA2 = 'jinja2'
+
+    CHOICES = (
+        (LANGUAGE_DJANGO, 'Django'),
+        (LANGUAGE_JINJA2, 'Jinja2'),
+    )
+
+    LEGACY_MAP = {
+        LANGUAGE_DJANGO: 10,
+        LANGUAGE_JINJA2: 20,
+    }
+
+
+#
+# Webhooks
+#
+
+class WebhookContentTypeChoices(ChoiceSet):
+
+    CONTENTTYPE_JSON = 'application/json'
+    CONTENTTYPE_FORMDATA = 'application/x-www-form-urlencoded'
+
+    CHOICES = (
+        (CONTENTTYPE_JSON, 'JSON'),
+        (CONTENTTYPE_FORMDATA, 'Form data'),
+    )
+
+    LEGACY_MAP = {
+        CONTENTTYPE_JSON: 1,
+        CONTENTTYPE_FORMDATA: 2,
+    }

+ 0 - 87
netbox/extras/constants.py

@@ -19,32 +19,6 @@ CUSTOMFIELD_MODELS = [
     'virtualization.virtualmachine',
 ]
 
-# Custom field types
-CF_TYPE_TEXT = 100
-CF_TYPE_INTEGER = 200
-CF_TYPE_BOOLEAN = 300
-CF_TYPE_DATE = 400
-CF_TYPE_URL = 500
-CF_TYPE_SELECT = 600
-CUSTOMFIELD_TYPE_CHOICES = (
-    (CF_TYPE_TEXT, 'Text'),
-    (CF_TYPE_INTEGER, 'Integer'),
-    (CF_TYPE_BOOLEAN, 'Boolean (true/false)'),
-    (CF_TYPE_DATE, 'Date'),
-    (CF_TYPE_URL, 'URL'),
-    (CF_TYPE_SELECT, 'Selection'),
-)
-
-# Custom field filter logic choices
-CF_FILTER_DISABLED = 0
-CF_FILTER_LOOSE = 1
-CF_FILTER_EXACT = 2
-CF_FILTER_CHOICES = (
-    (CF_FILTER_DISABLED, 'Disabled'),
-    (CF_FILTER_LOOSE, 'Loose'),
-    (CF_FILTER_EXACT, 'Exact'),
-)
-
 # Custom links
 CUSTOMLINK_MODELS = [
     'circuits.circuit',
@@ -68,23 +42,6 @@ CUSTOMLINK_MODELS = [
     'virtualization.virtualmachine',
 ]
 
-BUTTON_CLASS_DEFAULT = 'default'
-BUTTON_CLASS_PRIMARY = 'primary'
-BUTTON_CLASS_SUCCESS = 'success'
-BUTTON_CLASS_INFO = 'info'
-BUTTON_CLASS_WARNING = 'warning'
-BUTTON_CLASS_DANGER = 'danger'
-BUTTON_CLASS_LINK = 'link'
-BUTTON_CLASS_CHOICES = (
-    (BUTTON_CLASS_DEFAULT, 'Default'),
-    (BUTTON_CLASS_PRIMARY, 'Primary (blue)'),
-    (BUTTON_CLASS_SUCCESS, 'Success (green)'),
-    (BUTTON_CLASS_INFO, 'Info (aqua)'),
-    (BUTTON_CLASS_WARNING, 'Warning (orange)'),
-    (BUTTON_CLASS_DANGER, 'Danger (red)'),
-    (BUTTON_CLASS_LINK, 'None (link)'),
-)
-
 # Graph types
 GRAPH_TYPE_INTERFACE = 100
 GRAPH_TYPE_DEVICE = 150
@@ -128,42 +85,6 @@ EXPORTTEMPLATE_MODELS = [
     'virtualization.virtualmachine',
 ]
 
-# ExportTemplate language choices
-TEMPLATE_LANGUAGE_DJANGO = 10
-TEMPLATE_LANGUAGE_JINJA2 = 20
-TEMPLATE_LANGUAGE_CHOICES = (
-    (TEMPLATE_LANGUAGE_DJANGO, 'Django'),
-    (TEMPLATE_LANGUAGE_JINJA2, 'Jinja2'),
-)
-
-# Change log actions
-OBJECTCHANGE_ACTION_CREATE = 1
-OBJECTCHANGE_ACTION_UPDATE = 2
-OBJECTCHANGE_ACTION_DELETE = 3
-OBJECTCHANGE_ACTION_CHOICES = (
-    (OBJECTCHANGE_ACTION_CREATE, 'Created'),
-    (OBJECTCHANGE_ACTION_UPDATE, 'Updated'),
-    (OBJECTCHANGE_ACTION_DELETE, 'Deleted'),
-)
-
-# User action types
-ACTION_CREATE = 1
-ACTION_IMPORT = 2
-ACTION_EDIT = 3
-ACTION_BULK_EDIT = 4
-ACTION_DELETE = 5
-ACTION_BULK_DELETE = 6
-ACTION_BULK_CREATE = 7
-ACTION_CHOICES = (
-    (ACTION_CREATE, 'created'),
-    (ACTION_BULK_CREATE, 'bulk created'),
-    (ACTION_IMPORT, 'imported'),
-    (ACTION_EDIT, 'modified'),
-    (ACTION_BULK_EDIT, 'bulk edited'),
-    (ACTION_DELETE, 'deleted'),
-    (ACTION_BULK_DELETE, 'bulk deleted'),
-)
-
 # Report logging levels
 LOG_DEFAULT = 0
 LOG_SUCCESS = 10
@@ -178,14 +99,6 @@ LOG_LEVEL_CODES = {
     LOG_FAILURE: 'failure',
 }
 
-# webhook content types
-WEBHOOK_CT_JSON = 1
-WEBHOOK_CT_X_WWW_FORM_ENCODED = 2
-WEBHOOK_CT_CHOICES = (
-    (WEBHOOK_CT_JSON, 'application/json'),
-    (WEBHOOK_CT_X_WWW_FORM_ENCODED, 'application/x-www-form-urlencoded'),
-)
-
 # Models which support registered webhooks
 WEBHOOK_MODELS = [
     'circuits.circuit',

+ 9 - 3
netbox/extras/filters.py

@@ -4,6 +4,7 @@ from django.db.models import Q
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
+from .choices import *
 from .constants import *
 from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
 
@@ -25,7 +26,7 @@ class CustomFieldFilter(django_filters.Filter):
             return queryset
 
         # Selection fields get special treatment (values must be integers)
-        if self.cf_type == CF_TYPE_SELECT:
+        if self.cf_type == CustomFieldTypeChoices.TYPE_SELECT:
             try:
                 # Treat 0 as None
                 if int(value) == 0:
@@ -42,7 +43,8 @@ class CustomFieldFilter(django_filters.Filter):
                 return queryset.none()
 
         # Apply the assigned filter logic (exact or loose)
-        if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
+        if (self.cf_type == CustomFieldTypeChoices.TYPE_BOOLEAN or
+                self.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT):
             queryset = queryset.filter(
                 custom_field_values__field__name=self.field_name,
                 custom_field_values__serialized_value=value
@@ -65,7 +67,11 @@ class CustomFieldFilterSet(django_filters.FilterSet):
         super().__init__(*args, **kwargs)
 
         obj_type = ContentType.objects.get_for_model(self._meta.model)
-        custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED)
+        custom_fields = CustomField.objects.filter(
+            obj_type=obj_type
+        ).exclude(
+            filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
+        )
         for cf in custom_fields:
             self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf)
 

+ 8 - 7
netbox/extras/forms.py

@@ -13,6 +13,7 @@ from utilities.forms import (
     CommentField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, StaticSelect2,
     BOOLEAN_WITH_BLANK_CHOICES,
 )
+from .choices import *
 from .constants import *
 from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
 
@@ -28,18 +29,18 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
     field_dict = OrderedDict()
     custom_fields = CustomField.objects.filter(obj_type=content_type)
     if filterable_only:
-        custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED)
+        custom_fields = custom_fields.exclude(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED)
 
     for cf in custom_fields:
         field_name = 'cf_{}'.format(str(cf.name))
         initial = cf.default if not bulk_edit else None
 
         # Integer
-        if cf.type == CF_TYPE_INTEGER:
+        if cf.type == CustomFieldTypeChoices.TYPE_INTEGER:
             field = forms.IntegerField(required=cf.required, initial=initial)
 
         # Boolean
-        elif cf.type == CF_TYPE_BOOLEAN:
+        elif cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
             choices = (
                 (None, '---------'),
                 (1, 'True'),
@@ -56,11 +57,11 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
             )
 
         # Date
-        elif cf.type == CF_TYPE_DATE:
+        elif cf.type == CustomFieldTypeChoices.TYPE_DATE:
             field = forms.DateField(required=cf.required, initial=initial, help_text="Date format: YYYY-MM-DD")
 
         # Select
-        elif cf.type == CF_TYPE_SELECT:
+        elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
             choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
             if not cf.required or bulk_edit or filterable_only:
                 choices = [(None, '---------')] + choices
@@ -74,7 +75,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
             field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice)
 
         # URL
-        elif cf.type == CF_TYPE_URL:
+        elif cf.type == CustomFieldTypeChoices.TYPE_URL:
             field = LaxURLField(required=cf.required, initial=initial)
 
         # Text
@@ -400,7 +401,7 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
         )
     )
     action = forms.ChoiceField(
-        choices=add_blank_choice(OBJECTCHANGE_ACTION_CHOICES),
+        choices=add_blank_choice(ObjectChangeActionChoices),
         required=False
     )
     user = forms.ModelChoiceField(

+ 7 - 7
netbox/extras/middleware.py

@@ -10,7 +10,7 @@ from django.utils import timezone
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 
 from utilities.querysets import DummyQuerySet
-from .constants import *
+from .choices import ObjectChangeActionChoices
 from .models import ObjectChange
 from .signals import purge_changelog
 from .webhooks import enqueue_webhooks
@@ -23,7 +23,7 @@ def handle_changed_object(sender, instance, **kwargs):
     Fires when an object is created or updated.
     """
     # Queue the object for processing once the request completes
-    action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
+    action = ObjectChangeActionChoices.ACTION_CREATE if kwargs['created'] else ObjectChangeActionChoices.ACTION_UPDATE
     _thread_locals.changed_objects.append(
         (instance, action)
     )
@@ -46,7 +46,7 @@ def handle_deleted_object(sender, instance, **kwargs):
 
     # Queue the copy of the object for processing once the request completes
     _thread_locals.changed_objects.append(
-        (copy, OBJECTCHANGE_ACTION_DELETE)
+        (copy, ObjectChangeActionChoices.ACTION_DELETE)
     )
 
 
@@ -101,7 +101,7 @@ class ObjectChangeMiddleware(object):
         for instance, action in _thread_locals.changed_objects:
 
             # Refresh cached custom field values
-            if action in [OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_UPDATE]:
+            if action in [ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE]:
                 if hasattr(instance, 'cache_custom_fields'):
                     instance.cache_custom_fields()
 
@@ -116,11 +116,11 @@ class ObjectChangeMiddleware(object):
             enqueue_webhooks(instance, request.user, request.id, action)
 
             # Increment metric counters
-            if action == OBJECTCHANGE_ACTION_CREATE:
+            if action == ObjectChangeActionChoices.ACTION_CREATE:
                 model_inserts.labels(instance._meta.model_name).inc()
-            elif action == OBJECTCHANGE_ACTION_UPDATE:
+            elif action == ObjectChangeActionChoices.ACTION_UPDATE:
                 model_updates.labels(instance._meta.model_name).inc()
-            elif action == OBJECTCHANGE_ACTION_DELETE:
+            elif action == ObjectChangeActionChoices.ACTION_DELETE:
                 model_deletes.labels(instance._meta.model_name).inc()
 
         # Housekeeping: 1% chance of clearing out expired ObjectChanges. This applies only to requests which result in

+ 0 - 2
netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py

@@ -8,8 +8,6 @@ import django.db.models.deletion
 import extras.models
 from django.db.utils import OperationalError
 
-from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT
-
 
 def verify_postgresql_version(apps, schema_editor):
     """

+ 5 - 7
netbox/extras/migrations/0010_customfield_filter_logic.py

@@ -2,21 +2,19 @@
 # Generated by Django 1.11.9 on 2018-02-21 19:48
 from django.db import migrations, models
 
-from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT
-
 
 def is_filterable_to_filter_logic(apps, schema_editor):
     CustomField = apps.get_model('extras', 'CustomField')
-    CustomField.objects.filter(is_filterable=False).update(filter_logic=CF_FILTER_DISABLED)
-    CustomField.objects.filter(is_filterable=True).update(filter_logic=CF_FILTER_LOOSE)
+    CustomField.objects.filter(is_filterable=False).update(filter_logic=0)
+    CustomField.objects.filter(is_filterable=True).update(filter_logic=1)
     # Select fields match on primary key only
-    CustomField.objects.filter(is_filterable=True, type=CF_TYPE_SELECT).update(filter_logic=CF_FILTER_EXACT)
+    CustomField.objects.filter(is_filterable=True, type=600).update(filter_logic=2)
 
 
 def filter_logic_to_is_filterable(apps, schema_editor):
     CustomField = apps.get_model('extras', 'CustomField')
-    CustomField.objects.filter(filter_logic=CF_FILTER_DISABLED).update(is_filterable=False)
-    CustomField.objects.exclude(filter_logic=CF_FILTER_DISABLED).update(is_filterable=True)
+    CustomField.objects.filter(filter_logic=0).update(is_filterable=False)
+    CustomField.objects.exclude(filter_logic=0).update(is_filterable=True)
 
 
 class Migration(migrations.Migration):

+ 69 - 0
netbox/extras/migrations/0029_3569_customfield_fields.py

@@ -0,0 +1,69 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+CUSTOMFIELD_TYPE_CHOICES = (
+    (100, 'text'),
+    (200, 'integer'),
+    (300, 'boolean'),
+    (400, 'date'),
+    (500, 'url'),
+    (600, 'select')
+)
+
+CUSTOMFIELD_FILTER_LOGIC_CHOICES = (
+    (0, 'disabled'),
+    (1, 'integer'),
+    (2, 'exact'),
+)
+
+
+def customfield_type_to_slug(apps, schema_editor):
+    CustomField = apps.get_model('extras', 'CustomField')
+    for id, slug in CUSTOMFIELD_TYPE_CHOICES:
+        CustomField.objects.filter(type=str(id)).update(type=slug)
+
+
+def customfield_filter_logic_to_slug(apps, schema_editor):
+    CustomField = apps.get_model('extras', 'CustomField')
+    for id, slug in CUSTOMFIELD_FILTER_LOGIC_CHOICES:
+        CustomField.objects.filter(filter_logic=str(id)).update(filter_logic=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('extras', '0028_remove_topology_maps'),
+    ]
+
+    operations = [
+
+        # CustomField.type
+        migrations.AlterField(
+            model_name='customfield',
+            name='type',
+            field=models.CharField(default='text', max_length=50),
+        ),
+        migrations.RunPython(
+            code=customfield_type_to_slug
+        ),
+
+        # Update CustomFieldChoice.field.limit_choices_to
+        migrations.AlterField(
+            model_name='customfieldchoice',
+            name='field',
+            field=models.ForeignKey(limit_choices_to={'type': 'select'}, on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='extras.CustomField'),
+        ),
+
+        # CustomField.filter_logic
+        migrations.AlterField(
+            model_name='customfield',
+            name='filter_logic',
+            field=models.CharField(default='loose', max_length=50),
+        ),
+        migrations.RunPython(
+            code=customfield_filter_logic_to_slug
+        ),
+
+    ]

+ 36 - 0
netbox/extras/migrations/0030_3569_objectchange_fields.py

@@ -0,0 +1,36 @@
+from django.db import migrations, models
+
+
+OBJECTCHANGE_ACTION_CHOICES = (
+    (1, 'create'),
+    (2, 'update'),
+    (3, 'delete'),
+)
+
+
+def objectchange_action_to_slug(apps, schema_editor):
+    ObjectChange = apps.get_model('extras', 'ObjectChange')
+    for id, slug in OBJECTCHANGE_ACTION_CHOICES:
+        ObjectChange.objects.filter(action=str(id)).update(action=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('extras', '0029_3569_customfield_fields'),
+    ]
+
+    operations = [
+
+        # ObjectChange.action
+        migrations.AlterField(
+            model_name='objectchange',
+            name='action',
+            field=models.CharField(max_length=50),
+        ),
+        migrations.RunPython(
+            code=objectchange_action_to_slug
+        ),
+
+    ]

+ 35 - 0
netbox/extras/migrations/0031_3569_exporttemplate_fields.py

@@ -0,0 +1,35 @@
+from django.db import migrations, models
+
+
+EXPORTTEMPLATE_LANGUAGE_CHOICES = (
+    (10, 'django'),
+    (20, 'jinja2'),
+)
+
+
+def exporttemplate_language_to_slug(apps, schema_editor):
+    ExportTemplate = apps.get_model('extras', 'ExportTemplate')
+    for id, slug in EXPORTTEMPLATE_LANGUAGE_CHOICES:
+        ExportTemplate.objects.filter(template_language=str(id)).update(template_language=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('extras', '0030_3569_objectchange_fields'),
+    ]
+
+    operations = [
+
+        # ExportTemplate.template_language
+        migrations.AlterField(
+            model_name='exporttemplate',
+            name='template_language',
+            field=models.CharField(default='jinja2', max_length=50),
+        ),
+        migrations.RunPython(
+            code=exporttemplate_language_to_slug
+        ),
+
+    ]

+ 35 - 0
netbox/extras/migrations/0032_3569_webhook_fields.py

@@ -0,0 +1,35 @@
+from django.db import migrations, models
+
+
+WEBHOOK_CONTENTTYPE_CHOICES = (
+    (1, 'application/json'),
+    (2, 'application/x-www-form-urlencoded'),
+)
+
+
+def webhook_contenttype_to_slug(apps, schema_editor):
+    Webhook = apps.get_model('extras', 'Webhook')
+    for id, slug in WEBHOOK_CONTENTTYPE_CHOICES:
+        Webhook.objects.filter(http_content_type=str(id)).update(http_content_type=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('extras', '0031_3569_exporttemplate_fields'),
+    ]
+
+    operations = [
+
+        # Webhook.http_content_type
+        migrations.AlterField(
+            model_name='webhook',
+            name='http_content_type',
+            field=models.CharField(default='application/json', max_length=50),
+        ),
+        migrations.RunPython(
+            code=webhook_contenttype_to_slug
+        ),
+
+    ]

+ 35 - 26
netbox/extras/models.py

@@ -15,6 +15,7 @@ from taggit.models import TagBase, GenericTaggedItemBase
 
 from utilities.fields import ColorField
 from utilities.utils import deepmerge, model_names_to_filter_dict
+from .choices import *
 from .constants import *
 from .querysets import ConfigContextQuerySet
 
@@ -62,9 +63,10 @@ class Webhook(models.Model):
         verbose_name='URL',
         help_text="A POST will be sent to this URL when the webhook is called."
     )
-    http_content_type = models.PositiveSmallIntegerField(
-        choices=WEBHOOK_CT_CHOICES,
-        default=WEBHOOK_CT_JSON,
+    http_content_type = models.CharField(
+        max_length=50,
+        choices=WebhookContentTypeChoices,
+        default=WebhookContentTypeChoices.CONTENTTYPE_JSON,
         verbose_name='HTTP content type'
     )
     additional_headers = JSONField(
@@ -182,9 +184,10 @@ class CustomField(models.Model):
         limit_choices_to=get_custom_field_models,
         help_text='The object(s) to which this field applies.'
     )
-    type = models.PositiveSmallIntegerField(
-        choices=CUSTOMFIELD_TYPE_CHOICES,
-        default=CF_TYPE_TEXT
+    type = models.CharField(
+        max_length=50,
+        choices=CustomFieldTypeChoices,
+        default=CustomFieldTypeChoices.TYPE_TEXT
     )
     name = models.CharField(
         max_length=50,
@@ -205,9 +208,10 @@ class CustomField(models.Model):
         help_text='If true, this field is required when creating new objects '
                   'or editing an existing object.'
     )
-    filter_logic = models.PositiveSmallIntegerField(
-        choices=CF_FILTER_CHOICES,
-        default=CF_FILTER_LOOSE,
+    filter_logic = models.CharField(
+        max_length=50,
+        choices=CustomFieldFilterLogicChoices,
+        default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
         help_text='Loose matches any instance of a given string; exact '
                   'matches the entire field.'
     )
@@ -233,15 +237,15 @@ class CustomField(models.Model):
         """
         if value is None:
             return ''
-        if self.type == CF_TYPE_BOOLEAN:
+        if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
             return str(int(bool(value)))
-        if self.type == CF_TYPE_DATE:
+        if self.type == CustomFieldTypeChoices.TYPE_DATE:
             # Could be date/datetime object or string
             try:
                 return value.strftime('%Y-%m-%d')
             except AttributeError:
                 return value
-        if self.type == CF_TYPE_SELECT:
+        if self.type == CustomFieldTypeChoices.TYPE_SELECT:
             # Could be ModelChoiceField or TypedChoiceField
             return str(value.id) if hasattr(value, 'id') else str(value)
         return value
@@ -252,14 +256,14 @@ class CustomField(models.Model):
         """
         if serialized_value == '':
             return None
-        if self.type == CF_TYPE_INTEGER:
+        if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
             return int(serialized_value)
-        if self.type == CF_TYPE_BOOLEAN:
+        if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
             return bool(int(serialized_value))
-        if self.type == CF_TYPE_DATE:
+        if self.type == CustomFieldTypeChoices.TYPE_DATE:
             # Read date as YYYY-MM-DD
             return date(*[int(n) for n in serialized_value.split('-')])
-        if self.type == CF_TYPE_SELECT:
+        if self.type == CustomFieldTypeChoices.TYPE_SELECT:
             return self.choices.get(pk=int(serialized_value))
         return serialized_value
 
@@ -312,7 +316,7 @@ class CustomFieldChoice(models.Model):
         to='extras.CustomField',
         on_delete=models.CASCADE,
         related_name='choices',
-        limit_choices_to={'type': CF_TYPE_SELECT}
+        limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT}
     )
     value = models.CharField(
         max_length=100
@@ -330,14 +334,17 @@ class CustomFieldChoice(models.Model):
         return self.value
 
     def clean(self):
-        if self.field.type != CF_TYPE_SELECT:
+        if self.field.type != CustomFieldTypeChoices.TYPE_SELECT:
             raise ValidationError("Custom field choices can only be assigned to selection fields.")
 
     def delete(self, using=None, keep_parents=False):
         # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
         pk = self.pk
         super().delete(using, keep_parents)
-        CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
+        CustomFieldValue.objects.filter(
+            field__type=CustomFieldTypeChoices.TYPE_SELECT,
+            serialized_value=str(pk)
+        ).delete()
 
 
 #
@@ -381,8 +388,8 @@ class CustomLink(models.Model):
     )
     button_class = models.CharField(
         max_length=30,
-        choices=BUTTON_CLASS_CHOICES,
-        default=BUTTON_CLASS_DEFAULT,
+        choices=CustomLinkButtonClassChoices,
+        default=CustomLinkButtonClassChoices.CLASS_DEFAULT,
         help_text="The class of the first link in a group will be used for the dropdown button"
     )
     new_window = models.BooleanField(
@@ -458,9 +465,10 @@ class ExportTemplate(models.Model):
         max_length=200,
         blank=True
     )
-    template_language = models.PositiveSmallIntegerField(
-        choices=TEMPLATE_LANGUAGE_CHOICES,
-        default=TEMPLATE_LANGUAGE_JINJA2
+    template_language = models.CharField(
+        max_length=50,
+        choices=ExportTemplateLanguageChoices,
+        default=ExportTemplateLanguageChoices.LANGUAGE_JINJA2
     )
     template_code = models.TextField(
         help_text='The list of objects being exported is passed as a context variable named <code>queryset</code>.'
@@ -796,8 +804,9 @@ class ObjectChange(models.Model):
     request_id = models.UUIDField(
         editable=False
     )
-    action = models.PositiveSmallIntegerField(
-        choices=OBJECTCHANGE_ACTION_CHOICES
+    action = models.CharField(
+        max_length=50,
+        choices=ObjectChangeActionChoices
     )
     changed_object_type = models.ForeignKey(
         to=ContentType,

+ 3 - 3
netbox/extras/tables.py

@@ -38,11 +38,11 @@ OBJECTCHANGE_TIME = """
 """
 
 OBJECTCHANGE_ACTION = """
-{% if record.action == 1 %}
+{% if record.action == 'create' %}
     <span class="label label-success">Created</span>
-{% elif record.action == 2 %}
+{% elif record.action == 'update' %}
     <span class="label label-primary">Updated</span>
-{% elif record.action == 3 %}
+{% elif record.action == 'delete' %}
     <span class="label label-danger">Deleted</span>
 {% endif %}
 """

+ 5 - 4
netbox/extras/tests/test_changelog.py

@@ -3,6 +3,7 @@ from django.urls import reverse
 from rest_framework import status
 
 from dcim.models import Site
+from extras.choices import *
 from extras.constants import *
 from extras.models import CustomField, CustomFieldValue, ObjectChange
 from utilities.testing import APITestCase
@@ -17,7 +18,7 @@ class ChangeLogTest(APITestCase):
         # Create a custom field on the Site model
         ct = ContentType.objects.get_for_model(Site)
         cf = CustomField(
-            type=CF_TYPE_TEXT,
+            type=CustomFieldTypeChoices.TYPE_TEXT,
             name='my_field',
             required=False
         )
@@ -49,7 +50,7 @@ class ChangeLogTest(APITestCase):
             changed_object_id=site.pk
         )
         self.assertEqual(oc.changed_object, site)
-        self.assertEqual(oc.action, OBJECTCHANGE_ACTION_CREATE)
+        self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
         self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
         self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
 
@@ -81,7 +82,7 @@ class ChangeLogTest(APITestCase):
             changed_object_id=site.pk
         )
         self.assertEqual(oc.changed_object, site)
-        self.assertEqual(oc.action, OBJECTCHANGE_ACTION_UPDATE)
+        self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
         self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
         self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
 
@@ -110,6 +111,6 @@ class ChangeLogTest(APITestCase):
         oc = ObjectChange.objects.first()
         self.assertEqual(oc.changed_object, None)
         self.assertEqual(oc.object_repr, site.name)
-        self.assertEqual(oc.action, OBJECTCHANGE_ACTION_DELETE)
+        self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
         self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
         self.assertListEqual(sorted(oc.object_data['tags']), ['bar', 'foo'])

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

@@ -6,7 +6,7 @@ from django.urls import reverse
 from rest_framework import status
 
 from dcim.models import Site
-from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL, CF_TYPE_SELECT
+from extras.choices import *
 from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
 from utilities.testing import APITestCase
 from virtualization.models import VirtualMachine
@@ -25,13 +25,13 @@ class CustomFieldTest(TestCase):
     def test_simple_fields(self):
 
         DATA = (
-            {'field_type': CF_TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''},
-            {'field_type': CF_TYPE_INTEGER, 'field_value': 0, 'empty_value': None},
-            {'field_type': CF_TYPE_INTEGER, 'field_value': 42, 'empty_value': None},
-            {'field_type': CF_TYPE_BOOLEAN, 'field_value': True, 'empty_value': None},
-            {'field_type': CF_TYPE_BOOLEAN, 'field_value': False, 'empty_value': None},
-            {'field_type': CF_TYPE_DATE, 'field_value': date(2016, 6, 23), 'empty_value': None},
-            {'field_type': CF_TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''},
+            {'field_type': CustomFieldTypeChoices.TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''},
+            {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 0, 'empty_value': None},
+            {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 42, 'empty_value': None},
+            {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': True, 'empty_value': None},
+            {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': False, 'empty_value': None},
+            {'field_type': CustomFieldTypeChoices.TYPE_DATE, 'field_value': date(2016, 6, 23), 'empty_value': None},
+            {'field_type': CustomFieldTypeChoices.TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''},
         )
 
         obj_type = ContentType.objects.get_for_model(Site)
@@ -67,7 +67,7 @@ class CustomFieldTest(TestCase):
         obj_type = ContentType.objects.get_for_model(Site)
 
         # Create a custom field
-        cf = CustomField(type=CF_TYPE_SELECT, name='my_field', required=False)
+        cf = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='my_field', required=False)
         cf.save()
         cf.obj_type.set([obj_type])
         cf.save()
@@ -107,37 +107,37 @@ class CustomFieldAPITest(APITestCase):
         content_type = ContentType.objects.get_for_model(Site)
 
         # Text custom field
-        self.cf_text = CustomField(type=CF_TYPE_TEXT, name='magic_word')
+        self.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='magic_word')
         self.cf_text.save()
         self.cf_text.obj_type.set([content_type])
         self.cf_text.save()
 
         # Integer custom field
-        self.cf_integer = CustomField(type=CF_TYPE_INTEGER, name='magic_number')
+        self.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='magic_number')
         self.cf_integer.save()
         self.cf_integer.obj_type.set([content_type])
         self.cf_integer.save()
 
         # Boolean custom field
-        self.cf_boolean = CustomField(type=CF_TYPE_BOOLEAN, name='is_magic')
+        self.cf_boolean = CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='is_magic')
         self.cf_boolean.save()
         self.cf_boolean.obj_type.set([content_type])
         self.cf_boolean.save()
 
         # Date custom field
-        self.cf_date = CustomField(type=CF_TYPE_DATE, name='magic_date')
+        self.cf_date = CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='magic_date')
         self.cf_date.save()
         self.cf_date.obj_type.set([content_type])
         self.cf_date.save()
 
         # URL custom field
-        self.cf_url = CustomField(type=CF_TYPE_URL, name='magic_url')
+        self.cf_url = CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='magic_url')
         self.cf_url.save()
         self.cf_url.obj_type.set([content_type])
         self.cf_url.save()
 
         # Select custom field
-        self.cf_select = CustomField(type=CF_TYPE_SELECT, name='magic_choice')
+        self.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='magic_choice')
         self.cf_select.save()
         self.cf_select.obj_type.set([content_type])
         self.cf_select.save()
@@ -308,8 +308,8 @@ class CustomFieldChoiceAPITest(APITestCase):
 
         vm_content_type = ContentType.objects.get_for_model(VirtualMachine)
 
-        self.cf_1 = CustomField.objects.create(name="cf_1", type=CF_TYPE_SELECT)
-        self.cf_2 = CustomField.objects.create(name="cf_2", type=CF_TYPE_SELECT)
+        self.cf_1 = CustomField.objects.create(name="cf_1", type=CustomFieldTypeChoices.TYPE_SELECT)
+        self.cf_2 = CustomField.objects.create(name="cf_2", type=CustomFieldTypeChoices.TYPE_SELECT)
 
         self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_1", weight=100)
         self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_2", weight=50)

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

@@ -6,7 +6,7 @@ from django.test import Client, TestCase
 from django.urls import reverse
 
 from dcim.models import Site
-from extras.constants import OBJECTCHANGE_ACTION_UPDATE
+from extras.choices import ObjectChangeActionChoices
 from extras.models import ConfigContext, ObjectChange, Tag
 from utilities.testing import create_test_user
 
@@ -83,7 +83,7 @@ class ObjectChangeTestCase(TestCase):
 
         # Create three ObjectChanges
         for i in range(1, 4):
-            oc = site.to_objectchange(action=OBJECTCHANGE_ACTION_UPDATE)
+            oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
             oc.user = user
             oc.request_id = uuid.uuid4()
             oc.save()

+ 4 - 3
netbox/extras/webhooks.py

@@ -5,6 +5,7 @@ from django.contrib.contenttypes.models import ContentType
 
 from extras.models import Webhook
 from utilities.api import get_serializer_for_model
+from .choices import *
 from .constants import *
 
 
@@ -18,9 +19,9 @@ def enqueue_webhooks(instance, user, request_id, action):
 
     # Retrieve any applicable Webhooks
     action_flag = {
-        OBJECTCHANGE_ACTION_CREATE: 'type_create',
-        OBJECTCHANGE_ACTION_UPDATE: 'type_update',
-        OBJECTCHANGE_ACTION_DELETE: 'type_delete',
+        ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
+        ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',
+        ObjectChangeActionChoices.ACTION_DELETE: 'type_delete',
     }[action]
     obj_type = ContentType.objects.get_for_model(instance.__class__)
     webhooks = Webhook.objects.filter(obj_type=obj_type, enabled=True, **{action_flag: True})

+ 2 - 1
netbox/extras/webhooks_worker.py

@@ -6,6 +6,7 @@ import requests
 from django_rq import job
 from rest_framework.utils.encoders import JSONEncoder
 
+from .choices import ObjectChangeActionChoices
 from .constants import *
 
 
@@ -15,7 +16,7 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
     Make a POST request to the defined Webhook
     """
     payload = {
-        'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(),
+        'event': dict(ObjectChangeActionChoices)[event].lower(),
         'timestamp': timestamp,
         'model': model_name,
         'username': username,

+ 7 - 7
netbox/ipam/api/serializers.py

@@ -8,8 +8,8 @@ from taggit_serializer.serializers import TaggitSerializer, TagListSerializerFie
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
 from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
-from ipam.constants import *
-from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
+from ipam.choices import *
+from ipam.models import AF_CHOICES, Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import (
     ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
@@ -102,7 +102,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
     site = NestedSiteSerializer(required=False, allow_null=True)
     group = NestedVLANGroupSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
-    status = ChoiceField(choices=VLAN_STATUS_CHOICES, required=False)
+    status = ChoiceField(choices=VLANStatusChoices, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
     tags = TagListSerializerField(required=False)
     prefix_count = serializers.IntegerField(read_only=True)
@@ -140,7 +140,7 @@ class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     vlan = NestedVLANSerializer(required=False, allow_null=True)
-    status = ChoiceField(choices=PREFIX_STATUS_CHOICES, required=False)
+    status = ChoiceField(choices=PrefixStatusChoices, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
     tags = TagListSerializerField(required=False)
 
@@ -200,8 +200,8 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
     family = ChoiceField(choices=AF_CHOICES, read_only=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
-    status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False)
-    role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False, allow_null=True)
+    status = ChoiceField(choices=IPAddressStatusChoices, required=False)
+    role = ChoiceField(choices=IPAddressRoleChoices, required=False, allow_null=True)
     interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
     nat_outside = NestedIPAddressSerializer(read_only=True)
@@ -239,7 +239,7 @@ class AvailableIPSerializer(serializers.Serializer):
 class ServiceSerializer(CustomFieldModelSerializer):
     device = NestedDeviceSerializer(required=False, allow_null=True)
     virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
-    protocol = ChoiceField(choices=IP_PROTOCOL_CHOICES)
+    protocol = ChoiceField(choices=ServiceProtocolChoices)
     ipaddresses = SerializedPKRelatedField(
         queryset=IPAddress.objects.all(),
         serializer=NestedIPAddressSerializer,

+ 130 - 0
netbox/ipam/choices.py

@@ -0,0 +1,130 @@
+from utilities.choices import ChoiceSet
+
+
+#
+# Prefixes
+#
+
+class PrefixStatusChoices(ChoiceSet):
+
+    STATUS_CONTAINER = 'container'
+    STATUS_ACTIVE = 'active'
+    STATUS_RESERVED = 'reserved'
+    STATUS_DEPRECATED = 'deprecated'
+
+    CHOICES = (
+        (STATUS_CONTAINER, 'Container'),
+        (STATUS_ACTIVE, 'Active'),
+        (STATUS_RESERVED, 'Reserved'),
+        (STATUS_DEPRECATED, 'Deprecated'),
+    )
+
+    LEGACY_MAP = {
+        STATUS_CONTAINER: 0,
+        STATUS_ACTIVE: 1,
+        STATUS_RESERVED: 2,
+        STATUS_DEPRECATED: 3,
+    }
+
+
+#
+# IPAddresses
+#
+
+class IPAddressStatusChoices(ChoiceSet):
+
+    STATUS_ACTIVE = 'active'
+    STATUS_RESERVED = 'reserved'
+    STATUS_DEPRECATED = 'deprecated'
+    STATUS_DHCP = 'dhcp'
+
+    CHOICES = (
+        (STATUS_ACTIVE, 'Active'),
+        (STATUS_RESERVED, 'Reserved'),
+        (STATUS_DEPRECATED, 'Deprecated'),
+        (STATUS_DHCP, 'DHCP'),
+    )
+
+    LEGACY_MAP = {
+        STATUS_ACTIVE: 1,
+        STATUS_RESERVED: 2,
+        STATUS_DEPRECATED: 3,
+        STATUS_DHCP: 5,
+    }
+
+
+class IPAddressRoleChoices(ChoiceSet):
+
+    ROLE_LOOPBACK = 'loopback'
+    ROLE_SECONDARY = 'secondary'
+    ROLE_ANYCAST = 'anycast'
+    ROLE_VIP = 'vip'
+    ROLE_VRRP = 'vrrp'
+    ROLE_HSRP = 'hsrp'
+    ROLE_GLBP = 'glbp'
+    ROLE_CARP = 'carp'
+
+    CHOICES = (
+        (ROLE_LOOPBACK, 'Loopback'),
+        (ROLE_SECONDARY, 'Secondary'),
+        (ROLE_ANYCAST, 'Anycast'),
+        (ROLE_VIP, 'VIP'),
+        (ROLE_VRRP, 'VRRP'),
+        (ROLE_HSRP, 'HSRP'),
+        (ROLE_GLBP, 'GLBP'),
+        (ROLE_CARP, 'CARP'),
+    )
+
+    LEGACY_MAP = {
+        ROLE_LOOPBACK: 10,
+        ROLE_SECONDARY: 20,
+        ROLE_ANYCAST: 30,
+        ROLE_VIP: 40,
+        ROLE_VRRP: 41,
+        ROLE_HSRP: 42,
+        ROLE_GLBP: 43,
+        ROLE_CARP: 44,
+    }
+
+
+#
+# VLANs
+#
+
+class VLANStatusChoices(ChoiceSet):
+
+    STATUS_ACTIVE = 'active'
+    STATUS_RESERVED = 'reserved'
+    STATUS_DEPRECATED = 'deprecated'
+
+    CHOICES = (
+        (STATUS_ACTIVE, 'Active'),
+        (STATUS_RESERVED, 'Reserved'),
+        (STATUS_DEPRECATED, 'Deprecated'),
+    )
+
+    LEGACY_MAP = {
+        STATUS_ACTIVE: 1,
+        STATUS_RESERVED: 2,
+        STATUS_DEPRECATED: 3,
+    }
+
+
+#
+# VLANs
+#
+
+class ServiceProtocolChoices(ChoiceSet):
+
+    PROTOCOL_TCP = 'tcp'
+    PROTOCOL_UDP = 'udp'
+
+    CHOICES = (
+        (PROTOCOL_TCP, 'TCP'),
+        (PROTOCOL_UDP, 'UDP'),
+    )
+
+    LEGACY_MAP = {
+        PROTOCOL_TCP: 6,
+        PROTOCOL_UDP: 17,
+    }

+ 0 - 97
netbox/ipam/constants.py

@@ -1,97 +0,0 @@
-# IP address families
-AF_CHOICES = (
-    (4, 'IPv4'),
-    (6, 'IPv6'),
-)
-
-# Prefix statuses
-PREFIX_STATUS_CONTAINER = 0
-PREFIX_STATUS_ACTIVE = 1
-PREFIX_STATUS_RESERVED = 2
-PREFIX_STATUS_DEPRECATED = 3
-PREFIX_STATUS_CHOICES = (
-    (PREFIX_STATUS_CONTAINER, 'Container'),
-    (PREFIX_STATUS_ACTIVE, 'Active'),
-    (PREFIX_STATUS_RESERVED, 'Reserved'),
-    (PREFIX_STATUS_DEPRECATED, 'Deprecated')
-)
-
-# IP address statuses
-IPADDRESS_STATUS_ACTIVE = 1
-IPADDRESS_STATUS_RESERVED = 2
-IPADDRESS_STATUS_DEPRECATED = 3
-IPADDRESS_STATUS_DHCP = 5
-IPADDRESS_STATUS_CHOICES = (
-    (IPADDRESS_STATUS_ACTIVE, 'Active'),
-    (IPADDRESS_STATUS_RESERVED, 'Reserved'),
-    (IPADDRESS_STATUS_DEPRECATED, 'Deprecated'),
-    (IPADDRESS_STATUS_DHCP, 'DHCP')
-)
-
-# IP address roles
-IPADDRESS_ROLE_LOOPBACK = 10
-IPADDRESS_ROLE_SECONDARY = 20
-IPADDRESS_ROLE_ANYCAST = 30
-IPADDRESS_ROLE_VIP = 40
-IPADDRESS_ROLE_VRRP = 41
-IPADDRESS_ROLE_HSRP = 42
-IPADDRESS_ROLE_GLBP = 43
-IPADDRESS_ROLE_CARP = 44
-IPADDRESS_ROLE_CHOICES = (
-    (IPADDRESS_ROLE_LOOPBACK, 'Loopback'),
-    (IPADDRESS_ROLE_SECONDARY, 'Secondary'),
-    (IPADDRESS_ROLE_ANYCAST, 'Anycast'),
-    (IPADDRESS_ROLE_VIP, 'VIP'),
-    (IPADDRESS_ROLE_VRRP, 'VRRP'),
-    (IPADDRESS_ROLE_HSRP, 'HSRP'),
-    (IPADDRESS_ROLE_GLBP, 'GLBP'),
-    (IPADDRESS_ROLE_CARP, 'CARP'),
-)
-
-IPADDRESS_ROLES_NONUNIQUE = (
-    # IPAddress roles which are exempt from unique address enforcement
-    IPADDRESS_ROLE_ANYCAST,
-    IPADDRESS_ROLE_VIP,
-    IPADDRESS_ROLE_VRRP,
-    IPADDRESS_ROLE_HSRP,
-    IPADDRESS_ROLE_GLBP,
-    IPADDRESS_ROLE_CARP,
-)
-
-# VLAN statuses
-VLAN_STATUS_ACTIVE = 1
-VLAN_STATUS_RESERVED = 2
-VLAN_STATUS_DEPRECATED = 3
-VLAN_STATUS_CHOICES = (
-    (VLAN_STATUS_ACTIVE, 'Active'),
-    (VLAN_STATUS_RESERVED, 'Reserved'),
-    (VLAN_STATUS_DEPRECATED, 'Deprecated')
-)
-
-# Bootstrap CSS classes
-STATUS_CHOICE_CLASSES = {
-    0: 'default',
-    1: 'primary',
-    2: 'info',
-    3: 'danger',
-    4: 'warning',
-    5: 'success',
-}
-ROLE_CHOICE_CLASSES = {
-    10: 'default',
-    20: 'primary',
-    30: 'warning',
-    40: 'success',
-    41: 'success',
-    42: 'success',
-    43: 'success',
-    44: 'success',
-}
-
-# IP protocols (for services)
-IP_PROTOCOL_TCP = 6
-IP_PROTOCOL_UDP = 17
-IP_PROTOCOL_CHOICES = (
-    (IP_PROTOCOL_TCP, 'TCP'),
-    (IP_PROTOCOL_UDP, 'UDP'),
-)

+ 5 - 5
netbox/ipam/filters.py

@@ -9,7 +9,7 @@ from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from virtualization.models import VirtualMachine
-from .constants import *
+from .choices import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 
@@ -178,7 +178,7 @@ class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS
         label='Role (slug)',
     )
     status = django_filters.MultipleChoiceFilter(
-        choices=PREFIX_STATUS_CHOICES,
+        choices=PrefixStatusChoices,
         null_value=None
     )
     tag = TagFilter()
@@ -310,11 +310,11 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
         label='Interface (ID)',
     )
     status = django_filters.MultipleChoiceFilter(
-        choices=IPADDRESS_STATUS_CHOICES,
+        choices=IPAddressStatusChoices,
         null_value=None
     )
     role = django_filters.MultipleChoiceFilter(
-        choices=IPADDRESS_ROLE_CHOICES
+        choices=IPAddressRoleChoices
     )
     tag = TagFilter()
 
@@ -424,7 +424,7 @@ class VLANFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
         label='Role (slug)',
     )
     status = django_filters.MultipleChoiceFilter(
-        choices=VLAN_STATUS_CHOICES,
+        choices=VLANStatusChoices,
         null_value=None
     )
     tag = TagFilter()

+ 3 - 3
netbox/ipam/fixtures/ipam.json

@@ -40,7 +40,7 @@
         "site": 1,
         "vrf": null,
         "vlan": null,
-        "status": 1,
+        "status": "active",
         "role": 1,
         "description": ""
     }
@@ -56,7 +56,7 @@
         "site": 1,
         "vrf": null,
         "vlan": null,
-        "status": 1,
+        "status": "active",
         "role": 1,
         "description": ""
     }
@@ -322,7 +322,7 @@
         "site": 1,
         "vid": 999,
         "name": "TEST",
-        "status": 1,
+        "status": "active",
         "role": 1
     }
 }

+ 15 - 15
netbox/ipam/forms.py

@@ -13,7 +13,7 @@ from utilities.forms import (
     StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
 )
 from virtualization.models import VirtualMachine
-from .constants import *
+from .choices import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 IP_FAMILY_CHOICES = [
@@ -374,7 +374,7 @@ class PrefixCSVForm(forms.ModelForm):
         required=False
     )
     status = CSVChoiceField(
-        choices=PREFIX_STATUS_CHOICES,
+        choices=PrefixStatusChoices,
         help_text='Operational status'
     )
     role = forms.ModelChoiceField(
@@ -459,7 +459,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         )
     )
     status = forms.ChoiceField(
-        choices=add_blank_choice(PREFIX_STATUS_CHOICES),
+        choices=add_blank_choice(PrefixStatusChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -527,7 +527,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
         )
     )
     status = forms.MultipleChoiceField(
-        choices=PREFIX_STATUS_CHOICES,
+        choices=PrefixStatusChoices,
         required=False,
         widget=StaticSelect2Multiple()
     )
@@ -764,11 +764,11 @@ class IPAddressCSVForm(forms.ModelForm):
         }
     )
     status = CSVChoiceField(
-        choices=IPADDRESS_STATUS_CHOICES,
+        choices=IPAddressStatusChoices,
         help_text='Operational status'
     )
     role = CSVChoiceField(
-        choices=IPADDRESS_ROLE_CHOICES,
+        choices=IPAddressRoleChoices,
         required=False,
         help_text='Functional role'
     )
@@ -893,12 +893,12 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
         )
     )
     status = forms.ChoiceField(
-        choices=add_blank_choice(IPADDRESS_STATUS_CHOICES),
+        choices=add_blank_choice(IPAddressStatusChoices),
         required=False,
         widget=StaticSelect2()
     )
     role = forms.ChoiceField(
-        choices=add_blank_choice(IPADDRESS_ROLE_CHOICES),
+        choices=add_blank_choice(IPAddressRoleChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -972,12 +972,12 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
         )
     )
     status = forms.MultipleChoiceField(
-        choices=IPADDRESS_STATUS_CHOICES,
+        choices=IPAddressStatusChoices,
         required=False,
         widget=StaticSelect2Multiple()
     )
     role = forms.MultipleChoiceField(
-        choices=IPADDRESS_ROLE_CHOICES,
+        choices=IPAddressRoleChoices,
         required=False,
         widget=StaticSelect2Multiple()
     )
@@ -1111,7 +1111,7 @@ class VLANCSVForm(forms.ModelForm):
         }
     )
     status = CSVChoiceField(
-        choices=VLAN_STATUS_CHOICES,
+        choices=VLANStatusChoices,
         help_text='Operational status'
     )
     role = forms.ModelChoiceField(
@@ -1180,7 +1180,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         )
     )
     status = forms.ChoiceField(
-        choices=add_blank_choice(VLAN_STATUS_CHOICES),
+        choices=add_blank_choice(VLANStatusChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -1229,7 +1229,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
         )
     )
     status = forms.MultipleChoiceField(
-        choices=VLAN_STATUS_CHOICES,
+        choices=VLANStatusChoices,
         required=False,
         widget=StaticSelect2Multiple()
     )
@@ -1292,7 +1292,7 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm):
         label='Search'
     )
     protocol = forms.ChoiceField(
-        choices=add_blank_choice(IP_PROTOCOL_CHOICES),
+        choices=add_blank_choice(ServiceProtocolChoices),
         required=False,
         widget=StaticSelect2Multiple()
     )
@@ -1307,7 +1307,7 @@ class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         widget=forms.MultipleHiddenInput()
     )
     protocol = forms.ChoiceField(
-        choices=add_blank_choice(IP_PROTOCOL_CHOICES),
+        choices=add_blank_choice(ServiceProtocolChoices),
         required=False,
         widget=StaticSelect2()
     )

+ 37 - 0
netbox/ipam/migrations/0028_3569_prefix_fields.py

@@ -0,0 +1,37 @@
+from django.db import migrations, models
+
+
+PREFIX_STATUS_CHOICES = (
+    (0, 'container'),
+    (1, 'active'),
+    (2, 'reserved'),
+    (3, 'deprecated'),
+)
+
+
+def prefix_status_to_slug(apps, schema_editor):
+    Prefix = apps.get_model('ipam', 'Prefix')
+    for id, slug in PREFIX_STATUS_CHOICES:
+        Prefix.objects.filter(status=str(id)).update(status=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('ipam', '0027_ipaddress_add_dns_name'),
+    ]
+
+    operations = [
+
+        # Prefix.status
+        migrations.AlterField(
+            model_name='prefix',
+            name='status',
+            field=models.CharField(default='active', max_length=50),
+        ),
+        migrations.RunPython(
+            code=prefix_status_to_slug
+        ),
+
+    ]

+ 69 - 0
netbox/ipam/migrations/0029_3569_ipaddress_fields.py

@@ -0,0 +1,69 @@
+from django.db import migrations, models
+
+
+IPADDRESS_STATUS_CHOICES = (
+    (0, 'container'),
+    (1, 'active'),
+    (2, 'reserved'),
+    (3, 'deprecated'),
+)
+
+IPADDRESS_ROLE_CHOICES = (
+    (10, 'loopback'),
+    (20, 'secondary'),
+    (30, 'anycast'),
+    (40, 'vip'),
+    (41, 'vrrp'),
+    (42, 'hsrp'),
+    (43, 'glbp'),
+    (44, 'carp'),
+)
+
+
+def ipaddress_status_to_slug(apps, schema_editor):
+    IPAddress = apps.get_model('ipam', 'IPAddress')
+    for id, slug in IPADDRESS_STATUS_CHOICES:
+        IPAddress.objects.filter(status=str(id)).update(status=slug)
+
+
+def ipaddress_role_to_slug(apps, schema_editor):
+    IPAddress = apps.get_model('ipam', 'IPAddress')
+    for id, slug in IPADDRESS_STATUS_CHOICES:
+        IPAddress.objects.filter(role=str(id)).update(role=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('ipam', '0028_3569_prefix_fields'),
+    ]
+
+    operations = [
+
+        # IPAddress.status
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='status',
+            field=models.CharField(default='active', max_length=50),
+        ),
+        migrations.RunPython(
+            code=ipaddress_status_to_slug
+        ),
+
+        # IPAddress.role
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='role',
+            field=models.CharField(blank=True, default='', max_length=50),
+        ),
+        migrations.RunPython(
+            code=ipaddress_role_to_slug
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='role',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+
+    ]

+ 36 - 0
netbox/ipam/migrations/0030_3569_vlan_fields.py

@@ -0,0 +1,36 @@
+from django.db import migrations, models
+
+
+VLAN_STATUS_CHOICES = (
+    (1, 'active'),
+    (2, 'reserved'),
+    (3, 'deprecated'),
+)
+
+
+def vlan_status_to_slug(apps, schema_editor):
+    VLAN = apps.get_model('ipam', 'VLAN')
+    for id, slug in VLAN_STATUS_CHOICES:
+        VLAN.objects.filter(status=str(id)).update(status=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('ipam', '0029_3569_ipaddress_fields'),
+    ]
+
+    operations = [
+
+        # VLAN.status
+        migrations.AlterField(
+            model_name='vlan',
+            name='status',
+            field=models.CharField(default='active', max_length=50),
+        ),
+        migrations.RunPython(
+            code=vlan_status_to_slug
+        ),
+
+    ]

+ 35 - 0
netbox/ipam/migrations/0031_3569_service_fields.py

@@ -0,0 +1,35 @@
+from django.db import migrations, models
+
+
+SERVICE_PROTOCOL_CHOICES = (
+    (6, 'tcp'),
+    (17, 'udp'),
+)
+
+
+def service_protocol_to_slug(apps, schema_editor):
+    Service = apps.get_model('ipam', 'Service')
+    for id, slug in SERVICE_PROTOCOL_CHOICES:
+        Service.objects.filter(protocol=str(id)).update(protocol=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('ipam', '0030_3569_vlan_fields'),
+    ]
+
+    operations = [
+
+        # Service.protocol
+        migrations.AlterField(
+            model_name='service',
+            name='protocol',
+            field=models.CharField(max_length=50),
+        ),
+        migrations.RunPython(
+            code=service_protocol_to_slug
+        ),
+
+    ]

+ 74 - 25
netbox/ipam/models.py

@@ -14,12 +14,29 @@ from extras.models import CustomFieldModel, ObjectChange, TaggedItem
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object
 from virtualization.models import VirtualMachine
-from .constants import *
+from .choices import *
 from .fields import IPNetworkField, IPAddressField
 from .querysets import PrefixQuerySet
 from .validators import DNSValidator
 
 
+# IP address families
+AF_CHOICES = (
+    (4, 'IPv4'),
+    (6, 'IPv6'),
+)
+
+IPADDRESS_ROLES_NONUNIQUE = (
+    # IPAddress roles which are exempt from unique address enforcement
+    IPAddressRoleChoices.ROLE_ANYCAST,
+    IPAddressRoleChoices.ROLE_VIP,
+    IPAddressRoleChoices.ROLE_VRRP,
+    IPAddressRoleChoices.ROLE_HSRP,
+    IPAddressRoleChoices.ROLE_GLBP,
+    IPAddressRoleChoices.ROLE_CARP,
+)
+
+
 class VRF(ChangeLoggedModel, CustomFieldModel):
     """
     A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@@ -297,9 +314,10 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
         null=True,
         verbose_name='VLAN'
     )
-    status = models.PositiveSmallIntegerField(
-        choices=PREFIX_STATUS_CHOICES,
-        default=PREFIX_STATUS_ACTIVE,
+    status = models.CharField(
+        max_length=50,
+        choices=PrefixStatusChoices,
+        default=PrefixStatusChoices.STATUS_ACTIVE,
         verbose_name='Status',
         help_text='Operational status of this prefix'
     )
@@ -333,6 +351,13 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
         'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
     ]
 
+    STATUS_CLASS_MAP = {
+        'container': 'default',
+        'active': 'primary',
+        'reserved': 'info',
+        'deprecated': 'danger',
+    }
+
     class Meta:
         ordering = [F('vrf').asc(nulls_first=True), 'family', 'prefix']
         verbose_name_plural = 'prefixes'
@@ -404,7 +429,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
     prefix_length = property(fset=_set_prefix_length)
 
     def get_status_class(self):
-        return STATUS_CHOICE_CLASSES[self.status]
+        return self.STATUS_CLASS_MAP.get(self.status)
 
     def get_duplicates(self):
         return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
@@ -414,7 +439,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
         Return all Prefixes within this Prefix and VRF. If this Prefix is a container in the global table, return child
         Prefixes belonging to any VRF.
         """
-        if self.vrf is None and self.status == PREFIX_STATUS_CONTAINER:
+        if self.vrf is None and self.status == PrefixStatusChoices.STATUS_CONTAINER:
             return Prefix.objects.filter(prefix__net_contained=str(self.prefix))
         else:
             return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
@@ -424,7 +449,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
         Return all IPAddresses within this Prefix and VRF. If this Prefix is a container in the global table, return
         child IPAddresses belonging to any VRF.
         """
-        if self.vrf is None and self.status == PREFIX_STATUS_CONTAINER:
+        if self.vrf is None and self.status == PrefixStatusChoices.STATUS_CONTAINER:
             return IPAddress.objects.filter(address__net_host_contained=str(self.prefix))
         else:
             return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
@@ -490,7 +515,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
         Determine the utilization of the prefix and return it as a percentage. For Prefixes with a status of
         "container", calculate utilization based on child prefixes. For all others, count child IP addresses.
         """
-        if self.status == PREFIX_STATUS_CONTAINER:
+        if self.status == PrefixStatusChoices.STATUS_CONTAINER:
             queryset = Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
             child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
             return int(float(child_prefixes.size) / self.prefix.size * 100)
@@ -550,17 +575,16 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
         blank=True,
         null=True
     )
-    status = models.PositiveSmallIntegerField(
-        choices=IPADDRESS_STATUS_CHOICES,
-        default=IPADDRESS_STATUS_ACTIVE,
-        verbose_name='Status',
+    status = models.CharField(
+        max_length=50,
+        choices=IPAddressStatusChoices,
+        default=IPAddressStatusChoices.STATUS_ACTIVE,
         help_text='The operational status of this IP'
     )
-    role = models.PositiveSmallIntegerField(
-        verbose_name='Role',
-        choices=IPADDRESS_ROLE_CHOICES,
+    role = models.CharField(
+        max_length=50,
+        choices=IPAddressRoleChoices,
         blank=True,
-        null=True,
         help_text='The functional role of this IP'
     )
     interface = models.ForeignKey(
@@ -604,6 +628,24 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
         'dns_name', 'description',
     ]
 
+    STATUS_CLASS_MAP = {
+        'active': 'primary',
+        'reserved': 'info',
+        'deprecated': 'danger',
+        'dhcp': 'success',
+    }
+
+    ROLE_CLASS_MAP = {
+        'loopback': 'default',
+        'secondary': 'primary',
+        'anycast': 'warning',
+        'vip': 'success',
+        'vrrp': 'success',
+        'hsrp': 'success',
+        'glbp': 'success',
+        'carp': 'success',
+    }
+
     class Meta:
         ordering = ['family', 'address']
         verbose_name = 'IP address'
@@ -737,10 +779,10 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
         return None
 
     def get_status_class(self):
-        return STATUS_CHOICE_CLASSES[self.status]
+        return self.STATUS_CLASS_MAP.get(self.status)
 
     def get_role_class(self):
-        return ROLE_CHOICE_CLASSES[self.role]
+        return self.ROLE_CLASS_MAP[self.role]
 
 
 class VLANGroup(ChangeLoggedModel):
@@ -831,10 +873,10 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
         blank=True,
         null=True
     )
-    status = models.PositiveSmallIntegerField(
-        choices=VLAN_STATUS_CHOICES,
-        default=1,
-        verbose_name='Status'
+    status = models.CharField(
+        max_length=50,
+        choices=VLANStatusChoices,
+        default=VLANStatusChoices.STATUS_ACTIVE
     )
     role = models.ForeignKey(
         to='ipam.Role',
@@ -857,6 +899,12 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
 
     csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
 
+    STATUS_CLASS_MAP = {
+        'active': 'primary',
+        'reserved': 'info',
+        'deprecated': 'danger',
+    }
+
     class Meta:
         ordering = ['site', 'group', 'vid']
         unique_together = [
@@ -899,7 +947,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
         return None
 
     def get_status_class(self):
-        return STATUS_CHOICE_CLASSES[self.status]
+        return self.STATUS_CLASS_MAP[self.status]
 
     def get_members(self):
         # Return all interfaces assigned to this VLAN
@@ -932,8 +980,9 @@ class Service(ChangeLoggedModel, CustomFieldModel):
     name = models.CharField(
         max_length=30
     )
-    protocol = models.PositiveSmallIntegerField(
-        choices=IP_PROTOCOL_CHOICES
+    protocol = models.CharField(
+        max_length=50,
+        choices=ServiceProtocolChoices
     )
     port = models.PositiveIntegerField(
         validators=[MinValueValidator(1), MaxValueValidator(65535)],

+ 9 - 9
netbox/ipam/tests/test_api.py

@@ -5,7 +5,7 @@ from netaddr import IPNetwork
 from rest_framework import status
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
-from ipam.constants import IP_PROTOCOL_TCP, IP_PROTOCOL_UDP
+from ipam.choices import ServiceProtocolChoices
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from utilities.testing import APITestCase
 
@@ -996,13 +996,13 @@ class ServiceTest(APITestCase):
             name='Test Device 2', site=site, device_type=devicetype, device_role=devicerole
         )
         self.service1 = Service.objects.create(
-            device=self.device1, name='Test Service 1', protocol=IP_PROTOCOL_TCP, port=1
+            device=self.device1, name='Test Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1
         )
         self.service1 = Service.objects.create(
-            device=self.device1, name='Test Service 2', protocol=IP_PROTOCOL_TCP, port=2
+            device=self.device1, name='Test Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=2
         )
         self.service1 = Service.objects.create(
-            device=self.device1, name='Test Service 3', protocol=IP_PROTOCOL_TCP, port=3
+            device=self.device1, name='Test Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=3
         )
 
     def test_get_service(self):
@@ -1024,7 +1024,7 @@ class ServiceTest(APITestCase):
         data = {
             'device': self.device1.pk,
             'name': 'Test Service 4',
-            'protocol': IP_PROTOCOL_TCP,
+            'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
             'port': 4,
         }
 
@@ -1045,19 +1045,19 @@ class ServiceTest(APITestCase):
             {
                 'device': self.device1.pk,
                 'name': 'Test Service 4',
-                'protocol': IP_PROTOCOL_TCP,
+                'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
                 'port': 4,
             },
             {
                 'device': self.device1.pk,
                 'name': 'Test Service 5',
-                'protocol': IP_PROTOCOL_TCP,
+                'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
                 'port': 5,
             },
             {
                 'device': self.device1.pk,
                 'name': 'Test Service 6',
-                'protocol': IP_PROTOCOL_TCP,
+                'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
                 'port': 6,
             },
         ]
@@ -1076,7 +1076,7 @@ class ServiceTest(APITestCase):
         data = {
             'device': self.device2.pk,
             'name': 'Test Service X',
-            'protocol': IP_PROTOCOL_UDP,
+            'protocol': ServiceProtocolChoices.PROTOCOL_UDP,
             'port': 99,
         }
 

+ 3 - 3
netbox/ipam/tests/test_models.py

@@ -2,7 +2,7 @@ import netaddr
 from django.core.exceptions import ValidationError
 from django.test import TestCase, override_settings
 
-from ipam.constants import IPADDRESS_ROLE_VIP
+from ipam.choices import IPAddressRoleChoices
 from ipam.models import IPAddress, Prefix, VRF
 
 
@@ -61,5 +61,5 @@ class TestIPAddress(TestCase):
 
     @override_settings(ENFORCE_GLOBAL_UNIQUE=True)
     def test_duplicate_nonunique_role(self):
-        IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPADDRESS_ROLE_VIP)
-        IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPADDRESS_ROLE_VIP)
+        IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
+        IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)

+ 4 - 4
netbox/ipam/tests/test_views.py

@@ -5,7 +5,7 @@ from django.test import Client, TestCase
 from django.urls import reverse
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
-from ipam.constants import IP_PROTOCOL_TCP
+from ipam.choices import ServiceProtocolChoices
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from utilities.testing import create_test_user
 
@@ -264,9 +264,9 @@ class ServiceTestCase(TestCase):
         device.save()
 
         Service.objects.bulk_create([
-            Service(device=device, name='Service 1', protocol=IP_PROTOCOL_TCP, port=101),
-            Service(device=device, name='Service 2', protocol=IP_PROTOCOL_TCP, port=102),
-            Service(device=device, name='Service 3', protocol=IP_PROTOCOL_TCP, port=103),
+            Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=101),
+            Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=102),
+            Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103),
         ])
 
     def test_service_list(self):

+ 6 - 6
netbox/ipam/views.py

@@ -14,7 +14,7 @@ from utilities.views import (
 )
 from virtualization.models import VirtualMachine
 from . import filters, forms, tables
-from .constants import *
+from .choices import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 
@@ -217,13 +217,13 @@ class RIRListView(PermissionRequiredMixin, ObjectListView):
 
                 # Find all consumed space for each prefix status (we ignore containers for this purpose).
                 active_prefixes = netaddr.cidr_merge(
-                    [p.prefix for p in queryset.filter(status=PREFIX_STATUS_ACTIVE)]
+                    [p.prefix for p in queryset.filter(status=PrefixStatusChoices.STATUS_ACTIVE)]
                 )
                 reserved_prefixes = netaddr.cidr_merge(
-                    [p.prefix for p in queryset.filter(status=PREFIX_STATUS_RESERVED)]
+                    [p.prefix for p in queryset.filter(status=PrefixStatusChoices.STATUS_RESERVED)]
                 )
                 deprecated_prefixes = netaddr.cidr_merge(
-                    [p.prefix for p in queryset.filter(status=PREFIX_STATUS_DEPRECATED)]
+                    [p.prefix for p in queryset.filter(status=PrefixStatusChoices.STATUS_DEPRECATED)]
                 )
 
                 # Find all available prefixes by subtracting each of the existing prefix sets from the aggregate prefix.
@@ -665,8 +665,8 @@ class IPAddressView(PermissionRequiredMixin, View):
             'nat_inside', 'interface__device'
         )
         # Exclude anycast IPs if this IP is anycast
-        if ipaddress.role == IPADDRESS_ROLE_ANYCAST:
-            duplicate_ips = duplicate_ips.exclude(role=IPADDRESS_ROLE_ANYCAST)
+        if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
+            duplicate_ips = duplicate_ips.exclude(role=IPAddressRoleChoices.ROLE_ANYCAST)
         duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
 
         # Related IP table

+ 14 - 1
netbox/utilities/api.py

@@ -13,6 +13,7 @@ from rest_framework.response import Response
 from rest_framework.serializers import Field, ModelSerializer, ValidationError
 from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet
 
+from utilities.choices import ChoiceSet
 from .utils import dict_to_filter_params, dynamic_import
 
 
@@ -64,14 +65,17 @@ class ChoiceField(Field):
     Represent a ChoiceField as {'value': <DB value>, 'label': <string>}.
     """
     def __init__(self, choices, **kwargs):
+        self.choiceset = choices
         self._choices = dict()
+
+        # Unpack grouped choices
         for k, v in choices:
-            # Unpack grouped choices
             if type(v) in [list, tuple]:
                 for k2, v2 in v:
                     self._choices[k2] = v2
             else:
                 self._choices[k] = v
+
         super().__init__(**kwargs)
 
     def to_representation(self, obj):
@@ -81,6 +85,11 @@ class ChoiceField(Field):
             ('value', obj),
             ('label', self._choices[obj])
         ])
+
+        # Include legacy numeric ID (where applicable)
+        if type(self.choiceset) is ChoiceSet and obj in self.choiceset.LEGACY_MAP:
+            data['id'] = self.choiceset.LEGACY_MAP.get(obj)
+
         return data
 
     def to_internal_value(self, data):
@@ -104,6 +113,10 @@ class ChoiceField(Field):
         try:
             if data in self._choices:
                 return data
+            # Check if data is a legacy numeric ID
+            slug = self.choiceset.id_to_slug(data)
+            if slug is not None:
+                return slug
         except TypeError:  # Input is an unhashable type
             pass
 

+ 36 - 0
netbox/utilities/choices.py

@@ -0,0 +1,36 @@
+class ChoiceSetMeta(type):
+    """
+    Metaclass for ChoiceSet
+    """
+    def __call__(cls, *args, **kwargs):
+        # Django will check if a 'choices' value is callable, and if so assume that it returns an iterable
+        return getattr(cls, 'CHOICES', ())
+
+    def __iter__(cls):
+        choices = getattr(cls, 'CHOICES', ())
+        return iter(choices)
+
+
+class ChoiceSet(metaclass=ChoiceSetMeta):
+
+    CHOICES = list()
+    LEGACY_MAP = dict()
+
+    @classmethod
+    def slug_to_id(cls, slug):
+        """
+        Return the legacy integer value corresponding to a slug.
+        """
+        return cls.LEGACY_MAP.get(slug)
+
+    @classmethod
+    def id_to_slug(cls, legacy_id):
+        """
+        Return the slug value corresponding to a legacy integer value.
+        """
+        if legacy_id in cls.LEGACY_MAP.values():
+            # Invert the legacy map to allow lookup by integer
+            legacy_map = dict([
+                (id, slug) for slug, id in cls.LEGACY_MAP.items()
+            ])
+            return legacy_map.get(legacy_id)

+ 12 - 6
netbox/utilities/utils.py

@@ -5,7 +5,7 @@ from collections import OrderedDict
 from django.core.serializers import serialize
 from django.db.models import Count, OuterRef, Subquery
 
-from dcim.constants import LENGTH_UNIT_CENTIMETER, LENGTH_UNIT_FOOT, LENGTH_UNIT_INCH, LENGTH_UNIT_METER
+from dcim.choices import CableLengthUnitChoices
 
 
 def csv_format(data):
@@ -165,12 +165,18 @@ def to_meters(length, unit):
     length = int(length)
     if length < 0:
         raise ValueError("Length must be a positive integer")
-    if unit == LENGTH_UNIT_METER:
+
+    valid_units = [u[0] for u in CableLengthUnitChoices]
+    if unit not in valid_units:
+        raise ValueError(
+            "Unknown unit {}. Must be one of the following: {}".format(unit, ', '.join(valid_units))
+        )
+
+    if unit == CableLengthUnitChoices.UNIT_METER:
         return length
-    if unit == LENGTH_UNIT_CENTIMETER:
+    if unit == CableLengthUnitChoices.UNIT_CENTIMETER:
         return length / 100
-    if unit == LENGTH_UNIT_FOOT:
+    if unit == CableLengthUnitChoices.UNIT_FOOT:
         return length * 0.3048
-    if unit == LENGTH_UNIT_INCH:
+    if unit == CableLengthUnitChoices.UNIT_INCH:
         return length * 0.3048 * 12
-    raise ValueError("Unknown unit {}. Must be 'm', 'cm', 'ft', or 'in'.".format(unit))

+ 5 - 5
netbox/virtualization/api/serializers.py

@@ -3,14 +3,14 @@ from rest_framework import serializers
 from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
-from dcim.constants import IFACE_TYPE_CHOICES, IFACE_TYPE_VIRTUAL, IFACE_MODE_CHOICES
+from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices
 from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.models import VLAN
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer
-from virtualization.constants import VM_STATUS_CHOICES
+from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from .nested_serializers import *
 
@@ -57,7 +57,7 @@ class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
 #
 
 class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
-    status = ChoiceField(choices=VM_STATUS_CHOICES, required=False)
+    status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
     site = NestedSiteSerializer(read_only=True)
     cluster = NestedClusterSerializer()
     role = NestedDeviceRoleSerializer(required=False, allow_null=True)
@@ -98,8 +98,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 
 class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
     virtual_machine = NestedVirtualMachineSerializer()
-    type = ChoiceField(choices=IFACE_TYPE_CHOICES, default=IFACE_TYPE_VIRTUAL, required=False)
-    mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
+    type = ChoiceField(choices=InterfaceTypeChoices, default=InterfaceTypeChoices.TYPE_VIRTUAL, required=False)
+    mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),

+ 24 - 0
netbox/virtualization/choices.py

@@ -0,0 +1,24 @@
+from utilities.choices import ChoiceSet
+
+
+#
+# VirtualMachines
+#
+
+class VirtualMachineStatusChoices(ChoiceSet):
+
+    STATUS_ACTIVE = 'active'
+    STATUS_OFFLINE = 'offline'
+    STATUS_STAGED = 'staged'
+
+    CHOICES = (
+        (STATUS_ACTIVE, 'Active'),
+        (STATUS_OFFLINE, 'Offline'),
+        (STATUS_STAGED, 'Staged'),
+    )
+
+    LEGACY_MAP = {
+        STATUS_OFFLINE: 0,
+        STATUS_ACTIVE: 1,
+        STATUS_STAGED: 3,
+    }

+ 0 - 15
netbox/virtualization/constants.py

@@ -1,15 +0,0 @@
-from dcim.constants import DEVICE_STATUS_ACTIVE, DEVICE_STATUS_OFFLINE, DEVICE_STATUS_STAGED
-
-# VirtualMachine statuses (replicated from Device statuses)
-VM_STATUS_CHOICES = [
-    [DEVICE_STATUS_ACTIVE, 'Active'],
-    [DEVICE_STATUS_OFFLINE, 'Offline'],
-    [DEVICE_STATUS_STAGED, 'Staged'],
-]
-
-# Bootstrap CSS classes for VirtualMachine statuses
-VM_STATUS_CLASSES = {
-    0: 'warning',
-    1: 'success',
-    3: 'primary',
-}

+ 2 - 2
netbox/virtualization/filters.py

@@ -10,7 +10,7 @@ from tenancy.models import Tenant
 from utilities.filters import (
     MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
 )
-from .constants import *
+from .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
@@ -96,7 +96,7 @@ class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdate
         label='Search',
     )
     status = django_filters.MultipleChoiceFilter(
-        choices=VM_STATUS_CHOICES,
+        choices=VirtualMachineStatusChoices,
         null_value=None
     )
     cluster_group_id = django_filters.ModelMultipleChoiceFilter(

+ 12 - 12
netbox/virtualization/forms.py

@@ -2,7 +2,7 @@ from django import forms
 from django.core.exceptions import ValidationError
 from taggit.forms import TagField
 
-from dcim.constants import IFACE_TYPE_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL, IFACE_MODE_CHOICES
+from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices
 from dcim.forms import INTERFACE_MODE_HELP_TEXT
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
@@ -15,11 +15,11 @@ from utilities.forms import (
     ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField,
     SmallTextarea, StaticSelect2, StaticSelect2Multiple
 )
-from .constants import *
+from .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 VIFACE_TYPE_CHOICES = (
-    (IFACE_TYPE_VIRTUAL, 'Virtual'),
+    (InterfaceTypeChoices.TYPE_VIRTUAL, 'Virtual'),
 )
 
 
@@ -428,7 +428,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
 class VirtualMachineCSVForm(forms.ModelForm):
     status = CSVChoiceField(
-        choices=VM_STATUS_CHOICES,
+        choices=VirtualMachineStatusChoices,
         required=False,
         help_text='Operational status of device'
     )
@@ -481,7 +481,7 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
         widget=forms.MultipleHiddenInput()
     )
     status = forms.ChoiceField(
-        choices=add_blank_choice(VM_STATUS_CHOICES),
+        choices=add_blank_choice(VirtualMachineStatusChoices),
         required=False,
         initial='',
         widget=StaticSelect2(),
@@ -612,7 +612,7 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
         )
     )
     status = forms.MultipleChoiceField(
-        choices=VM_STATUS_CHOICES,
+        choices=VirtualMachineStatusChoices,
         required=False,
         widget=StaticSelect2Multiple()
     )
@@ -717,13 +717,13 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
         tagged_vlans = self.cleaned_data['tagged_vlans']
 
         # Untagged interfaces cannot be assigned tagged VLANs
-        if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
+        if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
             raise forms.ValidationError({
                 'mode': "An access interface cannot have tagged VLANs assigned."
             })
 
         # Remove all tagged VLAN assignments from "tagged all" interfaces
-        elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
+        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
             self.cleaned_data['tagged_vlans'] = []
 
 
@@ -733,7 +733,7 @@ class InterfaceCreateForm(ComponentForm):
     )
     type = forms.ChoiceField(
         choices=VIFACE_TYPE_CHOICES,
-        initial=IFACE_TYPE_VIRTUAL,
+        initial=InterfaceTypeChoices.TYPE_VIRTUAL,
         widget=forms.HiddenInput()
     )
     enabled = forms.BooleanField(
@@ -754,7 +754,7 @@ class InterfaceCreateForm(ComponentForm):
         required=False
     )
     mode = forms.ChoiceField(
-        choices=add_blank_choice(IFACE_MODE_CHOICES),
+        choices=add_blank_choice(InterfaceModeChoices),
         required=False,
         widget=StaticSelect2(),
     )
@@ -839,7 +839,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
         required=False
     )
     mode = forms.ChoiceField(
-        choices=add_blank_choice(IFACE_MODE_CHOICES),
+        choices=add_blank_choice(InterfaceModeChoices),
         required=False,
         widget=StaticSelect2()
     )
@@ -918,7 +918,7 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form):
 class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm):
     type = forms.ChoiceField(
         choices=VIFACE_TYPE_CHOICES,
-        initial=IFACE_TYPE_VIRTUAL,
+        initial=InterfaceTypeChoices.TYPE_VIRTUAL,
         widget=forms.HiddenInput()
     )
     enabled = forms.BooleanField(

+ 36 - 0
netbox/virtualization/migrations/0011_3569_virtualmachine_fields.py

@@ -0,0 +1,36 @@
+from django.db import migrations, models
+
+
+VIRTUALMACHINE_STATUS_CHOICES = (
+    (0, 'offline'),
+    (1, 'active'),
+    (3, 'staged'),
+)
+
+
+def virtualmachine_status_to_slug(apps, schema_editor):
+    VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
+    for id, slug in VIRTUALMACHINE_STATUS_CHOICES:
+        VirtualMachine.objects.filter(status=str(id)).update(status=slug)
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('virtualization', '0010_cluster_add_tenant'),
+    ]
+
+    operations = [
+
+        # VirtualMachine.status
+        migrations.AlterField(
+            model_name='virtualmachine',
+            name='status',
+            field=models.CharField(default='active', max_length=50),
+        ),
+        migrations.RunPython(
+            code=virtualmachine_status_to_slug
+        ),
+
+    ]

+ 12 - 5
netbox/virtualization/models.py

@@ -8,7 +8,7 @@ from taggit.managers import TaggableManager
 from dcim.models import Device
 from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
 from utilities.models import ChangeLoggedModel
-from .constants import *
+from .choices import *
 
 
 #
@@ -193,9 +193,10 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         max_length=64,
         unique=True
     )
-    status = models.PositiveSmallIntegerField(
-        choices=VM_STATUS_CHOICES,
-        default=DEVICE_STATUS_ACTIVE,
+    status = models.CharField(
+        max_length=50,
+        choices=VirtualMachineStatusChoices,
+        default=VirtualMachineStatusChoices.STATUS_ACTIVE,
         verbose_name='Status'
     )
     role = models.ForeignKey(
@@ -252,6 +253,12 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
     ]
 
+    STATUS_CLASS_MAP = {
+        'active': 'success',
+        'offline': 'warning',
+        'staged': 'primary',
+    }
+
     class Meta:
         ordering = ['name']
 
@@ -294,7 +301,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         )
 
     def get_status_class(self):
-        return VM_STATUS_CLASSES[self.status]
+        return self.STATUS_CLASS_MAP.get(self.status)
 
     @property
     def primary_ip(self):

+ 8 - 8
netbox/virtualization/tests/test_api.py

@@ -2,7 +2,7 @@ from django.urls import reverse
 from netaddr import IPNetwork
 from rest_framework import status
 
-from dcim.constants import IFACE_TYPE_VIRTUAL, IFACE_MODE_TAGGED
+from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices
 from dcim.models import Interface
 from ipam.models import IPAddress, VLAN
 from utilities.testing import APITestCase
@@ -489,17 +489,17 @@ class InterfaceTest(APITestCase):
         self.interface1 = Interface.objects.create(
             virtual_machine=self.virtualmachine,
             name='Test Interface 1',
-            type=IFACE_TYPE_VIRTUAL
+            type=InterfaceTypeChoices.TYPE_VIRTUAL
         )
         self.interface2 = Interface.objects.create(
             virtual_machine=self.virtualmachine,
             name='Test Interface 2',
-            type=IFACE_TYPE_VIRTUAL
+            type=InterfaceTypeChoices.TYPE_VIRTUAL
         )
         self.interface3 = Interface.objects.create(
             virtual_machine=self.virtualmachine,
             name='Test Interface 3',
-            type=IFACE_TYPE_VIRTUAL
+            type=InterfaceTypeChoices.TYPE_VIRTUAL
         )
 
         self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1)
@@ -551,7 +551,7 @@ class InterfaceTest(APITestCase):
         data = {
             'virtual_machine': self.virtualmachine.pk,
             'name': 'Test Interface 4',
-            'mode': IFACE_MODE_TAGGED,
+            'mode': InterfaceModeChoices.MODE_TAGGED,
             'untagged_vlan': self.vlan3.id,
             'tagged_vlans': [self.vlan1.id, self.vlan2.id],
         }
@@ -598,21 +598,21 @@ class InterfaceTest(APITestCase):
             {
                 'virtual_machine': self.virtualmachine.pk,
                 'name': 'Test Interface 4',
-                'mode': IFACE_MODE_TAGGED,
+                'mode': InterfaceModeChoices.MODE_TAGGED,
                 'untagged_vlan': self.vlan2.id,
                 'tagged_vlans': [self.vlan1.id],
             },
             {
                 'virtual_machine': self.virtualmachine.pk,
                 'name': 'Test Interface 5',
-                'mode': IFACE_MODE_TAGGED,
+                'mode': InterfaceModeChoices.MODE_TAGGED,
                 'untagged_vlan': self.vlan2.id,
                 'tagged_vlans': [self.vlan1.id],
             },
             {
                 'virtual_machine': self.virtualmachine.pk,
                 'name': 'Test Interface 6',
-                'mode': IFACE_MODE_TAGGED,
+                'mode': InterfaceModeChoices.MODE_TAGGED,
                 'untagged_vlan': self.vlan2.id,
                 'tagged_vlans': [self.vlan1.id],
             },