Use case-insensitive collations on fields considered for uniqueness

This commit is contained in:
Jeremy Stretch 2025-10-24 14:12:59 -04:00
parent dac0a06f4f
commit 06052f8eaa
29 changed files with 123 additions and 81 deletions

View File

@ -41,9 +41,10 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
ProviderAccount. Circuit port speed and commit rate are measured in Kbps.
"""
cid = models.CharField(
max_length=100,
verbose_name=_('circuit ID'),
help_text=_('Unique circuit ID')
max_length=100,
db_collation='case_insensitive',
help_text=_('Unique circuit ID'),
)
provider = models.ForeignKey(
to='circuits.Provider',

View File

@ -21,13 +21,14 @@ class Provider(ContactsMixin, PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
help_text=_('Full name of the provider'),
db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
unique=True,
db_collation='case_insensitive',
)
asns = models.ManyToManyField(
to='ipam.ASN',
@ -56,13 +57,15 @@ class ProviderAccount(ContactsMixin, PrimaryModel):
related_name='accounts'
)
account = models.CharField(
verbose_name=_('account ID'),
max_length=100,
verbose_name=_('account ID')
db_collation='ci_natural_sort',
)
name = models.CharField(
verbose_name=_('name'),
max_length=100,
blank=True
db_collation='ci_natural_sort',
blank=True,
)
clone_fields = ('provider', )
@ -97,7 +100,7 @@ class ProviderNetwork(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
provider = models.ForeignKey(
to='circuits.Provider',

View File

@ -34,9 +34,10 @@ class VirtualCircuit(PrimaryModel):
A virtual connection between two or more endpoints, delivered across one or more physical circuits.
"""
cid = models.CharField(
max_length=100,
verbose_name=_('circuit ID'),
help_text=_('Unique circuit ID')
max_length=100,
db_collation='case_insensitive',
help_text=_('Unique circuit ID'),
)
provider_network = models.ForeignKey(
to='circuits.ProviderNetwork',

View File

@ -38,7 +38,8 @@ class DataSource(JobsMixin, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
unique=True,
db_collation='ci_natural_sort',
)
type = models.CharField(
verbose_name=_('type'),

View File

@ -43,10 +43,10 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation='ci_natural_sort',
help_text=_(
"{module} is accepted as a substitution for the module bay position when attached to a module type."
),
db_collation="natural_sort"
)
label = models.CharField(
verbose_name=_('label'),

View File

@ -52,7 +52,7 @@ class ComponentModel(NetBoxModel):
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
label = models.CharField(
verbose_name=_('label'),

View File

@ -1,8 +1,7 @@
import decimal
import yaml
from functools import cached_property
import yaml
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
@ -10,7 +9,6 @@ from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import F, ProtectedError, prefetch_related_objects
from django.db.models.functions import Lower
from django.db.models.signals import post_save
from django.urls import reverse
from django.utils.safestring import mark_safe
@ -25,8 +23,8 @@ from extras.querysets import ConfigContextModelQuerySet
from netbox.choices import ColorChoices
from netbox.config import ConfigItem
from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.mixins import WeightMixin
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from netbox.models.mixins import WeightMixin
from utilities.fields import ColorField, CounterCacheField
from utilities.prefetch import get_prefetchable_fields
from utilities.tracking import TrackingModelMixin
@ -34,7 +32,6 @@ from .device_components import *
from .mixins import RenderConfigMixin
from .modules import Module
__all__ = (
'Device',
'DeviceRole',
@ -83,11 +80,13 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
)
model = models.CharField(
verbose_name=_('model'),
max_length=100
max_length=100,
db_collation='case_insensitive',
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100
max_length=100,
db_collation='case_insensitive',
)
default_platform = models.ForeignKey(
to='dcim.Platform',
@ -525,7 +524,7 @@ class Device(
max_length=64,
blank=True,
null=True,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
serial = models.CharField(
max_length=50,
@ -721,11 +720,11 @@ class Device(
ordering = ('name', 'pk') # Name may be null
constraints = (
models.UniqueConstraint(
Lower('name'), 'site', 'tenant',
'name', 'site', 'tenant',
name='%(app_label)s_%(class)s_unique_name_site_tenant'
),
models.UniqueConstraint(
Lower('name'), 'site',
'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.")
@ -1119,7 +1118,7 @@ class VirtualChassis(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation="natural_sort"
db_collation='natural_sort',
)
domain = models.CharField(
verbose_name=_('domain'),
@ -1182,7 +1181,7 @@ class VirtualDeviceContext(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
status = models.CharField(
verbose_name=_('status'),

View File

@ -31,7 +31,8 @@ class ModuleTypeProfile(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
unique=True,
db_collation='ci_natural_sort',
)
schema = models.JSONField(
blank=True,
@ -72,7 +73,8 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
)
model = models.CharField(
verbose_name=_('model'),
max_length=100
max_length=100,
db_collation='ci_natural_sort',
)
part_number = models.CharField(
verbose_name=_('part number'),

View File

@ -37,7 +37,7 @@ class PowerPanel(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
prerequisite_models = (
@ -88,7 +88,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
status = models.CharField(
verbose_name=_('status'),

View File

@ -137,12 +137,14 @@ class RackType(RackBase):
)
model = models.CharField(
verbose_name=_('model'),
max_length=100
max_length=100,
db_collation='ci_natural_sort',
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
unique=True,
db_collation='case_insensitive',
)
clone_fields = (
@ -262,7 +264,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
facility_id = models.CharField(
max_length=50,

View File

@ -142,13 +142,14 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
help_text=_("Full name of the site"),
db_collation="natural_sort"
db_collation='ci_natural_sort',
help_text=_("Full name of the site")
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
unique=True,
db_collation='case_insensitive',
)
status = models.CharField(
verbose_name=_('status'),

View File

@ -35,7 +35,8 @@ class ConfigContextProfile(SyncedDataMixin, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
unique=True,
db_collation='ci_natural_sort',
)
description = models.CharField(
verbose_name=_('description'),
@ -77,7 +78,8 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
unique=True,
db_collation='ci_natural_sort',
)
profile = models.ForeignKey(
to='extras.ConfigContextProfile',

View File

@ -94,6 +94,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
verbose_name=_('name'),
max_length=50,
unique=True,
db_collation='ci_natural_sort',
help_text=_('Internal field name'),
validators=(
RegexValidator(
@ -779,7 +780,8 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
"""
name = models.CharField(
max_length=100,
unique=True
unique=True,
db_collation='ci_natural_sort',
)
description = models.CharField(
max_length=200,

View File

@ -59,7 +59,8 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
name = models.CharField(
verbose_name=_('name'),
max_length=150,
unique=True
unique=True,
db_collation='ci_natural_sort',
)
description = models.CharField(
verbose_name=_('description'),
@ -164,7 +165,8 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
name = models.CharField(
verbose_name=_('name'),
max_length=150,
unique=True
unique=True,
db_collation='ci_natural_sort',
)
description = models.CharField(
verbose_name=_('description'),
@ -307,7 +309,8 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
unique=True,
db_collation='ci_natural_sort',
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
@ -468,12 +471,14 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
unique=True,
db_collation='ci_natural_sort',
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
unique=True,
db_collation='case_insensitive',
)
description = models.CharField(
verbose_name=_('description'),

View File

@ -125,7 +125,8 @@ class NotificationGroup(ChangeLoggedModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
unique=True,
db_collation='ci_natural_sort',
)
description = models.CharField(
verbose_name=_('description'),

View File

@ -2,7 +2,7 @@ from django.conf import settings
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from taggit.models import TagBase, GenericTaggedItemBase
from netbox.choices import ColorChoices
@ -25,6 +25,21 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
id = models.BigAutoField(
primary_key=True
)
# Override TagBase.name to set db_collation
name = models.CharField(
verbose_name=pgettext_lazy("A tag name", "name"),
unique=True,
max_length=100,
db_collation='ci_natural_sort',
)
# Override TagBase.slug to set db_collation
slug = models.SlugField(
verbose_name=pgettext_lazy("A tag slug", "slug"),
unique=True,
max_length=100,
allow_unicode=True,
db_collation='case_insensitive',
)
color = ColorField(
verbose_name=_('color'),
default=ColorChoices.COLOR_GREY

View File

@ -18,12 +18,7 @@ class ASNRange(OrganizationalModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
db_collation='ci_natural_sort',
)
rir = models.ForeignKey(
to='ipam.RIR',

View File

@ -50,7 +50,8 @@ class ServiceTemplate(ServiceBase, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
unique=True,
db_collation='ci_natural_sort',
)
class Meta:

View File

@ -37,11 +37,12 @@ class VLANGroup(OrganizationalModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100
max_length=100,
db_collation='case_insensitive',
)
scope_type = models.ForeignKey(
to='contenttypes.ContentType',
@ -214,7 +215,8 @@ class VLAN(PrimaryModel):
)
name = models.CharField(
verbose_name=_('name'),
max_length=64
max_length=64,
db_collation='ci_natural_sort',
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
@ -362,6 +364,7 @@ class VLANTranslationPolicy(PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
)
class Meta:

View File

@ -19,11 +19,12 @@ class VRF(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation="natural_sort"
db_collation='natural_sort',
)
rd = models.CharField(
max_length=VRF_RD_MAX_LENGTH,
unique=True,
db_collation='case_insensitive',
blank=True,
null=True,
verbose_name=_('route distinguisher'),
@ -75,8 +76,8 @@ class RouteTarget(PrimaryModel):
verbose_name=_('name'),
max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4)
unique=True,
db_collation='ci_natural_sort',
help_text=_('Route target value (formatted in accordance with RFC 4360)'),
db_collation="natural_sort"
)
tenant = models.ForeignKey(
to='tenancy.Tenant',

View File

@ -153,11 +153,13 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
)
name = models.CharField(
verbose_name=_('name'),
max_length=100
max_length=100,
db_collation='ci_natural_sort',
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100
max_length=100,
db_collation='case_insensitive',
)
description = models.CharField(
verbose_name=_('description'),
@ -202,12 +204,14 @@ class OrganizationalModel(NetBoxModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
unique=True,
db_collation='ci_natural_sort',
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
unique=True,
db_collation='case_insensitive',
)
description = models.CharField(
verbose_name=_('description'),

View File

@ -55,7 +55,7 @@ class Contact(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation="natural_sort"
db_collation='natural_sort',
)
title = models.CharField(
verbose_name=_('title'),

View File

@ -19,12 +19,13 @@ class TenantGroup(NestedGroupModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation="natural_sort"
db_collation='ci_natural_sort'
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
unique=True,
db_collation='case_insensitive'
)
class Meta:
@ -41,11 +42,12 @@ class Tenant(ContactsMixin, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100
max_length=100,
db_collation='case_insensitive',
)
group = models.ForeignKey(
to='tenancy.TenantGroup',

View File

@ -51,7 +51,7 @@ class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
type = models.ForeignKey(
verbose_name=_('type'),

View File

@ -5,7 +5,6 @@ from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q, Sum
from django.db.models.functions import Lower
from django.utils.translation import gettext_lazy as _
from dcim.models import BaseInterface
@ -70,7 +69,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
status = models.CharField(
max_length=50,
@ -156,11 +155,11 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
ordering = ('name', 'pk') # Name may be non-unique
constraints = (
models.UniqueConstraint(
Lower('name'), 'cluster', 'tenant',
'name', 'cluster', 'tenant',
name='%(app_label)s_%(class)s_unique_name_cluster_tenant'
),
models.UniqueConstraint(
Lower('name'), 'cluster',
'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 cluster.")
@ -275,7 +274,7 @@ class ComponentModel(NetBoxModel):
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
description = models.CharField(
verbose_name=_('description'),

View File

@ -23,7 +23,7 @@ class IKEProposal(PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
authentication_method = models.CharField(
verbose_name=('authentication method'),
@ -69,7 +69,7 @@ class IKEPolicy(PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
version = models.PositiveSmallIntegerField(
verbose_name=_('version'),
@ -128,7 +128,7 @@ class IPSecProposal(PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
encryption_algorithm = models.CharField(
verbose_name=_('encryption'),
@ -180,7 +180,7 @@ class IPSecPolicy(PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
proposals = models.ManyToManyField(
to='vpn.IPSecProposal',
@ -216,7 +216,7 @@ class IPSecProfile(PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
mode = models.CharField(
verbose_name=_('mode'),

View File

@ -20,12 +20,13 @@ class L2VPN(ContactsMixin, PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
unique=True,
db_collation='case_insensitive',
)
type = models.CharField(
verbose_name=_('type'),

View File

@ -32,7 +32,7 @@ class Tunnel(ContactsMixin, PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
status = models.CharField(
verbose_name=_('status'),

View File

@ -53,12 +53,13 @@ class WirelessLANGroup(NestedGroupModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation="natural_sort"
db_collation='ci_natural_sort',
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
unique=True,
db_collation='case_insensitive',
)
class Meta: