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

Closes #17761: Store empty CharField choices as null

Jeremy Stretch 1 год назад
Родитель
Сommit
ef1fdf0a01

+ 43 - 0
netbox/circuits/migrations/0046_charfield_null_choices.py

@@ -0,0 +1,43 @@
+from django.db import migrations, models
+
+
+def set_null_values(apps, schema_editor):
+    """
+    Replace empty strings with null values.
+    """
+    Circuit = apps.get_model('circuits', 'Circuit')
+    CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment')
+    CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+
+    Circuit.objects.filter(distance_unit='').update(distance_unit=None)
+    CircuitGroupAssignment.objects.filter(priority='').update(priority=None)
+    CircuitTermination.objects.filter(cable_end='').update(cable_end=None)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0045_circuit_distance'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='circuit',
+            name='distance_unit',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='circuitgroupassignment',
+            name='priority',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='circuittermination',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1, null=True),
+        ),
+        migrations.RunPython(
+            code=set_null_values,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 2 - 1
netbox/circuits/models/circuits.py

@@ -187,7 +187,8 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin,
         verbose_name=_('priority'),
         verbose_name=_('priority'),
         max_length=50,
         max_length=50,
         choices=CircuitPriorityChoices,
         choices=CircuitPriorityChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
     prerequisite_models = (
     prerequisite_models = (
         'circuits.Circuit',
         'circuits.Circuit',

+ 287 - 0
netbox/dcim/migrations/0194_charfield_null_choices.py

@@ -0,0 +1,287 @@
+from django.db import migrations, models
+
+
+def set_null_values(apps, schema_editor):
+    """
+    Replace empty strings with null values.
+    """
+    Cable = apps.get_model('dcim', 'Cable')
+    ConsolePort = apps.get_model('dcim', 'ConsolePort')
+    ConsolePortTemplate = apps.get_model('dcim', 'ConsolePortTemplate')
+    ConsoleServerPort = apps.get_model('dcim', 'ConsoleServerPort')
+    ConsoleServerPortTemplate = apps.get_model('dcim', 'ConsoleServerPortTemplate')
+    Device = apps.get_model('dcim', 'Device')
+    DeviceType = apps.get_model('dcim', 'DeviceType')
+    FrontPort = apps.get_model('dcim', 'FrontPort')
+    Interface = apps.get_model('dcim', 'Interface')
+    InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate')
+    ModuleType = apps.get_model('dcim', 'ModuleType')
+    PowerFeed = apps.get_model('dcim', 'PowerFeed')
+    PowerOutlet = apps.get_model('dcim', 'PowerOutlet')
+    PowerOutletTemplate = apps.get_model('dcim', 'PowerOutletTemplate')
+    PowerPort = apps.get_model('dcim', 'PowerPort')
+    PowerPortTemplate = apps.get_model('dcim', 'PowerPortTemplate')
+    Rack = apps.get_model('dcim', 'Rack')
+    RackType = apps.get_model('dcim', 'RackType')
+    RearPort = apps.get_model('dcim', 'RearPort')
+
+    Cable.objects.filter(length_unit='').update(length_unit=None)
+    Cable.objects.filter(type='').update(type=None)
+    ConsolePort.objects.filter(cable_end='').update(cable_end=None)
+    ConsolePort.objects.filter(type='').update(type=None)
+    ConsolePortTemplate.objects.filter(type='').update(type=None)
+    ConsoleServerPort.objects.filter(cable_end='').update(cable_end=None)
+    ConsoleServerPort.objects.filter(type='').update(type=None)
+    ConsoleServerPortTemplate.objects.filter(type='').update(type=None)
+    Device.objects.filter(airflow='').update(airflow=None)
+    Device.objects.filter(face='').update(face=None)
+    DeviceType.objects.filter(airflow='').update(airflow=None)
+    DeviceType.objects.filter(subdevice_role='').update(subdevice_role=None)
+    DeviceType.objects.filter(weight_unit='').update(weight_unit=None)
+    FrontPort.objects.filter(cable_end='').update(cable_end=None)
+    Interface.objects.filter(cable_end='').update(cable_end=None)
+    Interface.objects.filter(mode='').update(mode=None)
+    Interface.objects.filter(poe_mode='').update(poe_mode=None)
+    Interface.objects.filter(poe_type='').update(poe_type=None)
+    Interface.objects.filter(rf_channel='').update(rf_channel=None)
+    Interface.objects.filter(rf_role='').update(rf_role=None)
+    InterfaceTemplate.objects.filter(poe_mode='').update(poe_mode=None)
+    InterfaceTemplate.objects.filter(poe_type='').update(poe_type=None)
+    InterfaceTemplate.objects.filter(rf_role='').update(rf_role=None)
+    ModuleType.objects.filter(airflow='').update(airflow=None)
+    ModuleType.objects.filter(weight_unit='').update(weight_unit=None)
+    PowerFeed.objects.filter(cable_end='').update(cable_end=None)
+    PowerOutlet.objects.filter(cable_end='').update(cable_end=None)
+    PowerOutlet.objects.filter(feed_leg='').update(feed_leg=None)
+    PowerOutlet.objects.filter(type='').update(type=None)
+    PowerOutletTemplate.objects.filter(feed_leg='').update(feed_leg=None)
+    PowerOutletTemplate.objects.filter(type='').update(type=None)
+    PowerPort.objects.filter(cable_end='').update(cable_end=None)
+    PowerPort.objects.filter(type='').update(type=None)
+    PowerPortTemplate.objects.filter(type='').update(type=None)
+    Rack.objects.filter(airflow='').update(airflow=None)
+    Rack.objects.filter(form_factor='').update(form_factor=None)
+    Rack.objects.filter(outer_unit='').update(outer_unit=None)
+    Rack.objects.filter(weight_unit='').update(weight_unit=None)
+    RackType.objects.filter(outer_unit='').update(outer_unit=None)
+    RackType.objects.filter(weight_unit='').update(weight_unit=None)
+    RearPort.objects.filter(cable_end='').update(cable_end=None)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0193_poweroutlet_color'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='cable',
+            name='length_unit',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='cable',
+            name='type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='consoleport',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1, null=True),
+        ),
+        migrations.AlterField(
+            model_name='consoleport',
+            name='type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='consoleporttemplate',
+            name='type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='consoleserverport',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1, null=True),
+        ),
+        migrations.AlterField(
+            model_name='consoleserverport',
+            name='type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='consoleserverporttemplate',
+            name='type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='airflow',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='face',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='airflow',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='subdevice_role',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='weight_unit',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='frontport',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1, null=True),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1, null=True),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='mode',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='poe_mode',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='poe_type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='rf_channel',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='rf_role',
+            field=models.CharField(blank=True, max_length=30, null=True),
+        ),
+        migrations.AlterField(
+            model_name='interfacetemplate',
+            name='poe_mode',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='interfacetemplate',
+            name='poe_type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='interfacetemplate',
+            name='rf_role',
+            field=models.CharField(blank=True, max_length=30, null=True),
+        ),
+        migrations.AlterField(
+            model_name='moduletype',
+            name='airflow',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='moduletype',
+            name='weight_unit',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='powerfeed',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1, null=True),
+        ),
+        migrations.AlterField(
+            model_name='poweroutlet',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1, null=True),
+        ),
+        migrations.AlterField(
+            model_name='poweroutlet',
+            name='feed_leg',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='poweroutlet',
+            name='type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='poweroutlettemplate',
+            name='feed_leg',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='poweroutlettemplate',
+            name='type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='powerport',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1, null=True),
+        ),
+        migrations.AlterField(
+            model_name='powerport',
+            name='type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='powerporttemplate',
+            name='type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='airflow',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='form_factor',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='outer_unit',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='weight_unit',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='racktype',
+            name='outer_unit',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='racktype',
+            name='weight_unit',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='rearport',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1, null=True),
+        ),
+        migrations.RunPython(
+            code=set_null_values,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 5 - 3
netbox/dcim/models/cables.py

@@ -42,7 +42,8 @@ class Cable(PrimaryModel):
         verbose_name=_('type'),
         verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=CableTypeChoices,
         choices=CableTypeChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
     status = models.CharField(
     status = models.CharField(
         verbose_name=_('status'),
         verbose_name=_('status'),
@@ -78,6 +79,7 @@ class Cable(PrimaryModel):
         max_length=50,
         max_length=50,
         choices=CableLengthUnitChoices,
         choices=CableLengthUnitChoices,
         blank=True,
         blank=True,
+        null=True
     )
     )
     # Stores the normalized length (in meters) for database ordering
     # Stores the normalized length (in meters) for database ordering
     _abs_length = models.DecimalField(
     _abs_length = models.DecimalField(
@@ -206,7 +208,7 @@ class Cable(PrimaryModel):
 
 
         # Clear length_unit if no length is defined
         # Clear length_unit if no length is defined
         if self.length is None:
         if self.length is None:
-            self.length_unit = ''
+            self.length_unit = None
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
@@ -365,7 +367,7 @@ class CableTermination(ChangeLoggedModel):
         termination = self.termination._meta.model.objects.get(pk=self.termination_id)
         termination = self.termination._meta.model.objects.get(pk=self.termination_id)
         termination.snapshot()
         termination.snapshot()
         termination.cable = None
         termination.cable = None
-        termination.cable_end = ''
+        termination.cable_end = None
         termination.save()
         termination.save()
 
 
         super().delete(*args, **kwargs)
         super().delete(*args, **kwargs)

+ 12 - 4
netbox/dcim/models/device_component_templates.py

@@ -203,7 +203,8 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
         verbose_name=_('type'),
         verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
 
 
     component_model = ConsolePort
     component_model = ConsolePort
@@ -237,7 +238,8 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
         verbose_name=_('type'),
         verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
 
 
     component_model = ConsoleServerPort
     component_model = ConsoleServerPort
@@ -272,7 +274,8 @@ class PowerPortTemplate(ModularComponentTemplateModel):
         verbose_name=_('type'),
         verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
     maximum_draw = models.PositiveIntegerField(
     maximum_draw = models.PositiveIntegerField(
         verbose_name=_('maximum draw'),
         verbose_name=_('maximum draw'),
@@ -334,7 +337,8 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
         verbose_name=_('type'),
         verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
     power_port = models.ForeignKey(
     power_port = models.ForeignKey(
         to='dcim.PowerPortTemplate',
         to='dcim.PowerPortTemplate',
@@ -348,6 +352,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
         max_length=50,
         max_length=50,
         choices=PowerOutletFeedLegChoices,
         choices=PowerOutletFeedLegChoices,
         blank=True,
         blank=True,
+        null=True,
         help_text=_('Phase (for three-phase feeds)')
         help_text=_('Phase (for three-phase feeds)')
     )
     )
 
 
@@ -434,18 +439,21 @@ class InterfaceTemplate(ModularComponentTemplateModel):
         max_length=50,
         max_length=50,
         choices=InterfacePoEModeChoices,
         choices=InterfacePoEModeChoices,
         blank=True,
         blank=True,
+        null=True,
         verbose_name=_('PoE mode')
         verbose_name=_('PoE mode')
     )
     )
     poe_type = models.CharField(
     poe_type = models.CharField(
         max_length=50,
         max_length=50,
         choices=InterfacePoETypeChoices,
         choices=InterfacePoETypeChoices,
         blank=True,
         blank=True,
+        null=True,
         verbose_name=_('PoE type')
         verbose_name=_('PoE type')
     )
     )
     rf_role = models.CharField(
     rf_role = models.CharField(
         max_length=30,
         max_length=30,
         choices=WirelessRoleChoices,
         choices=WirelessRoleChoices,
         blank=True,
         blank=True,
+        null=True,
         verbose_name=_('wireless role')
         verbose_name=_('wireless role')
     )
     )
 
 

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

@@ -142,8 +142,9 @@ class CabledObjectModel(models.Model):
     cable_end = models.CharField(
     cable_end = models.CharField(
         verbose_name=_('cable end'),
         verbose_name=_('cable end'),
         max_length=1,
         max_length=1,
+        choices=CableEndChoices,
         blank=True,
         blank=True,
-        choices=CableEndChoices
+        null=True
     )
     )
     mark_connected = models.BooleanField(
     mark_connected = models.BooleanField(
         verbose_name=_('mark connected'),
         verbose_name=_('mark connected'),
@@ -283,6 +284,7 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
         blank=True,
         blank=True,
+        null=True,
         help_text=_('Physical port type')
         help_text=_('Physical port type')
     )
     )
     speed = models.PositiveIntegerField(
     speed = models.PositiveIntegerField(
@@ -309,6 +311,7 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint,
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
         blank=True,
         blank=True,
+        null=True,
         help_text=_('Physical port type')
         help_text=_('Physical port type')
     )
     )
     speed = models.PositiveIntegerField(
     speed = models.PositiveIntegerField(
@@ -339,6 +342,7 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracking
         max_length=50,
         max_length=50,
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
         blank=True,
         blank=True,
+        null=True,
         help_text=_('Physical port type')
         help_text=_('Physical port type')
     )
     )
     maximum_draw = models.PositiveIntegerField(
     maximum_draw = models.PositiveIntegerField(
@@ -454,6 +458,7 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
         max_length=50,
         max_length=50,
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
         blank=True,
         blank=True,
+        null=True,
         help_text=_('Physical port type')
         help_text=_('Physical port type')
     )
     )
     power_port = models.ForeignKey(
     power_port = models.ForeignKey(
@@ -468,6 +473,7 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
         max_length=50,
         max_length=50,
         choices=PowerOutletFeedLegChoices,
         choices=PowerOutletFeedLegChoices,
         blank=True,
         blank=True,
+        null=True,
         help_text=_('Phase (for three-phase feeds)')
         help_text=_('Phase (for three-phase feeds)')
     )
     )
     color = ColorField(
     color = ColorField(
@@ -522,6 +528,7 @@ class BaseInterface(models.Model):
         max_length=50,
         max_length=50,
         choices=InterfaceModeChoices,
         choices=InterfaceModeChoices,
         blank=True,
         blank=True,
+        null=True,
         help_text=_('IEEE 802.1Q tagging strategy')
         help_text=_('IEEE 802.1Q tagging strategy')
     )
     )
     parent = models.ForeignKey(
     parent = models.ForeignKey(
@@ -624,12 +631,14 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         max_length=30,
         max_length=30,
         choices=WirelessRoleChoices,
         choices=WirelessRoleChoices,
         blank=True,
         blank=True,
+        null=True,
         verbose_name=_('wireless role')
         verbose_name=_('wireless role')
     )
     )
     rf_channel = models.CharField(
     rf_channel = models.CharField(
         max_length=50,
         max_length=50,
         choices=WirelessChannelChoices,
         choices=WirelessChannelChoices,
         blank=True,
         blank=True,
+        null=True,
         verbose_name=_('wireless channel')
         verbose_name=_('wireless channel')
     )
     )
     rf_channel_frequency = models.DecimalField(
     rf_channel_frequency = models.DecimalField(
@@ -658,12 +667,14 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         max_length=50,
         max_length=50,
         choices=InterfacePoEModeChoices,
         choices=InterfacePoEModeChoices,
         blank=True,
         blank=True,
+        null=True,
         verbose_name=_('PoE mode')
         verbose_name=_('PoE mode')
     )
     )
     poe_type = models.CharField(
     poe_type = models.CharField(
         max_length=50,
         max_length=50,
         choices=InterfacePoETypeChoices,
         choices=InterfacePoETypeChoices,
         blank=True,
         blank=True,
+        null=True,
         verbose_name=_('PoE type')
         verbose_name=_('PoE type')
     )
     )
     wireless_link = models.ForeignKey(
     wireless_link = models.ForeignKey(

+ 8 - 3
netbox/dcim/models/devices.py

@@ -118,6 +118,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         max_length=50,
         max_length=50,
         choices=SubdeviceRoleChoices,
         choices=SubdeviceRoleChoices,
         blank=True,
         blank=True,
+        null=True,
         verbose_name=_('parent/child status'),
         verbose_name=_('parent/child status'),
         help_text=_('Parent devices house child devices in device bays. Leave blank '
         help_text=_('Parent devices house child devices in device bays. Leave blank '
                     'if this device type is neither a parent nor a child.')
                     'if this device type is neither a parent nor a child.')
@@ -126,7 +127,8 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         verbose_name=_('airflow'),
         verbose_name=_('airflow'),
         max_length=50,
         max_length=50,
         choices=DeviceAirflowChoices,
         choices=DeviceAirflowChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
     front_image = models.ImageField(
     front_image = models.ImageField(
         upload_to='devicetype-images',
         upload_to='devicetype-images',
@@ -387,7 +389,8 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         verbose_name=_('airflow'),
         verbose_name=_('airflow'),
         max_length=50,
         max_length=50,
         choices=ModuleAirflowChoices,
         choices=ModuleAirflowChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
 
 
     clone_fields = ('manufacturer', 'weight', 'weight_unit', 'airflow')
     clone_fields = ('manufacturer', 'weight', 'weight_unit', 'airflow')
@@ -632,6 +635,7 @@ class Device(
     face = models.CharField(
     face = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
+        null=True,
         choices=DeviceFaceChoices,
         choices=DeviceFaceChoices,
         verbose_name=_('rack face')
         verbose_name=_('rack face')
     )
     )
@@ -645,7 +649,8 @@ class Device(
         verbose_name=_('airflow'),
         verbose_name=_('airflow'),
         max_length=50,
         max_length=50,
         choices=DeviceAirflowChoices,
         choices=DeviceAirflowChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
     primary_ip4 = models.OneToOneField(
     primary_ip4 = models.OneToOneField(
         to='ipam.IPAddress',
         to='ipam.IPAddress',

+ 7 - 4
netbox/dcim/models/racks.py

@@ -83,7 +83,8 @@ class RackBase(WeightMixin, PrimaryModel):
         verbose_name=_('outer unit'),
         verbose_name=_('outer unit'),
         max_length=50,
         max_length=50,
         choices=RackDimensionUnitChoices,
         choices=RackDimensionUnitChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
     mounting_depth = models.PositiveSmallIntegerField(
     mounting_depth = models.PositiveSmallIntegerField(
         verbose_name=_('mounting depth'),
         verbose_name=_('mounting depth'),
@@ -188,7 +189,7 @@ class RackType(RackBase):
 
 
         # Clear unit if outer width & depth are not set
         # Clear unit if outer width & depth are not set
         if self.outer_width is None and self.outer_depth is None:
         if self.outer_width is None and self.outer_depth is None:
-            self.outer_unit = ''
+            self.outer_unit = None
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
@@ -242,6 +243,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
         choices=RackFormFactorChoices,
         choices=RackFormFactorChoices,
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
+        null=True,
         verbose_name=_('form factor')
         verbose_name=_('form factor')
     )
     )
     rack_type = models.ForeignKey(
     rack_type = models.ForeignKey(
@@ -317,7 +319,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
         verbose_name=_('airflow'),
         verbose_name=_('airflow'),
         max_length=50,
         max_length=50,
         choices=RackAirflowChoices,
         choices=RackAirflowChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
 
 
     # Generic relations
     # Generic relations
@@ -409,7 +412,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
 
 
         # Clear unit if outer width & depth are not set
         # Clear unit if outer width & depth are not set
         if self.outer_width is None and self.outer_depth is None:
         if self.outer_width is None and self.outer_depth is None:
-            self.outer_unit = ''
+            self.outer_unit = None
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 

+ 0 - 1
netbox/dcim/tests/test_filtersets.py

@@ -871,7 +871,6 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_outer_unit(self):
     def test_outer_unit(self):
-        self.assertEqual(Rack.objects.filter(outer_unit__isnull=False).count(), 5)
         params = {'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER}
         params = {'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 

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

@@ -592,7 +592,7 @@ class DeviceTypeTestCase(
             'part_number': '123ABC',
             'part_number': '123ABC',
             'u_height': 2,
             'u_height': 2,
             'is_full_depth': True,
             'is_full_depth': True,
-            'subdevice_role': '',  # CharField
+            'subdevice_role': None,
             'comments': 'Some comments',
             'comments': 'Some comments',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }

+ 29 - 0
netbox/extras/migrations/0122_charfield_null_choices.py

@@ -0,0 +1,29 @@
+from django.db import migrations, models
+
+
+def set_null_values(apps, schema_editor):
+    """
+    Replace empty strings with null values.
+    """
+    CustomFieldChoiceSet = apps.get_model('extras', 'CustomFieldChoiceSet')
+
+    CustomFieldChoiceSet.objects.filter(base_choices='').update(base_choices=None)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0121_customfield_related_object_filter'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='customfieldchoiceset',
+            name='base_choices',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.RunPython(
+            code=set_null_values,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 1 - 0
netbox/extras/models/customfields.py

@@ -760,6 +760,7 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
         max_length=50,
         max_length=50,
         choices=CustomFieldChoiceSetBaseChoices,
         choices=CustomFieldChoiceSetBaseChoices,
         blank=True,
         blank=True,
+        null=True,
         help_text=_('Base set of predefined choices (optional)')
         help_text=_('Base set of predefined choices (optional)')
     )
     )
     extra_choices = ArrayField(
     extra_choices = ArrayField(

+ 36 - 0
netbox/ipam/migrations/0073_charfield_null_choices.py

@@ -0,0 +1,36 @@
+from django.db import migrations, models
+
+
+def set_null_values(apps, schema_editor):
+    """
+    Replace empty strings with null values.
+    """
+    FHRPGroup = apps.get_model('ipam', 'FHRPGroup')
+    IPAddress = apps.get_model('ipam', 'IPAddress')
+
+    FHRPGroup.objects.filter(auth_type='').update(auth_type=None)
+    IPAddress.objects.filter(role='').update(role=None)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0072_prefix_cached_relations'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='fhrpgroup',
+            name='auth_type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='role',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.RunPython(
+            code=set_null_values,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 1 - 0
netbox/ipam/models/fhrp.py

@@ -34,6 +34,7 @@ class FHRPGroup(PrimaryModel):
         max_length=50,
         max_length=50,
         choices=FHRPGroupAuthTypeChoices,
         choices=FHRPGroupAuthTypeChoices,
         blank=True,
         blank=True,
+        null=True,
         verbose_name=_('authentication type')
         verbose_name=_('authentication type')
     )
     )
     auth_key = models.CharField(
     auth_key = models.CharField(

+ 1 - 0
netbox/ipam/models/ip.py

@@ -784,6 +784,7 @@ class IPAddress(ContactsMixin, PrimaryModel):
         max_length=50,
         max_length=50,
         choices=IPAddressRoleChoices,
         choices=IPAddressRoleChoices,
         blank=True,
         blank=True,
+        null=True,
         help_text=_('The functional role of this IP')
         help_text=_('The functional role of this IP')
     )
     )
     assigned_object_type = models.ForeignKey(
     assigned_object_type = models.ForeignKey(

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

@@ -23,6 +23,7 @@ class WeightMixin(models.Model):
         max_length=50,
         max_length=50,
         choices=WeightUnitChoices,
         choices=WeightUnitChoices,
         blank=True,
         blank=True,
+        null=True,
     )
     )
     # Stores the normalized weight (in grams) for database ordering
     # Stores the normalized weight (in grams) for database ordering
     _abs_weight = models.PositiveBigIntegerField(
     _abs_weight = models.PositiveBigIntegerField(
@@ -64,6 +65,7 @@ class DistanceMixin(models.Model):
         max_length=50,
         max_length=50,
         choices=DistanceUnitChoices,
         choices=DistanceUnitChoices,
         blank=True,
         blank=True,
+        null=True,
     )
     )
     # Stores the normalized distance (in meters) for database ordering
     # Stores the normalized distance (in meters) for database ordering
     _abs_distance = models.DecimalField(
     _abs_distance = models.DecimalField(
@@ -85,7 +87,7 @@ class DistanceMixin(models.Model):
 
 
         # Clear distance_unit if no distance is defined
         # Clear distance_unit if no distance is defined
         if self.distance is None:
         if self.distance is None:
-            self.distance_unit = ''
+            self.distance_unit = None
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 

+ 29 - 0
netbox/tenancy/migrations/0016_charfield_null_choices.py

@@ -0,0 +1,29 @@
+from django.db import migrations, models
+
+
+def set_null_values(apps, schema_editor):
+    """
+    Replace empty strings with null values.
+    """
+    ContactAssignment = apps.get_model('tenancy', 'ContactAssignment')
+
+    ContactAssignment.objects.filter(priority='').update(priority=None)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0015_contactassignment_rename_content_type'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='contactassignment',
+            name='priority',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.RunPython(
+            code=set_null_values,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 2 - 1
netbox/tenancy/models/contacts.py

@@ -125,7 +125,8 @@ class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, Chan
         verbose_name=_('priority'),
         verbose_name=_('priority'),
         max_length=50,
         max_length=50,
         choices=ContactPriorityChoices,
         choices=ContactPriorityChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
 
 
     clone_fields = ('object_type', 'object_id', 'role', 'priority')
     clone_fields = ('object_type', 'object_id', 'role', 'priority')

+ 29 - 0
netbox/virtualization/migrations/0041_charfield_null_choices.py

@@ -0,0 +1,29 @@
+from django.db import migrations, models
+
+
+def set_null_values(apps, schema_editor):
+    """
+    Replace empty strings with null values.
+    """
+    VMInterface = apps.get_model('virtualization', 'VMInterface')
+
+    VMInterface.objects.filter(mode='').update(mode=None)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0040_convert_disk_size'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='vminterface',
+            name='mode',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.RunPython(
+            code=set_null_values,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 49 - 0
netbox/vpn/migrations/0006_charfield_null_choices.py

@@ -0,0 +1,49 @@
+from django.db import migrations, models
+
+
+def set_null_values(apps, schema_editor):
+    """
+    Replace empty strings with null values.
+    """
+    IKEPolicy = apps.get_model('vpn', 'IKEPolicy')
+    IKEProposal = apps.get_model('vpn', 'IKEProposal')
+    IPSecProposal = apps.get_model('vpn', 'IPSecProposal')
+
+    IKEPolicy.objects.filter(mode='').update(mode=None)
+    IKEProposal.objects.filter(authentication_algorithm='').update(authentication_algorithm=None)
+    IPSecProposal.objects.filter(authentication_algorithm='').update(authentication_algorithm=None)
+    IPSecProposal.objects.filter(encryption_algorithm='').update(encryption_algorithm=None)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('vpn', '0005_rename_indexes'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='ikepolicy',
+            name='mode',
+            field=models.CharField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='ikeproposal',
+            name='authentication_algorithm',
+            field=models.CharField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='ipsecproposal',
+            name='authentication_algorithm',
+            field=models.CharField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='ipsecproposal',
+            name='encryption_algorithm',
+            field=models.CharField(blank=True, null=True),
+        ),
+        migrations.RunPython(
+            code=set_null_values,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 8 - 4
netbox/vpn/models/crypto.py

@@ -35,7 +35,8 @@ class IKEProposal(PrimaryModel):
     authentication_algorithm = models.CharField(
     authentication_algorithm = models.CharField(
         verbose_name=_('authentication algorithm'),
         verbose_name=_('authentication algorithm'),
         choices=AuthenticationAlgorithmChoices,
         choices=AuthenticationAlgorithmChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
     group = models.PositiveSmallIntegerField(
     group = models.PositiveSmallIntegerField(
         verbose_name=_('group'),
         verbose_name=_('group'),
@@ -76,7 +77,8 @@ class IKEPolicy(PrimaryModel):
     mode = models.CharField(
     mode = models.CharField(
         verbose_name=_('mode'),
         verbose_name=_('mode'),
         choices=IKEModeChoices,
         choices=IKEModeChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
     proposals = models.ManyToManyField(
     proposals = models.ManyToManyField(
         to='vpn.IKEProposal',
         to='vpn.IKEProposal',
@@ -128,12 +130,14 @@ class IPSecProposal(PrimaryModel):
     encryption_algorithm = models.CharField(
     encryption_algorithm = models.CharField(
         verbose_name=_('encryption'),
         verbose_name=_('encryption'),
         choices=EncryptionAlgorithmChoices,
         choices=EncryptionAlgorithmChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
     authentication_algorithm = models.CharField(
     authentication_algorithm = models.CharField(
         verbose_name=_('authentication'),
         verbose_name=_('authentication'),
         choices=AuthenticationAlgorithmChoices,
         choices=AuthenticationAlgorithmChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
     sa_lifetime_seconds = models.PositiveIntegerField(
     sa_lifetime_seconds = models.PositiveIntegerField(
         verbose_name=_('SA lifetime (seconds)'),
         verbose_name=_('SA lifetime (seconds)'),

+ 54 - 0
netbox/wireless/migrations/0010_charfield_null_choices.py

@@ -0,0 +1,54 @@
+from django.db import migrations, models
+
+
+def set_null_values(apps, schema_editor):
+    """
+    Replace empty strings with null values.
+    """
+    WirelessLAN = apps.get_model('wireless', 'WirelessLAN')
+    WirelessLink = apps.get_model('wireless', 'WirelessLink')
+
+    WirelessLAN.objects.filter(auth_cipher='').update(auth_cipher=None)
+    WirelessLAN.objects.filter(auth_type='').update(auth_type=None)
+    WirelessLink.objects.filter(auth_cipher='').update(auth_cipher=None)
+    WirelessLink.objects.filter(auth_type='').update(auth_type=None)
+    WirelessLink.objects.filter(distance_unit='').update(distance_unit=None)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wireless', '0009_wirelesslink_distance'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='wirelesslan',
+            name='auth_cipher',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='wirelesslan',
+            name='auth_type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='wirelesslink',
+            name='auth_cipher',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='wirelesslink',
+            name='auth_type',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='wirelesslink',
+            name='distance_unit',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.RunPython(
+            code=set_null_values,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 3 - 1
netbox/wireless/models.py

@@ -24,13 +24,15 @@ class WirelessAuthenticationBase(models.Model):
         max_length=50,
         max_length=50,
         choices=WirelessAuthTypeChoices,
         choices=WirelessAuthTypeChoices,
         blank=True,
         blank=True,
+        null=True,
         verbose_name=_("authentication type"),
         verbose_name=_("authentication type"),
     )
     )
     auth_cipher = models.CharField(
     auth_cipher = models.CharField(
         verbose_name=_('authentication cipher'),
         verbose_name=_('authentication cipher'),
         max_length=50,
         max_length=50,
         choices=WirelessAuthCipherChoices,
         choices=WirelessAuthCipherChoices,
-        blank=True
+        blank=True,
+        null=True
     )
     )
     auth_psk = models.CharField(
     auth_psk = models.CharField(
         max_length=PSK_MAX_LENGTH,
         max_length=PSK_MAX_LENGTH,