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

Merge pull request #10368 from netbox-community/10361-unique-constraints

Closes #10361: Migrate from unique_together to UniqueConstraints
Jeremy Stretch 3 лет назад
Родитель
Сommit
c349e06346

+ 39 - 0
netbox/circuits/migrations/0039_unique_constraints.py

@@ -0,0 +1,39 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0038_cabling_cleanup'),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name='providernetwork',
+            name='circuits_providernetwork_provider_name',
+        ),
+        migrations.AlterUniqueTogether(
+            name='circuit',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='circuittermination',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='providernetwork',
+            unique_together=set(),
+        ),
+        migrations.AddConstraint(
+            model_name='circuit',
+            constraint=models.UniqueConstraint(fields=('provider', 'cid'), name='circuits_circuit_unique_provider_cid'),
+        ),
+        migrations.AddConstraint(
+            model_name='circuittermination',
+            constraint=models.UniqueConstraint(fields=('circuit', 'term_side'), name='circuits_circuittermination_unique_circuit_term_side'),
+        ),
+        migrations.AddConstraint(
+            model_name='providernetwork',
+            constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_unique_provider_name'),
+        ),
+    ]

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

@@ -132,7 +132,12 @@ class Circuit(NetBoxModel):
 
     class Meta:
         ordering = ['provider', 'cid']
-        unique_together = ['provider', 'cid']
+        constraints = (
+            models.UniqueConstraint(
+                fields=('provider', 'cid'),
+                name='%(app_label)s_%(class)s_unique_provider_cid'
+            ),
+        )
 
     def __str__(self):
         return self.cid
@@ -208,7 +213,12 @@ class CircuitTermination(
 
     class Meta:
         ordering = ['circuit', 'term_side']
-        unique_together = ['circuit', 'term_side']
+        constraints = (
+            models.UniqueConstraint(
+                fields=('circuit', 'term_side'),
+                name='%(app_label)s_%(class)s_unique_circuit_term_side'
+            ),
+        )
 
     def __str__(self):
         return f'Termination {self.term_side}: {self.site or self.provider_network}'

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

@@ -106,10 +106,9 @@ class ProviderNetwork(NetBoxModel):
         constraints = (
             models.UniqueConstraint(
                 fields=('provider', 'name'),
-                name='circuits_providernetwork_provider_name'
+                name='%(app_label)s_%(class)s_unique_provider_name'
             ),
         )
-        unique_together = ('provider', 'name')
 
     def __str__(self):
         return self.name

+ 331 - 0
netbox/dcim/migrations/0162_unique_constraints.py

@@ -0,0 +1,331 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0161_cabling_cleanup'),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name='cabletermination',
+            name='dcim_cable_termination_unique_termination',
+        ),
+        migrations.RemoveConstraint(
+            model_name='location',
+            name='dcim_location_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='location',
+            name='dcim_location_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='region',
+            name='dcim_region_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='region',
+            name='dcim_region_slug',
+        ),
+        migrations.RemoveConstraint(
+            model_name='sitegroup',
+            name='dcim_sitegroup_name',
+        ),
+        migrations.RemoveConstraint(
+            model_name='sitegroup',
+            name='dcim_sitegroup_slug',
+        ),
+        migrations.AlterUniqueTogether(
+            name='consoleport',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='consoleporttemplate',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='consoleserverport',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='consoleserverporttemplate',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='device',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='devicebay',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='devicebaytemplate',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='devicetype',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='frontport',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='frontporttemplate',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='interface',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='interfacetemplate',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='inventoryitem',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='inventoryitemtemplate',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='modulebay',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='modulebaytemplate',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='moduletype',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='powerfeed',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='poweroutlet',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='poweroutlettemplate',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='powerpanel',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='powerport',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='powerporttemplate',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='rack',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='rearport',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='rearporttemplate',
+            unique_together=set(),
+        ),
+        migrations.AddConstraint(
+            model_name='cabletermination',
+            constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cabletermination_unique_termination'),
+        ),
+        migrations.AddConstraint(
+            model_name='consoleport',
+            constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_consoleport_unique_device_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='consoleporttemplate',
+            constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_consoleporttemplate_unique_device_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='consoleporttemplate',
+            constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_consoleporttemplate_unique_module_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='consoleserverport',
+            constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_consoleserverport_unique_device_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='consoleserverporttemplate',
+            constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_consoleserverporttemplate_unique_device_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='consoleserverporttemplate',
+            constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_consoleserverporttemplate_unique_module_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='device',
+            constraint=models.UniqueConstraint(fields=('name', 'site', 'tenant'), name='dcim_device_unique_name_site_tenant'),
+        ),
+        migrations.AddConstraint(
+            model_name='device',
+            constraint=models.UniqueConstraint(condition=models.Q(('tenant__isnull', True)), fields=('name', 'site'), name='dcim_device_unique_name_site', violation_error_message='Device name must be unique per site.'),
+        ),
+        migrations.AddConstraint(
+            model_name='device',
+            constraint=models.UniqueConstraint(fields=('rack', 'position', 'face'), name='dcim_device_unique_rack_position_face'),
+        ),
+        migrations.AddConstraint(
+            model_name='device',
+            constraint=models.UniqueConstraint(fields=('virtual_chassis', 'vc_position'), name='dcim_device_unique_virtual_chassis_vc_position'),
+        ),
+        migrations.AddConstraint(
+            model_name='devicebay',
+            constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_devicebay_unique_device_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='devicebaytemplate',
+            constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_devicebaytemplate_unique_device_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='devicetype',
+            constraint=models.UniqueConstraint(fields=('manufacturer', 'model'), name='dcim_devicetype_unique_manufacturer_model'),
+        ),
+        migrations.AddConstraint(
+            model_name='devicetype',
+            constraint=models.UniqueConstraint(fields=('manufacturer', 'slug'), name='dcim_devicetype_unique_manufacturer_slug'),
+        ),
+        migrations.AddConstraint(
+            model_name='frontport',
+            constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_frontport_unique_device_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='frontport',
+            constraint=models.UniqueConstraint(fields=('rear_port', 'rear_port_position'), name='dcim_frontport_unique_rear_port_position'),
+        ),
+        migrations.AddConstraint(
+            model_name='frontporttemplate',
+            constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_frontporttemplate_unique_device_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='frontporttemplate',
+            constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_frontporttemplate_unique_module_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='frontporttemplate',
+            constraint=models.UniqueConstraint(fields=('rear_port', 'rear_port_position'), name='dcim_frontporttemplate_unique_rear_port_position'),
+        ),
+        migrations.AddConstraint(
+            model_name='interface',
+            constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_interface_unique_device_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='interfacetemplate',
+            constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_interfacetemplate_unique_device_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='interfacetemplate',
+            constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_interfacetemplate_unique_module_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='inventoryitem',
+            constraint=models.UniqueConstraint(fields=('device', 'parent', 'name'), name='dcim_inventoryitem_unique_device_parent_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='inventoryitemtemplate',
+            constraint=models.UniqueConstraint(fields=('device_type', 'parent', 'name'), name='dcim_inventoryitemtemplate_unique_device_type_parent_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='location',
+            constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'name'), name='dcim_location_name', violation_error_message='A location with this name already exists within the specified site.'),
+        ),
+        migrations.AddConstraint(
+            model_name='location',
+            constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'slug'), name='dcim_location_slug', violation_error_message='A location with this slug already exists within the specified site.'),
+        ),
+        migrations.AddConstraint(
+            model_name='modulebay',
+            constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_modulebay_unique_device_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='modulebaytemplate',
+            constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_modulebaytemplate_unique_device_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='moduletype',
+            constraint=models.UniqueConstraint(fields=('manufacturer', 'model'), name='dcim_moduletype_unique_manufacturer_model'),
+        ),
+        migrations.AddConstraint(
+            model_name='powerfeed',
+            constraint=models.UniqueConstraint(fields=('power_panel', 'name'), name='dcim_powerfeed_unique_power_panel_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='poweroutlet',
+            constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_poweroutlet_unique_device_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='poweroutlettemplate',
+            constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_poweroutlettemplate_unique_device_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='poweroutlettemplate',
+            constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_poweroutlettemplate_unique_module_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='powerpanel',
+            constraint=models.UniqueConstraint(fields=('site', 'name'), name='dcim_powerpanel_unique_site_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='powerport',
+            constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_powerport_unique_device_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='powerporttemplate',
+            constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_powerporttemplate_unique_device_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='powerporttemplate',
+            constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_powerporttemplate_unique_module_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='rack',
+            constraint=models.UniqueConstraint(fields=('location', 'name'), name='dcim_rack_unique_location_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='rack',
+            constraint=models.UniqueConstraint(fields=('location', 'facility_id'), name='dcim_rack_unique_location_facility_id'),
+        ),
+        migrations.AddConstraint(
+            model_name='rearport',
+            constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_rearport_unique_device_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='rearporttemplate',
+            constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_rearporttemplate_unique_device_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='rearporttemplate',
+            constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_rearporttemplate_unique_module_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='region',
+            constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='dcim_region_name', violation_error_message='A top-level region with this name already exists.'),
+        ),
+        migrations.AddConstraint(
+            model_name='region',
+            constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('slug',), name='dcim_region_slug', violation_error_message='A top-level region with this slug already exists.'),
+        ),
+        migrations.AddConstraint(
+            model_name='sitegroup',
+            constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='dcim_sitegroup_name', violation_error_message='A top-level site group with this name already exists.'),
+        ),
+        migrations.AddConstraint(
+            model_name='sitegroup',
+            constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('slug',), name='dcim_sitegroup_slug', violation_error_message='A top-level site group with this slug already exists.'),
+        ),
+    ]

+ 1 - 1
netbox/dcim/models/cables.py

@@ -269,7 +269,7 @@ class CableTermination(models.Model):
         constraints = (
             models.UniqueConstraint(
                 fields=('termination_type', 'termination_id'),
-                name='dcim_cable_termination_unique_termination'
+                name='%(app_label)s_%(class)s_unique_termination'
             ),
         )
 

+ 38 - 57
netbox/dcim/models/device_component_templates.py

@@ -61,6 +61,13 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
 
     class Meta:
         abstract = True
+        ordering = ('device_type', '_name')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('device_type', 'name'),
+                name='%(app_label)s_%(class)s_unique_device_type_name'
+            ),
+        )
 
     def __str__(self):
         if self.label:
@@ -100,6 +107,17 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
 
     class Meta:
         abstract = True
+        ordering = ('device_type', 'module_type', '_name')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('device_type', 'name'),
+                name='%(app_label)s_%(class)s_unique_device_type_name'
+            ),
+            models.UniqueConstraint(
+                fields=('module_type', 'name'),
+                name='%(app_label)s_%(class)s_unique_module_type_name'
+            ),
+        )
 
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(action)
@@ -145,13 +163,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
 
     component_model = ConsolePort
 
-    class Meta:
-        ordering = ('device_type', 'module_type', '_name')
-        unique_together = (
-            ('device_type', 'name'),
-            ('module_type', 'name'),
-        )
-
     def instantiate(self, **kwargs):
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
@@ -181,13 +192,6 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
 
     component_model = ConsoleServerPort
 
-    class Meta:
-        ordering = ('device_type', 'module_type', '_name')
-        unique_together = (
-            ('device_type', 'name'),
-            ('module_type', 'name'),
-        )
-
     def instantiate(self, **kwargs):
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
@@ -229,13 +233,6 @@ class PowerPortTemplate(ModularComponentTemplateModel):
 
     component_model = PowerPort
 
-    class Meta:
-        ordering = ('device_type', 'module_type', '_name')
-        unique_together = (
-            ('device_type', 'name'),
-            ('module_type', 'name'),
-        )
-
     def instantiate(self, **kwargs):
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
@@ -291,13 +288,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
 
     component_model = PowerOutlet
 
-    class Meta:
-        ordering = ('device_type', 'module_type', '_name')
-        unique_together = (
-            ('device_type', 'name'),
-            ('module_type', 'name'),
-        )
-
     def clean(self):
         super().clean()
 
@@ -372,13 +362,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
 
     component_model = Interface
 
-    class Meta:
-        ordering = ('device_type', 'module_type', '_name')
-        unique_together = (
-            ('device_type', 'name'),
-            ('module_type', 'name'),
-        )
-
     def instantiate(self, **kwargs):
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
@@ -428,12 +411,20 @@ class FrontPortTemplate(ModularComponentTemplateModel):
 
     component_model = FrontPort
 
-    class Meta:
-        ordering = ('device_type', 'module_type', '_name')
-        unique_together = (
-            ('device_type', 'name'),
-            ('module_type', 'name'),
-            ('rear_port', 'rear_port_position'),
+    class Meta(ModularComponentTemplateModel.Meta):
+        constraints = (
+            models.UniqueConstraint(
+                fields=('device_type', 'name'),
+                name='%(app_label)s_%(class)s_unique_device_type_name'
+            ),
+            models.UniqueConstraint(
+                fields=('module_type', 'name'),
+                name='%(app_label)s_%(class)s_unique_module_type_name'
+            ),
+            models.UniqueConstraint(
+                fields=('rear_port', 'rear_port_position'),
+                name='%(app_label)s_%(class)s_unique_rear_port_position'
+            ),
         )
 
     def clean(self):
@@ -507,13 +498,6 @@ class RearPortTemplate(ModularComponentTemplateModel):
 
     component_model = RearPort
 
-    class Meta:
-        ordering = ('device_type', 'module_type', '_name')
-        unique_together = (
-            ('device_type', 'name'),
-            ('module_type', 'name'),
-        )
-
     def instantiate(self, **kwargs):
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
@@ -547,10 +531,6 @@ class ModuleBayTemplate(ComponentTemplateModel):
 
     component_model = ModuleBay
 
-    class Meta:
-        ordering = ('device_type', '_name')
-        unique_together = ('device_type', 'name')
-
     def instantiate(self, device):
         return self.component_model(
             device=device,
@@ -574,10 +554,6 @@ class DeviceBayTemplate(ComponentTemplateModel):
     """
     component_model = DeviceBay
 
-    class Meta:
-        ordering = ('device_type', '_name')
-        unique_together = ('device_type', 'name')
-
     def instantiate(self, device):
         return self.component_model(
             device=device,
@@ -653,7 +629,12 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
 
     class Meta:
         ordering = ('device_type__id', 'parent__id', '_name')
-        unique_together = ('device_type', 'parent', 'name')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('device_type', 'parent', 'name'),
+                name='%(app_label)s_%(class)s_unique_device_type_parent_name'
+            ),
+        )
 
     def instantiate(self, **kwargs):
         parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None

+ 25 - 37
netbox/dcim/models/device_components.py

@@ -69,6 +69,13 @@ class ComponentModel(NetBoxModel):
 
     class Meta:
         abstract = True
+        ordering = ('device', '_name')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('device', 'name'),
+                name='%(app_label)s_%(class)s_unique_device_name'
+            ),
+        )
 
     def __str__(self):
         if self.label:
@@ -99,7 +106,7 @@ class ModularComponentModel(ComponentModel):
         object_id_field='component_id'
     )
 
-    class Meta:
+    class Meta(ComponentModel.Meta):
         abstract = True
 
 
@@ -265,10 +272,6 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
 
     clone_fields = ('device', 'module', 'type', 'speed')
 
-    class Meta:
-        ordering = ('device', '_name')
-        unique_together = ('device', 'name')
-
     def get_absolute_url(self):
         return reverse('dcim:consoleport', kwargs={'pk': self.pk})
 
@@ -292,10 +295,6 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
 
     clone_fields = ('device', 'module', 'type', 'speed')
 
-    class Meta:
-        ordering = ('device', '_name')
-        unique_together = ('device', 'name')
-
     def get_absolute_url(self):
         return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
 
@@ -329,10 +328,6 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
 
     clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
 
-    class Meta:
-        ordering = ('device', '_name')
-        unique_together = ('device', 'name')
-
     def get_absolute_url(self):
         return reverse('dcim:powerport', kwargs={'pk': self.pk})
 
@@ -443,10 +438,6 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
 
     clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
 
-    class Meta:
-        ordering = ('device', '_name')
-        unique_together = ('device', 'name')
-
     def get_absolute_url(self):
         return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
 
@@ -677,9 +668,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf',
     )
 
-    class Meta:
+    class Meta(ModularComponentModel.Meta):
         ordering = ('device', CollateAsChar('_name'))
-        unique_together = ('device', 'name')
 
     def get_absolute_url(self):
         return reverse('dcim:interface', kwargs={'pk': self.pk})
@@ -895,11 +885,16 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
 
     clone_fields = ('device', 'type', 'color')
 
-    class Meta:
-        ordering = ('device', '_name')
-        unique_together = (
-            ('device', 'name'),
-            ('rear_port', 'rear_port_position'),
+    class Meta(ModularComponentModel.Meta):
+        constraints = (
+            models.UniqueConstraint(
+                fields=('device', 'name'),
+                name='%(app_label)s_%(class)s_unique_device_name'
+            ),
+            models.UniqueConstraint(
+                fields=('rear_port', 'rear_port_position'),
+                name='%(app_label)s_%(class)s_unique_rear_port_position'
+            ),
         )
 
     def get_absolute_url(self):
@@ -944,10 +939,6 @@ class RearPort(ModularComponentModel, CabledObjectModel):
     )
     clone_fields = ('device', 'type', 'color', 'positions')
 
-    class Meta:
-        ordering = ('device', '_name')
-        unique_together = ('device', 'name')
-
     def get_absolute_url(self):
         return reverse('dcim:rearport', kwargs={'pk': self.pk})
 
@@ -980,10 +971,6 @@ class ModuleBay(ComponentModel):
 
     clone_fields = ('device',)
 
-    class Meta:
-        ordering = ('device', '_name')
-        unique_together = ('device', 'name')
-
     def get_absolute_url(self):
         return reverse('dcim:modulebay', kwargs={'pk': self.pk})
 
@@ -1002,10 +989,6 @@ class DeviceBay(ComponentModel):
 
     clone_fields = ('device',)
 
-    class Meta:
-        ordering = ('device', '_name')
-        unique_together = ('device', 'name')
-
     def get_absolute_url(self):
         return reverse('dcim:devicebay', kwargs={'pk': self.pk})
 
@@ -1141,7 +1124,12 @@ class InventoryItem(MPTTModel, ComponentModel):
 
     class Meta:
         ordering = ('device__id', 'parent__id', '_name')
-        unique_together = ('device', 'parent', 'name')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('device', 'parent', 'name'),
+                name='%(app_label)s_%(class)s_unique_device_parent_name'
+            ),
+        )
 
     def get_absolute_url(self):
         return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})

+ 34 - 27
netbox/dcim/models/devices.py

@@ -143,10 +143,16 @@ class DeviceType(NetBoxModel):
 
     class Meta:
         ordering = ['manufacturer', 'model']
-        unique_together = [
-            ['manufacturer', 'model'],
-            ['manufacturer', 'slug'],
-        ]
+        constraints = (
+            models.UniqueConstraint(
+                fields=('manufacturer', 'model'),
+                name='%(app_label)s_%(class)s_unique_manufacturer_model'
+            ),
+            models.UniqueConstraint(
+                fields=('manufacturer', 'slug'),
+                name='%(app_label)s_%(class)s_unique_manufacturer_slug'
+            ),
+        )
 
     def __str__(self):
         return self.model
@@ -341,8 +347,11 @@ class ModuleType(NetBoxModel):
 
     class Meta:
         ordering = ('manufacturer', 'model')
-        unique_together = (
-            ('manufacturer', 'model'),
+        constraints = (
+            models.UniqueConstraint(
+                fields=('manufacturer', 'model'),
+                name='%(app_label)s_%(class)s_unique_manufacturer_model'
+            ),
         )
 
     def __str__(self):
@@ -651,10 +660,25 @@ class Device(NetBoxModel, ConfigContextModel):
 
     class Meta:
         ordering = ('_name', 'pk')  # Name may be null
-        unique_together = (
-            ('site', 'tenant', 'name'),  # See validate_unique below
-            ('rack', 'position', 'face'),
-            ('virtual_chassis', 'vc_position'),
+        constraints = (
+            models.UniqueConstraint(
+                fields=('name', 'site', 'tenant'),
+                name='%(app_label)s_%(class)s_unique_name_site_tenant'
+            ),
+            models.UniqueConstraint(
+                fields=('name', 'site'),
+                name='%(app_label)s_%(class)s_unique_name_site',
+                condition=Q(tenant__isnull=True),
+                violation_error_message="Device name must be unique per site."
+            ),
+            models.UniqueConstraint(
+                fields=('rack', 'position', 'face'),
+                name='%(app_label)s_%(class)s_unique_rack_position_face'
+            ),
+            models.UniqueConstraint(
+                fields=('virtual_chassis', 'vc_position'),
+                name='%(app_label)s_%(class)s_unique_virtual_chassis_vc_position'
+            ),
         )
 
     def __str__(self):
@@ -679,23 +703,6 @@ class Device(NetBoxModel, ConfigContextModel):
     def get_absolute_url(self):
         return reverse('dcim:device', args=[self.pk])
 
-    def validate_unique(self, exclude=None):
-
-        # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
-        # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
-        # of the uniqueness constraint without manual intervention.
-        if self.name and hasattr(self, 'site') and self.tenant is None:
-            if Device.objects.exclude(pk=self.pk).filter(
-                    name=self.name,
-                    site=self.site,
-                    tenant__isnull=True
-            ):
-                raise ValidationError({
-                    'name': 'A device with this name already exists.'
-                })
-
-        super().validate_unique(exclude)
-
     def clean(self):
         super().clean()
 

+ 12 - 2
netbox/dcim/models/power.py

@@ -50,7 +50,12 @@ class PowerPanel(NetBoxModel):
 
     class Meta:
         ordering = ['site', 'name']
-        unique_together = ['site', 'name']
+        constraints = (
+            models.UniqueConstraint(
+                fields=('site', 'name'),
+                name='%(app_label)s_%(class)s_unique_site_name'
+            ),
+        )
 
     def __str__(self):
         return self.name
@@ -138,7 +143,12 @@ class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
 
     class Meta:
         ordering = ['power_panel', 'name']
-        unique_together = ['power_panel', 'name']
+        constraints = (
+            models.UniqueConstraint(
+                fields=('power_panel', 'name'),
+                name='%(app_label)s_%(class)s_unique_power_panel_name'
+            ),
+        )
 
     def __str__(self):
         return self.name

+ 11 - 6
netbox/dcim/models/racks.py

@@ -3,12 +3,11 @@ import decimal
 from django.apps import apps
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericRelation
-from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
-from django.db.models import Count, Sum
+from django.db.models import Count
 from django.urls import reverse
 
 from dcim.choices import *
@@ -18,7 +17,7 @@ from netbox.models import OrganizationalModel, NetBoxModel
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.utils import array_to_string, drange
-from .device_components import PowerOutlet, PowerPort
+from .device_components import PowerPort
 from .devices import Device
 from .power import PowerFeed
 
@@ -191,10 +190,16 @@ class Rack(NetBoxModel):
 
     class Meta:
         ordering = ('site', 'location', '_name', 'pk')  # (site, location, name) may be non-unique
-        unique_together = (
+        constraints = (
             # Name and facility_id must be unique *only* within a Location
-            ('location', 'name'),
-            ('location', 'facility_id'),
+            models.UniqueConstraint(
+                fields=('location', 'name'),
+                name='%(app_label)s_%(class)s_unique_location_name'
+            ),
+            models.UniqueConstraint(
+                fields=('location', 'facility_id'),
+                name='%(app_label)s_%(class)s_unique_location_facility_id'
+            ),
         )
 
     def __str__(self):

+ 24 - 60
netbox/dcim/models/sites.py

@@ -62,38 +62,26 @@ class Region(NestedGroupModel):
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),
-                name='dcim_region_parent_name'
+                name='%(app_label)s_%(class)s_parent_name'
             ),
             models.UniqueConstraint(
                 fields=('name',),
-                name='dcim_region_name',
-                condition=Q(parent=None)
+                name='%(app_label)s_%(class)s_name',
+                condition=Q(parent__isnull=True),
+                violation_error_message="A top-level region with this name already exists."
             ),
             models.UniqueConstraint(
                 fields=('parent', 'slug'),
-                name='dcim_region_parent_slug'
+                name='%(app_label)s_%(class)s_parent_slug'
             ),
             models.UniqueConstraint(
                 fields=('slug',),
-                name='dcim_region_slug',
-                condition=Q(parent=None)
+                name='%(app_label)s_%(class)s_slug',
+                condition=Q(parent__isnull=True),
+                violation_error_message="A top-level region with this slug already exists."
             ),
         )
 
-    def validate_unique(self, exclude=None):
-        if self.parent is None:
-            regions = Region.objects.exclude(pk=self.pk)
-            if regions.filter(name=self.name, parent__isnull=True).exists():
-                raise ValidationError({
-                    'name': 'A region with this name already exists.'
-                })
-            if regions.filter(slug=self.slug, parent__isnull=True).exists():
-                raise ValidationError({
-                    'name': 'A region with this slug already exists.'
-                })
-
-        super().validate_unique(exclude=exclude)
-
     def get_absolute_url(self):
         return reverse('dcim:region', args=[self.pk])
 
@@ -148,38 +136,26 @@ class SiteGroup(NestedGroupModel):
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),
-                name='dcim_sitegroup_parent_name'
+                name='%(app_label)s_%(class)s_parent_name'
             ),
             models.UniqueConstraint(
                 fields=('name',),
-                name='dcim_sitegroup_name',
-                condition=Q(parent=None)
+                name='%(app_label)s_%(class)s_name',
+                condition=Q(parent__isnull=True),
+                violation_error_message="A top-level site group with this name already exists."
             ),
             models.UniqueConstraint(
                 fields=('parent', 'slug'),
-                name='dcim_sitegroup_parent_slug'
+                name='%(app_label)s_%(class)s_parent_slug'
             ),
             models.UniqueConstraint(
                 fields=('slug',),
-                name='dcim_sitegroup_slug',
-                condition=Q(parent=None)
+                name='%(app_label)s_%(class)s_slug',
+                condition=Q(parent__isnull=True),
+                violation_error_message="A top-level site group with this slug already exists."
             ),
         )
 
-    def validate_unique(self, exclude=None):
-        if self.parent is None:
-            site_groups = SiteGroup.objects.exclude(pk=self.pk)
-            if site_groups.filter(name=self.name, parent__isnull=True).exists():
-                raise ValidationError({
-                    'name': 'A site group with this name already exists.'
-                })
-            if site_groups.filter(slug=self.slug, parent__isnull=True).exists():
-                raise ValidationError({
-                    'name': 'A site group with this slug already exists.'
-                })
-
-        super().validate_unique(exclude=exclude)
-
     def get_absolute_url(self):
         return reverse('dcim:sitegroup', args=[self.pk])
 
@@ -379,38 +355,26 @@ class Location(NestedGroupModel):
         constraints = (
             models.UniqueConstraint(
                 fields=('site', 'parent', 'name'),
-                name='dcim_location_parent_name'
+                name='%(app_label)s_%(class)s_parent_name'
             ),
             models.UniqueConstraint(
                 fields=('site', 'name'),
-                name='dcim_location_name',
-                condition=Q(parent=None)
+                name='%(app_label)s_%(class)s_name',
+                condition=Q(parent__isnull=True),
+                violation_error_message="A location with this name already exists within the specified site."
             ),
             models.UniqueConstraint(
                 fields=('site', 'parent', 'slug'),
-                name='dcim_location_parent_slug'
+                name='%(app_label)s_%(class)s_parent_slug'
             ),
             models.UniqueConstraint(
                 fields=('site', 'slug'),
-                name='dcim_location_slug',
-                condition=Q(parent=None)
+                name='%(app_label)s_%(class)s_slug',
+                condition=Q(parent__isnull=True),
+                violation_error_message="A location with this slug already exists within the specified site."
             ),
         )
 
-    def validate_unique(self, exclude=None):
-        if self.parent is None:
-            locations = Location.objects.exclude(pk=self.pk)
-            if locations.filter(name=self.name, site=self.site, parent__isnull=True).exists():
-                raise ValidationError({
-                    "name": f"A location with this name in site {self.site} already exists."
-                })
-            if locations.filter(slug=self.slug, site=self.site, parent__isnull=True).exists():
-                raise ValidationError({
-                    "name": f"A location with this slug in site {self.site} already exists."
-                })
-
-        super().validate_unique(exclude=exclude)
-
     @classmethod
     def get_prerequisite_models(cls):
         return [Site, ]

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

@@ -384,7 +384,7 @@ class DeviceTestCase(TestCase):
             site=self.site,
             device_type=self.device_type,
             device_role=self.device_role,
-            name=''
+            name=None
         )
         device1.save()
 
@@ -392,12 +392,12 @@ class DeviceTestCase(TestCase):
             site=device1.site,
             device_type=device1.device_type,
             device_role=device1.device_role,
-            name=''
+            name=None
         )
         device2.full_clean()
         device2.save()
 
-        self.assertEqual(Device.objects.filter(name='').count(), 2)
+        self.assertEqual(Device.objects.filter(name__isnull=True).count(), 2)
 
     def test_device_duplicate_names(self):
 

+ 27 - 0
netbox/extras/migrations/0078_unique_constraints.py

@@ -0,0 +1,27 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0077_customlink_extend_text_and_url'),
+    ]
+
+    operations = [
+        migrations.AlterUniqueTogether(
+            name='exporttemplate',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='webhook',
+            unique_together=set(),
+        ),
+        migrations.AddConstraint(
+            model_name='exporttemplate',
+            constraint=models.UniqueConstraint(fields=('content_type', 'name'), name='extras_exporttemplate_unique_content_type_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='webhook',
+            constraint=models.UniqueConstraint(fields=('payload_url', 'type_create', 'type_update', 'type_delete'), name='extras_webhook_unique_payload_url_types'),
+        ),
+    ]

+ 12 - 4
netbox/extras/models/models.py

@@ -131,7 +131,12 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
 
     class Meta:
         ordering = ('name',)
-        unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',)
+        constraints = (
+            models.UniqueConstraint(
+                fields=('payload_url', 'type_create', 'type_update', 'type_delete'),
+                name='%(app_label)s_%(class)s_unique_payload_url_types'
+            ),
+        )
 
     def __str__(self):
         return self.name
@@ -297,9 +302,12 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
 
     class Meta:
         ordering = ['content_type', 'name']
-        unique_together = [
-            ['content_type', 'name']
-        ]
+        constraints = (
+            models.UniqueConstraint(
+                fields=('content_type', 'name'),
+                name='%(app_label)s_%(class)s_unique_content_type_name'
+            ),
+        )
 
     def __str__(self):
         return f"{self.content_type}: {self.name}"

+ 43 - 0
netbox/ipam/migrations/0062_unique_constraints.py

@@ -0,0 +1,43 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0061_fhrpgroup_name'),
+    ]
+
+    operations = [
+        migrations.AlterUniqueTogether(
+            name='fhrpgroupassignment',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='vlan',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='vlangroup',
+            unique_together=set(),
+        ),
+        migrations.AddConstraint(
+            model_name='fhrpgroupassignment',
+            constraint=models.UniqueConstraint(fields=('interface_type', 'interface_id', 'group'), name='ipam_fhrpgroupassignment_unique_interface_group'),
+        ),
+        migrations.AddConstraint(
+            model_name='vlan',
+            constraint=models.UniqueConstraint(fields=('group', 'vid'), name='ipam_vlan_unique_group_vid'),
+        ),
+        migrations.AddConstraint(
+            model_name='vlan',
+            constraint=models.UniqueConstraint(fields=('group', 'name'), name='ipam_vlan_unique_group_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='vlangroup',
+            constraint=models.UniqueConstraint(fields=('scope_type', 'scope_id', 'name'), name='ipam_vlangroup_unique_scope_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='vlangroup',
+            constraint=models.UniqueConstraint(fields=('scope_type', 'scope_id', 'slug'), name='ipam_vlangroup_unique_scope_slug'),
+        ),
+    ]

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

@@ -102,7 +102,12 @@ class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel):
 
     class Meta:
         ordering = ('-priority', 'pk')
-        unique_together = ('interface_type', 'interface_id', 'group')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('interface_type', 'interface_id', 'group'),
+                name='%(app_label)s_%(class)s_unique_interface_group'
+            ),
+        )
         verbose_name = 'FHRP group assignment'
 
     def __str__(self):

+ 20 - 8
netbox/ipam/models/vlans.py

@@ -70,10 +70,16 @@ class VLANGroup(OrganizationalModel):
 
     class Meta:
         ordering = ('name', 'pk')  # Name may be non-unique
-        unique_together = [
-            ['scope_type', 'scope_id', 'name'],
-            ['scope_type', 'scope_id', 'slug'],
-        ]
+        constraints = (
+            models.UniqueConstraint(
+                fields=('scope_type', 'scope_id', 'name'),
+                name='%(app_label)s_%(class)s_unique_scope_name'
+            ),
+            models.UniqueConstraint(
+                fields=('scope_type', 'scope_id', 'slug'),
+                name='%(app_label)s_%(class)s_unique_scope_slug'
+            ),
+        )
         verbose_name = 'VLAN group'
         verbose_name_plural = 'VLAN groups'
 
@@ -189,10 +195,16 @@ class VLAN(NetBoxModel):
 
     class Meta:
         ordering = ('site', 'group', 'vid', 'pk')  # (site, group, vid) may be non-unique
-        unique_together = [
-            ['group', 'vid'],
-            ['group', 'name'],
-        ]
+        constraints = (
+            models.UniqueConstraint(
+                fields=('group', 'vid'),
+                name='%(app_label)s_%(class)s_unique_group_vid'
+            ),
+            models.UniqueConstraint(
+                fields=('group', 'name'),
+                name='%(app_label)s_%(class)s_unique_group_name'
+            ),
+        )
         verbose_name = 'VLAN'
         verbose_name_plural = 'VLANs'
 

+ 35 - 0
netbox/tenancy/migrations/0008_unique_constraints.py

@@ -0,0 +1,35 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0007_contact_link'),
+    ]
+
+    operations = [
+        migrations.AlterUniqueTogether(
+            name='contact',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='contactassignment',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='contactgroup',
+            unique_together=set(),
+        ),
+        migrations.AddConstraint(
+            model_name='contact',
+            constraint=models.UniqueConstraint(fields=('group', 'name'), name='tenancy_contact_unique_group_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='contactassignment',
+            constraint=models.UniqueConstraint(fields=('content_type', 'object_id', 'contact', 'role'), name='tenancy_contactassignment_unique_object_contact_role'),
+        ),
+        migrations.AddConstraint(
+            model_name='contactgroup',
+            constraint=models.UniqueConstraint(fields=('parent', 'name'), name='tenancy_contactgroup_unique_parent_name'),
+        ),
+    ]

+ 16 - 5
netbox/tenancy/models/contacts.py

@@ -41,8 +41,11 @@ class ContactGroup(NestedGroupModel):
 
     class Meta:
         ordering = ['name']
-        unique_together = (
-            ('parent', 'name')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('parent', 'name'),
+                name='%(app_label)s_%(class)s_unique_parent_name'
+            ),
         )
 
     def get_absolute_url(self):
@@ -118,8 +121,11 @@ class Contact(NetBoxModel):
 
     class Meta:
         ordering = ['name']
-        unique_together = (
-            ('group', 'name')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('group', 'name'),
+                name='%(app_label)s_%(class)s_unique_group_name'
+            ),
         )
 
     def __str__(self):
@@ -159,7 +165,12 @@ class ContactAssignment(WebhooksMixin, ChangeLoggedModel):
 
     class Meta:
         ordering = ('priority', 'contact')
-        unique_together = ('content_type', 'object_id', 'contact', 'role', 'priority')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('content_type', 'object_id', 'contact', 'role'),
+                name='%(app_label)s_%(class)s_unique_object_contact_role'
+            ),
+        )
 
     def __str__(self):
         if self.priority:

+ 43 - 0
netbox/virtualization/migrations/0033_unique_constraints.py

@@ -0,0 +1,43 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0032_virtualmachine_update_sites'),
+    ]
+
+    operations = [
+        migrations.AlterUniqueTogether(
+            name='cluster',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='virtualmachine',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='vminterface',
+            unique_together=set(),
+        ),
+        migrations.AddConstraint(
+            model_name='cluster',
+            constraint=models.UniqueConstraint(fields=('group', 'name'), name='virtualization_cluster_unique_group_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='cluster',
+            constraint=models.UniqueConstraint(fields=('site', 'name'), name='virtualization_cluster_unique_site_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='virtualmachine',
+            constraint=models.UniqueConstraint(fields=('name', 'cluster', 'tenant'), name='virtualization_virtualmachine_unique_name_cluster_tenant'),
+        ),
+        migrations.AddConstraint(
+            model_name='virtualmachine',
+            constraint=models.UniqueConstraint(condition=models.Q(('tenant__isnull', True)), fields=('name', 'cluster'), name='virtualization_virtualmachine_unique_name_cluster', violation_error_message='Virtual machine name must be unique per site.'),
+        ),
+        migrations.AddConstraint(
+            model_name='vminterface',
+            constraint=models.UniqueConstraint(fields=('virtual_machine', 'name'), name='virtualization_vminterface_unique_virtual_machine_name'),
+        ),
+    ]

+ 29 - 22
netbox/virtualization/models.py

@@ -2,6 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.validators import MinValueValidator
 from django.db import models
+from django.db.models import Q
 from django.urls import reverse
 
 from dcim.models import BaseInterface, Device
@@ -159,9 +160,15 @@ class Cluster(NetBoxModel):
 
     class Meta:
         ordering = ['name']
-        unique_together = (
-            ('group', 'name'),
-            ('site', 'name'),
+        constraints = (
+            models.UniqueConstraint(
+                fields=('group', 'name'),
+                name='%(app_label)s_%(class)s_unique_group_name'
+            ),
+            models.UniqueConstraint(
+                fields=('site', 'name'),
+                name='%(app_label)s_%(class)s_unique_site_name'
+            ),
         )
 
     def __str__(self):
@@ -309,9 +316,18 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
 
     class Meta:
         ordering = ('_name', 'pk')  # Name may be non-unique
-        unique_together = [
-            ['cluster', 'tenant', 'name']
-        ]
+        constraints = (
+            models.UniqueConstraint(
+                fields=('name', 'cluster', 'tenant'),
+                name='%(app_label)s_%(class)s_unique_name_cluster_tenant'
+            ),
+            models.UniqueConstraint(
+                fields=('name', 'cluster'),
+                name='%(app_label)s_%(class)s_unique_name_cluster',
+                condition=Q(tenant__isnull=True),
+                violation_error_message="Virtual machine name must be unique per site."
+            ),
+        )
 
     def __str__(self):
         return self.name
@@ -323,20 +339,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
     def get_absolute_url(self):
         return reverse('virtualization:virtualmachine', args=[self.pk])
 
-    def validate_unique(self, exclude=None):
-
-        # Check for a duplicate name on a VM assigned to the same Cluster and no Tenant. This is necessary
-        # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
-        # of the uniqueness constraint without manual intervention.
-        if self.tenant is None and VirtualMachine.objects.exclude(pk=self.pk).filter(
-                name=self.name, cluster=self.cluster, tenant__isnull=True
-        ):
-            raise ValidationError({
-                'name': 'A virtual machine with this name already exists in the assigned cluster.'
-            })
-
-        super().validate_unique(exclude)
-
     def clean(self):
         super().clean()
 
@@ -465,9 +467,14 @@ class VMInterface(NetBoxModel, BaseInterface):
     )
 
     class Meta:
-        verbose_name = 'interface'
         ordering = ('virtual_machine', CollateAsChar('_name'))
-        unique_together = ('virtual_machine', 'name')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('virtual_machine', 'name'),
+                name='%(app_label)s_%(class)s_unique_virtual_machine_name'
+            ),
+        )
+        verbose_name = 'interface'
 
     def __str__(self):
         return self.name

+ 27 - 0
netbox/wireless/migrations/0006_unique_constraints.py

@@ -0,0 +1,27 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wireless', '0005_wirelesslink_interface_types'),
+    ]
+
+    operations = [
+        migrations.AlterUniqueTogether(
+            name='wirelesslangroup',
+            unique_together=set(),
+        ),
+        migrations.AlterUniqueTogether(
+            name='wirelesslink',
+            unique_together=set(),
+        ),
+        migrations.AddConstraint(
+            model_name='wirelesslangroup',
+            constraint=models.UniqueConstraint(fields=('parent', 'name'), name='wireless_wirelesslangroup_unique_parent_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='wirelesslink',
+            constraint=models.UniqueConstraint(fields=('interface_a', 'interface_b'), name='wireless_wirelesslink_unique_interfaces'),
+        ),
+    ]

+ 11 - 3
netbox/wireless/models.py

@@ -69,8 +69,11 @@ class WirelessLANGroup(NestedGroupModel):
 
     class Meta:
         ordering = ('name', 'pk')
-        unique_together = (
-            ('parent', 'name')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('parent', 'name'),
+                name='%(app_label)s_%(class)s_unique_parent_name'
+            ),
         )
         verbose_name = 'Wireless LAN Group'
 
@@ -195,7 +198,12 @@ class WirelessLink(WirelessAuthenticationBase, NetBoxModel):
 
     class Meta:
         ordering = ['pk']
-        unique_together = ('interface_a', 'interface_b')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('interface_a', 'interface_b'),
+                name='%(app_label)s_%(class)s_unique_interfaces'
+            ),
+        )
 
     def __str__(self):
         return f'#{self.pk}'