Merge branch 'feature' into 7848-rq-api

This commit is contained in:
Arthur Hanson 2024-11-18 07:41:36 -08:00
commit d3953a2f6d
83 changed files with 1125 additions and 496 deletions

View File

@ -43,3 +43,7 @@ The security cipher used to apply wireless authentication. Options include:
### Pre-Shared Key ### Pre-Shared Key
The security key configured on each client to grant access to the secured wireless LAN. This applies only to certain authentication types. The security key configured on each client to grant access to the secured wireless LAN. This applies only to certain authentication types.
### Scope
The [region](../dcim/region.md), [site](../dcim/site.md), [site group](../dcim/sitegroup.md) or [location](../dcim/location.md) with which this wireless LAN is associated.

View File

@ -21,6 +21,7 @@ def copy_site_assignments(apps, schema_editor):
termination_id=models.F('provider_network_id') termination_id=models.F('provider_network_id')
) )
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [

View File

@ -0,0 +1,22 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0048_circuitterminations_cached_relations'),
('dcim', '0197_natural_sort_collation'),
]
operations = [
migrations.AlterField(
model_name='provider',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='providernetwork',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100),
),
]

View File

@ -21,7 +21,8 @@ class Provider(ContactsMixin, PrimaryModel):
verbose_name=_('name'), verbose_name=_('name'),
max_length=100, max_length=100,
unique=True, unique=True,
help_text=_('Full name of the provider') help_text=_('Full name of the provider'),
db_collation="natural_sort"
) )
slug = models.SlugField( slug = models.SlugField(
verbose_name=_('slug'), verbose_name=_('slug'),
@ -95,7 +96,8 @@ class ProviderNetwork(PrimaryModel):
""" """
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100 max_length=100,
db_collation="natural_sort"
) )
provider = models.ForeignKey( provider = models.ForeignKey(
to='circuits.Provider', to='circuits.Provider',

View File

@ -341,7 +341,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
class TestCase(ViewTestCases.PrimaryObjectViewTestCase): class TestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CircuitTermination model = CircuitTermination
@classmethod @classmethod

View File

@ -76,10 +76,6 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2']) self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
# Check that private attributes were included in raw data but not display data
self.assertIn('_name', oc.postchange_data)
self.assertNotIn('_name', oc.postchange_data_clean)
def test_update_object(self): def test_update_object(self):
site = Site(name='Site 1', slug='site-1') site = Site(name='Site 1', slug='site-1')
site.save() site.save()
@ -117,12 +113,6 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2']) self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 3']) self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
# Check that private attributes were included in raw data but not display data
self.assertIn('_name', oc.prechange_data)
self.assertNotIn('_name', oc.prechange_data_clean)
self.assertIn('_name', oc.postchange_data)
self.assertNotIn('_name', oc.postchange_data_clean)
def test_delete_object(self): def test_delete_object(self):
site = Site( site = Site(
name='Site 1', name='Site 1',
@ -153,10 +143,6 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None) self.assertEqual(oc.postchange_data, None)
# Check that private attributes were included in raw data but not display data
self.assertIn('_name', oc.prechange_data)
self.assertNotIn('_name', oc.prechange_data_clean)
def test_bulk_update_objects(self): def test_bulk_update_objects(self):
sites = ( sites = (
Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE), Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
@ -353,10 +339,6 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields']) self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
# Check that private attributes were included in raw data but not display data
self.assertIn('_name', oc.postchange_data)
self.assertNotIn('_name', oc.postchange_data_clean)
def test_update_object(self): def test_update_object(self):
site = Site(name='Site 1', slug='site-1') site = Site(name='Site 1', slug='site-1')
site.save() site.save()
@ -389,12 +371,6 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields']) self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 3']) self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
# Check that private attributes were included in raw data but not display data
self.assertIn('_name', oc.prechange_data)
self.assertNotIn('_name', oc.prechange_data_clean)
self.assertIn('_name', oc.postchange_data)
self.assertNotIn('_name', oc.postchange_data_clean)
def test_delete_object(self): def test_delete_object(self):
site = Site( site = Site(
name='Site 1', name='Site 1',
@ -423,10 +399,6 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None) self.assertEqual(oc.postchange_data, None)
# Check that private attributes were included in raw data but not display data
self.assertIn('_name', oc.prechange_data)
self.assertNotIn('_name', oc.prechange_data_clean)
def test_bulk_create_objects(self): def test_bulk_create_objects(self):
data = ( data = (
{ {

View File

@ -21,7 +21,7 @@ __all__ = (
class RegionSerializer(NestedGroupModelSerializer): class RegionSerializer(NestedGroupModelSerializer):
parent = NestedRegionSerializer(required=False, allow_null=True, default=None) parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True, default=0) site_count = serializers.IntegerField(read_only=True, default=0)
prefix_count = RelatedObjectCountField('_prefixes') prefix_count = RelatedObjectCountField('prefix_set')
class Meta: class Meta:
model = Region model = Region
@ -35,7 +35,7 @@ class RegionSerializer(NestedGroupModelSerializer):
class SiteGroupSerializer(NestedGroupModelSerializer): class SiteGroupSerializer(NestedGroupModelSerializer):
parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None) parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True, default=0) site_count = serializers.IntegerField(read_only=True, default=0)
prefix_count = RelatedObjectCountField('_prefixes') prefix_count = RelatedObjectCountField('prefix_set')
class Meta: class Meta:
model = SiteGroup model = SiteGroup
@ -63,7 +63,7 @@ class SiteSerializer(NetBoxModelSerializer):
# Related object counts # Related object counts
circuit_count = RelatedObjectCountField('circuit_terminations') circuit_count = RelatedObjectCountField('circuit_terminations')
device_count = RelatedObjectCountField('devices') device_count = RelatedObjectCountField('devices')
prefix_count = RelatedObjectCountField('_prefixes') prefix_count = RelatedObjectCountField('prefix_set')
rack_count = RelatedObjectCountField('racks') rack_count = RelatedObjectCountField('racks')
vlan_count = RelatedObjectCountField('vlans') vlan_count = RelatedObjectCountField('vlans')
virtualmachine_count = RelatedObjectCountField('virtual_machines') virtualmachine_count = RelatedObjectCountField('virtual_machines')
@ -86,7 +86,7 @@ class LocationSerializer(NestedGroupModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True, default=0) rack_count = serializers.IntegerField(read_only=True, default=0)
device_count = serializers.IntegerField(read_only=True, default=0) device_count = serializers.IntegerField(read_only=True, default=0)
prefix_count = RelatedObjectCountField('_prefixes') prefix_count = RelatedObjectCountField('prefix_set')
class Meta: class Meta:
model = Location model = Location

View File

@ -0,0 +1,67 @@
import django_filters
from django.utils.translation import gettext as _
from netbox.filtersets import BaseFilterSet
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
from .models import *
__all__ = (
'ScopedFilterSet',
)
class ScopedFilterSet(BaseFilterSet):
"""
Provides additional filtering functionality for location, site, etc.. for Scoped models.
"""
scope_type = ContentTypeFilter()
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
to_field_name='slug',
label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='_site_group',
lookup_expr='in',
label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='_site_group',
lookup_expr='in',
to_field_name='slug',
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
field_name='_site',
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='_site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site (slug)'),
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
to_field_name='slug',
label=_('Location (slug)'),
)

View File

@ -73,7 +73,6 @@ __all__ = (
'RearPortFilterSet', 'RearPortFilterSet',
'RearPortTemplateFilterSet', 'RearPortTemplateFilterSet',
'RegionFilterSet', 'RegionFilterSet',
'ScopedFilterSet',
'SiteFilterSet', 'SiteFilterSet',
'SiteGroupFilterSet', 'SiteGroupFilterSet',
'VirtualChassisFilterSet', 'VirtualChassisFilterSet',
@ -2345,60 +2344,3 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet):
class Meta: class Meta:
model = Interface model = Interface
fields = tuple() fields = tuple()
class ScopedFilterSet(BaseFilterSet):
"""
Provides additional filtering functionality for location, site, etc.. for Scoped models.
"""
scope_type = ContentTypeFilter()
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
to_field_name='slug',
label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='_site_group',
lookup_expr='in',
label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='_site_group',
lookup_expr='in',
to_field_name='slug',
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
field_name='_site',
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='_site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site (slug)'),
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
to_field_name='slug',
label=_('Location (slug)'),
)

View File

@ -76,7 +76,6 @@ class ComponentType(
""" """
Base type for device/VM components Base type for device/VM components
""" """
_name: str
device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]
@ -93,7 +92,6 @@ class ComponentTemplateType(
""" """
Base type for device/VM components Base type for device/VM components
""" """
_name: str
device_type: Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')] device_type: Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]
@ -181,7 +179,7 @@ class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin
filters=ConsolePortTemplateFilter filters=ConsolePortTemplateFilter
) )
class ConsolePortTemplateType(ModularComponentTemplateType): class ConsolePortTemplateType(ModularComponentTemplateType):
_name: str pass
@strawberry_django.type( @strawberry_django.type(
@ -199,7 +197,7 @@ class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpoin
filters=ConsoleServerPortTemplateFilter filters=ConsoleServerPortTemplateFilter
) )
class ConsoleServerPortTemplateType(ModularComponentTemplateType): class ConsoleServerPortTemplateType(ModularComponentTemplateType):
_name: str pass
@strawberry_django.type( @strawberry_django.type(
@ -208,7 +206,6 @@ class ConsoleServerPortTemplateType(ModularComponentTemplateType):
filters=DeviceFilter filters=DeviceFilter
) )
class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
_name: str
console_port_count: BigInt console_port_count: BigInt
console_server_port_count: BigInt console_server_port_count: BigInt
power_port_count: BigInt power_port_count: BigInt
@ -273,7 +270,7 @@ class DeviceBayType(ComponentType):
filters=DeviceBayTemplateFilter filters=DeviceBayTemplateFilter
) )
class DeviceBayTemplateType(ComponentTemplateType): class DeviceBayTemplateType(ComponentTemplateType):
_name: str pass
@strawberry_django.type( @strawberry_django.type(
@ -282,7 +279,6 @@ class DeviceBayTemplateType(ComponentTemplateType):
filters=InventoryItemTemplateFilter filters=InventoryItemTemplateFilter
) )
class InventoryItemTemplateType(ComponentTemplateType): class InventoryItemTemplateType(ComponentTemplateType):
_name: str
role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
@ -366,7 +362,6 @@ class FrontPortType(ModularComponentType, CabledObjectMixin):
filters=FrontPortTemplateFilter filters=FrontPortTemplateFilter
) )
class FrontPortTemplateType(ModularComponentTemplateType): class FrontPortTemplateType(ModularComponentTemplateType):
_name: str
color: str color: str
rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]
@ -377,6 +372,7 @@ class FrontPortTemplateType(ModularComponentTemplateType):
filters=InterfaceFilter filters=InterfaceFilter
) )
class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin): class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin):
_name: str
mac_address: str | None mac_address: str | None
wwn: str | None wwn: str | None
parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None
@ -465,7 +461,7 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi
@strawberry_django.field @strawberry_django.field
def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
return self._clusters.all() return self.cluster_set.all()
@strawberry_django.field @strawberry_django.field
def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
@ -527,7 +523,7 @@ class ModuleBayType(ModularComponentType):
filters=ModuleBayTemplateFilter filters=ModuleBayTemplateFilter
) )
class ModuleBayTemplateType(ModularComponentTemplateType): class ModuleBayTemplateType(ModularComponentTemplateType):
_name: str pass
@strawberry_django.type( @strawberry_django.type(
@ -588,7 +584,6 @@ class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin
filters=PowerOutletTemplateFilter filters=PowerOutletTemplateFilter
) )
class PowerOutletTemplateType(ModularComponentTemplateType): class PowerOutletTemplateType(ModularComponentTemplateType):
_name: str
power_port: Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')] | None power_port: Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')] | None
@ -620,8 +615,6 @@ class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin):
filters=PowerPortTemplateFilter filters=PowerPortTemplateFilter
) )
class PowerPortTemplateType(ModularComponentTemplateType): class PowerPortTemplateType(ModularComponentTemplateType):
_name: str
poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
@ -640,7 +633,6 @@ class RackTypeType(NetBoxObjectType):
filters=RackFilter filters=RackFilter
) )
class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
_name: str
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]
location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@ -693,7 +685,6 @@ class RearPortType(ModularComponentType, CabledObjectMixin):
filters=RearPortTemplateFilter filters=RearPortTemplateFilter
) )
class RearPortTemplateType(ModularComponentTemplateType): class RearPortTemplateType(ModularComponentTemplateType):
_name: str
color: str color: str
frontport_templates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] frontport_templates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
@ -716,7 +707,7 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
@strawberry_django.field @strawberry_django.field
def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
return self._clusters.all() return self.cluster_set.all()
@strawberry_django.field @strawberry_django.field
def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
@ -729,7 +720,6 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
filters=SiteFilter filters=SiteFilter
) )
class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
_name: str
time_zone: str | None time_zone: str | None
region: Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None region: Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None
group: Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None group: Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None
@ -749,7 +739,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje
@strawberry_django.field @strawberry_django.field
def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
return self._clusters.all() return self.cluster_set.all()
@strawberry_django.field @strawberry_django.field
def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
@ -773,7 +763,7 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
@strawberry_django.field @strawberry_django.field
def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
return self._clusters.all() return self.cluster_set.all()
@strawberry_django.field @strawberry_django.field
def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:

View File

@ -0,0 +1,17 @@
from django.contrib.postgres.operations import CreateCollation
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0196_qinq_svlan'),
]
operations = [
CreateCollation(
"natural_sort",
provider="icu",
locale="und-u-kn-true",
),
]

View File

@ -0,0 +1,318 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0197_natural_sort_collation'),
]
operations = [
migrations.AlterModelOptions(
name='site',
options={'ordering': ('name',)},
),
migrations.AlterField(
model_name='site',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
migrations.AlterModelOptions(
name='consoleport',
options={'ordering': ('device', 'name')},
),
migrations.AlterModelOptions(
name='consoleporttemplate',
options={'ordering': ('device_type', 'module_type', 'name')},
),
migrations.AlterModelOptions(
name='consoleserverport',
options={'ordering': ('device', 'name')},
),
migrations.AlterModelOptions(
name='consoleserverporttemplate',
options={'ordering': ('device_type', 'module_type', 'name')},
),
migrations.AlterModelOptions(
name='device',
options={'ordering': ('name', 'pk')},
),
migrations.AlterModelOptions(
name='devicebay',
options={'ordering': ('device', 'name')},
),
migrations.AlterModelOptions(
name='devicebaytemplate',
options={'ordering': ('device_type', 'name')},
),
migrations.AlterModelOptions(
name='frontport',
options={'ordering': ('device', 'name')},
),
migrations.AlterModelOptions(
name='frontporttemplate',
options={'ordering': ('device_type', 'module_type', 'name')},
),
migrations.AlterModelOptions(
name='interfacetemplate',
options={'ordering': ('device_type', 'module_type', 'name')},
),
migrations.AlterModelOptions(
name='inventoryitem',
options={'ordering': ('device__id', 'parent__id', 'name')},
),
migrations.AlterModelOptions(
name='inventoryitemtemplate',
options={'ordering': ('device_type__id', 'parent__id', 'name')},
),
migrations.AlterModelOptions(
name='modulebay',
options={'ordering': ('device', 'name')},
),
migrations.AlterModelOptions(
name='modulebaytemplate',
options={'ordering': ('device_type', 'module_type', 'name')},
),
migrations.AlterModelOptions(
name='poweroutlet',
options={'ordering': ('device', 'name')},
),
migrations.AlterModelOptions(
name='poweroutlettemplate',
options={'ordering': ('device_type', 'module_type', 'name')},
),
migrations.AlterModelOptions(
name='powerport',
options={'ordering': ('device', 'name')},
),
migrations.AlterModelOptions(
name='powerporttemplate',
options={'ordering': ('device_type', 'module_type', 'name')},
),
migrations.AlterModelOptions(
name='rack',
options={'ordering': ('site', 'location', 'name', 'pk')},
),
migrations.AlterModelOptions(
name='rearport',
options={'ordering': ('device', 'name')},
),
migrations.AlterModelOptions(
name='rearporttemplate',
options={'ordering': ('device_type', 'module_type', 'name')},
),
migrations.RemoveField(
model_name='consoleport',
name='_name',
),
migrations.RemoveField(
model_name='consoleporttemplate',
name='_name',
),
migrations.RemoveField(
model_name='consoleserverport',
name='_name',
),
migrations.RemoveField(
model_name='consoleserverporttemplate',
name='_name',
),
migrations.RemoveField(
model_name='device',
name='_name',
),
migrations.RemoveField(
model_name='devicebay',
name='_name',
),
migrations.RemoveField(
model_name='devicebaytemplate',
name='_name',
),
migrations.RemoveField(
model_name='frontport',
name='_name',
),
migrations.RemoveField(
model_name='frontporttemplate',
name='_name',
),
migrations.RemoveField(
model_name='inventoryitem',
name='_name',
),
migrations.RemoveField(
model_name='inventoryitemtemplate',
name='_name',
),
migrations.RemoveField(
model_name='modulebay',
name='_name',
),
migrations.RemoveField(
model_name='modulebaytemplate',
name='_name',
),
migrations.RemoveField(
model_name='poweroutlet',
name='_name',
),
migrations.RemoveField(
model_name='poweroutlettemplate',
name='_name',
),
migrations.RemoveField(
model_name='powerport',
name='_name',
),
migrations.RemoveField(
model_name='powerporttemplate',
name='_name',
),
migrations.RemoveField(
model_name='rack',
name='_name',
),
migrations.RemoveField(
model_name='rearport',
name='_name',
),
migrations.RemoveField(
model_name='rearporttemplate',
name='_name',
),
migrations.RemoveField(
model_name='site',
name='_name',
),
migrations.AlterField(
model_name='consoleport',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='consoleporttemplate',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='consoleserverport',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='consoleserverporttemplate',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='device',
name='name',
field=models.CharField(blank=True, db_collation='natural_sort', max_length=64, null=True),
),
migrations.AlterField(
model_name='devicebay',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='devicebaytemplate',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='frontport',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='frontporttemplate',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='interface',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='interfacetemplate',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='inventoryitem',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='inventoryitemtemplate',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='modulebay',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='modulebaytemplate',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='poweroutlet',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='poweroutlettemplate',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='powerport',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='powerporttemplate',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='rack',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100),
),
migrations.AlterField(
model_name='rearport',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='rearporttemplate',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='powerfeed',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100),
),
migrations.AlterField(
model_name='powerpanel',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100),
),
migrations.AlterField(
model_name='virtualchassis',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='virtualdevicecontext',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
]

View File

@ -44,12 +44,8 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
max_length=64, max_length=64,
help_text=_( help_text=_(
"{module} is accepted as a substitution for the module bay position when attached to a module type." "{module} is accepted as a substitution for the module bay position when attached to a module type."
) ),
) db_collation="natural_sort"
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
) )
label = models.CharField( label = models.CharField(
verbose_name=_('label'), verbose_name=_('label'),
@ -65,7 +61,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
class Meta: class Meta:
abstract = True abstract = True
ordering = ('device_type', '_name') ordering = ('device_type', 'name')
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('device_type', 'name'), fields=('device_type', 'name'),
@ -125,7 +121,7 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
class Meta: class Meta:
abstract = True abstract = True
ordering = ('device_type', 'module_type', '_name') ordering = ('device_type', 'module_type', 'name')
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('device_type', 'name'), fields=('device_type', 'name'),
@ -782,7 +778,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
component_model = InventoryItem component_model = InventoryItem
class Meta: class Meta:
ordering = ('device_type__id', 'parent__id', '_name') ordering = ('device_type__id', 'parent__id', 'name')
indexes = ( indexes = (
models.Index(fields=('component_type', 'component_id')), models.Index(fields=('component_type', 'component_id')),
) )

View File

@ -50,12 +50,8 @@ class ComponentModel(NetBoxModel):
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=64 max_length=64,
) db_collation="natural_sort"
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
) )
label = models.CharField( label = models.CharField(
verbose_name=_('label'), verbose_name=_('label'),
@ -71,7 +67,7 @@ class ComponentModel(NetBoxModel):
class Meta: class Meta:
abstract = True abstract = True
ordering = ('device', '_name') ordering = ('device', 'name')
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('device', 'name'), fields=('device', 'name'),
@ -1301,7 +1297,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
clone_fields = ('device', 'parent', 'role', 'manufacturer', 'status', 'part_id') clone_fields = ('device', 'parent', 'role', 'manufacturer', 'status', 'part_id')
class Meta: class Meta:
ordering = ('device__id', 'parent__id', '_name') ordering = ('device__id', 'parent__id', 'name')
indexes = ( indexes = (
models.Index(fields=('component_type', 'component_id')), models.Index(fields=('component_type', 'component_id')),
) )

View File

@ -23,7 +23,7 @@ from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from netbox.models.mixins import WeightMixin from netbox.models.mixins import WeightMixin
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField from utilities.fields import ColorField, CounterCacheField
from utilities.tracking import TrackingModelMixin from utilities.tracking import TrackingModelMixin
from .device_components import * from .device_components import *
from .mixins import RenderConfigMixin from .mixins import RenderConfigMixin
@ -582,13 +582,8 @@ class Device(
verbose_name=_('name'), verbose_name=_('name'),
max_length=64, max_length=64,
blank=True, blank=True,
null=True null=True,
) db_collation="natural_sort"
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True,
null=True
) )
serial = models.CharField( serial = models.CharField(
max_length=50, max_length=50,
@ -775,7 +770,7 @@ class Device(
) )
class Meta: class Meta:
ordering = ('_name', 'pk') # Name may be null ordering = ('name', 'pk') # Name may be null
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
Lower('name'), 'site', 'tenant', Lower('name'), 'site', 'tenant',
@ -1320,7 +1315,8 @@ class VirtualChassis(PrimaryModel):
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=64 max_length=64,
db_collation="natural_sort"
) )
domain = models.CharField( domain = models.CharField(
verbose_name=_('domain'), verbose_name=_('domain'),
@ -1382,7 +1378,8 @@ class VirtualDeviceContext(PrimaryModel):
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=64 max_length=64,
db_collation="natural_sort"
) )
status = models.CharField( status = models.CharField(
verbose_name=_('status'), verbose_name=_('status'),

View File

@ -59,28 +59,24 @@ class CachedScopeMixin(models.Model):
_location = models.ForeignKey( _location = models.ForeignKey(
to='dcim.Location', to='dcim.Location',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='_%(class)ss',
blank=True, blank=True,
null=True null=True
) )
_site = models.ForeignKey( _site = models.ForeignKey(
to='dcim.Site', to='dcim.Site',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='_%(class)ss',
blank=True, blank=True,
null=True null=True
) )
_region = models.ForeignKey( _region = models.ForeignKey(
to='dcim.Region', to='dcim.Region',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='_%(class)ss',
blank=True, blank=True,
null=True null=True
) )
_site_group = models.ForeignKey( _site_group = models.ForeignKey(
to='dcim.SiteGroup', to='dcim.SiteGroup',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='_%(class)ss',
blank=True, blank=True,
null=True null=True
) )

View File

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

View File

@ -19,7 +19,7 @@ from netbox.models.mixins import WeightMixin
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.conversion import to_grams from utilities.conversion import to_grams
from utilities.data import array_to_string, drange from utilities.data import array_to_string, drange
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField
from .device_components import PowerPort from .device_components import PowerPort
from .devices import Device, Module from .devices import Device, Module
from .power import PowerFeed from .power import PowerFeed
@ -255,12 +255,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100
)
_name = NaturalOrderingField(
target_field='name',
max_length=100, max_length=100,
blank=True db_collation="natural_sort"
) )
facility_id = models.CharField( facility_id = models.CharField(
max_length=50, max_length=50,
@ -340,7 +336,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
) )
class Meta: class Meta:
ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique ordering = ('site', 'location', 'name', 'pk') # (site, location, name) may be non-unique
constraints = ( constraints = (
# Name and facility_id must be unique *only* within a Location # Name and facility_id must be unique *only* within a Location
models.UniqueConstraint( models.UniqueConstraint(

View File

@ -8,7 +8,6 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from netbox.models import NestedGroupModel, PrimaryModel from netbox.models import NestedGroupModel, PrimaryModel
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.fields import NaturalOrderingField
__all__ = ( __all__ = (
'Location', 'Location',
@ -143,12 +142,8 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
verbose_name=_('name'), verbose_name=_('name'),
max_length=100, max_length=100,
unique=True, unique=True,
help_text=_("Full name of the site") help_text=_("Full name of the site"),
) db_collation="natural_sort"
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
) )
slug = models.SlugField( slug = models.SlugField(
verbose_name=_('slug'), verbose_name=_('slug'),
@ -245,7 +240,7 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
) )
class Meta: class Meta:
ordering = ('_name',) ordering = ('name',)
verbose_name = _('site') verbose_name = _('site')
verbose_name_plural = _('sites') verbose_name_plural = _('sites')

View File

@ -132,7 +132,6 @@ class PlatformTable(NetBoxTable):
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.TemplateColumn( name = tables.TemplateColumn(
verbose_name=_('Name'), verbose_name=_('Name'),
order_by=('_name',),
template_code=DEVICE_LINK, template_code=DEVICE_LINK,
linkify=True linkify=True
) )
@ -288,7 +287,6 @@ class DeviceComponentTable(NetBoxTable):
name = tables.Column( name = tables.Column(
verbose_name=_('Name'), verbose_name=_('Name'),
linkify=True, linkify=True,
order_by=('_name',)
) )
device_status = columns.ChoiceFieldColumn( device_status = columns.ChoiceFieldColumn(
accessor=tables.A('device__status'), accessor=tables.A('device__status'),
@ -391,7 +389,6 @@ class DeviceConsolePortTable(ConsolePortTable):
name = tables.TemplateColumn( name = tables.TemplateColumn(
verbose_name=_('Name'), verbose_name=_('Name'),
template_code='<i class="mdi mdi-console"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>', template_code='<i class="mdi mdi-console"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
@ -433,7 +430,6 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
verbose_name=_('Name'), verbose_name=_('Name'),
template_code='<i class="mdi mdi-console-network-outline"></i> ' template_code='<i class="mdi mdi-console-network-outline"></i> '
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>', '<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
@ -482,7 +478,6 @@ class DevicePowerPortTable(PowerPortTable):
verbose_name=_('Name'), verbose_name=_('Name'),
template_code='<i class="mdi mdi-power-plug-outline"></i> <a href="{{ record.get_absolute_url }}">' template_code='<i class="mdi mdi-power-plug-outline"></i> <a href="{{ record.get_absolute_url }}">'
'{{ value }}</a>', '{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
@ -531,7 +526,6 @@ class DevicePowerOutletTable(PowerOutletTable):
name = tables.TemplateColumn( name = tables.TemplateColumn(
verbose_name=_('Name'), verbose_name=_('Name'),
template_code='<i class="mdi mdi-power-socket"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>', template_code='<i class="mdi mdi-power-socket"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
@ -550,6 +544,11 @@ class DevicePowerOutletTable(PowerOutletTable):
class BaseInterfaceTable(NetBoxTable): class BaseInterfaceTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True,
order_by=('_name',)
)
enabled = columns.BooleanColumn( enabled = columns.BooleanColumn(
verbose_name=_('Enabled'), verbose_name=_('Enabled'),
) )
@ -597,7 +596,7 @@ class BaseInterfaceTable(NetBoxTable):
return ",".join([str(obj) for obj in value.all()]) return ",".join([str(obj) for obj in value.all()])
class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable): class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpointTable):
device = tables.Column( device = tables.Column(
verbose_name=_('Device'), verbose_name=_('Device'),
linkify={ linkify={
@ -736,7 +735,6 @@ class DeviceFrontPortTable(FrontPortTable):
verbose_name=_('Name'), verbose_name=_('Name'),
template_code='<i class="mdi mdi-square-rounded{% if not record.cable %}-outline{% endif %}"></i> ' template_code='<i class="mdi mdi-square-rounded{% if not record.cable %}-outline{% endif %}"></i> '
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>', '<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
@ -783,7 +781,6 @@ class DeviceRearPortTable(RearPortTable):
verbose_name=_('Name'), verbose_name=_('Name'),
template_code='<i class="mdi mdi-square-rounded{% if not record.cable %}-outline{% endif %}"></i> ' template_code='<i class="mdi mdi-square-rounded{% if not record.cable %}-outline{% endif %}"></i> '
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>', '<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
@ -846,7 +843,6 @@ class DeviceDeviceBayTable(DeviceBayTable):
verbose_name=_('Name'), verbose_name=_('Name'),
template_code='<i class="mdi mdi-circle{% if record.installed_device %}slice-8{% else %}outline{% endif %}' template_code='<i class="mdi mdi-circle{% if record.installed_device %}slice-8{% else %}outline{% endif %}'
'"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>', '"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
@ -915,7 +911,6 @@ class DeviceModuleBayTable(ModuleBayTable):
name = columns.MPTTColumn( name = columns.MPTTColumn(
verbose_name=_('Name'), verbose_name=_('Name'),
linkify=True, linkify=True,
order_by=Accessor('_name')
) )
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
extra_buttons=MODULEBAY_BUTTONS extra_buttons=MODULEBAY_BUTTONS
@ -982,7 +977,6 @@ class DeviceInventoryItemTable(InventoryItemTable):
verbose_name=_('Name'), verbose_name=_('Name'),
template_code='<a href="{{ record.get_absolute_url }}" style="padding-left: {{ record.level }}0px">' template_code='<a href="{{ record.get_absolute_url }}" style="padding-left: {{ record.level }}0px">'
'{{ value }}</a>', '{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )

View File

@ -163,9 +163,7 @@ class ComponentTemplateTable(NetBoxTable):
id = tables.Column( id = tables.Column(
verbose_name=_('ID') verbose_name=_('ID')
) )
name = tables.Column( name = tables.Column()
order_by=('_name',)
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
exclude = ('id', ) exclude = ('id', )
@ -220,6 +218,10 @@ class PowerOutletTemplateTable(ComponentTemplateTable):
class InterfaceTemplateTable(ComponentTemplateTable): class InterfaceTemplateTable(ComponentTemplateTable):
name = tables.Column(
verbose_name=_('Name'),
order_by=('_name',)
)
enabled = columns.BooleanColumn( enabled = columns.BooleanColumn(
verbose_name=_('Enabled'), verbose_name=_('Enabled'),
) )

View File

@ -111,7 +111,6 @@ class RackTypeTable(NetBoxTable):
class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.Column( name = tables.Column(
verbose_name=_('Name'), verbose_name=_('Name'),
order_by=('_name',),
linkify=True linkify=True
) )
location = tables.Column( location = tables.Column(

View File

@ -688,8 +688,7 @@ class RackElevationListView(generic.ObjectListView):
sort = request.GET.get('sort', 'name') sort = request.GET.get('sort', 'name')
if sort not in ORDERING_CHOICES: if sort not in ORDERING_CHOICES:
sort = 'name' sort = 'name'
sort_field = sort.replace("name", "_name") # Use natural ordering racks = racks.order_by(sort)
racks = racks.order_by(sort_field)
# Pagination # Pagination
per_page = get_paginate_count(request) per_page = get_paginate_count(request)
@ -731,8 +730,8 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView):
peer_racks = peer_racks.filter(location=instance.location) peer_racks = peer_racks.filter(location=instance.location)
else: else:
peer_racks = peer_racks.filter(location__isnull=True) peer_racks = peer_racks.filter(location__isnull=True)
next_rack = peer_racks.filter(_name__gt=instance._name).first() next_rack = peer_racks.filter(name__gt=instance.name).first()
prev_rack = peer_racks.filter(_name__lt=instance._name).reverse().first() prev_rack = peer_racks.filter(name__lt=instance.name).reverse().first()
# Determine any additional parameters to pass when embedding the rack elevations # Determine any additional parameters to pass when embedding the rack elevations
svg_extra = '&'.join([ svg_extra = '&'.join([

View File

@ -223,7 +223,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
from extras.scripts import get_module_and_script from extras.scripts import get_module_and_script
module_name, script_name = action_object.split('.', 1) module_name, script_name = action_object.split('.', 1)
try: try:
module, script = get_module_and_script(module_name, script_name) script = get_module_and_script(module_name, script_name)[1]
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise forms.ValidationError(_("Script {name} not found").format(name=action_object)) raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
self.instance.action_object = script self.instance.action_object = script

View File

@ -38,7 +38,7 @@ class Command(BaseCommand):
data = {} data = {}
module_name, script_name = script.split('.', 1) module_name, script_name = script.split('.', 1)
module, script_obj = get_module_and_script(module_name, script_name) script_obj = get_module_and_script(module_name, script_name)[1]
script = script_obj.python_class script = script_obj.python_class
# Take user from command line if provided and exists, other # Take user from command line if provided and exists, other

View File

@ -30,7 +30,7 @@ def get_python_name(scriptmodule):
""" """
Return the Python name of a ScriptModule's file on disk. Return the Python name of a ScriptModule's file on disk.
""" """
path, filename = os.path.split(scriptmodule.file_path) filename = os.path.split(scriptmodule.file_path)[0]
return os.path.splitext(filename)[0] return os.path.splitext(filename)[0]
@ -128,7 +128,7 @@ def update_event_rules(apps, schema_editor):
for eventrule in EventRule.objects.filter(action_object_type=scriptmodule_ct): for eventrule in EventRule.objects.filter(action_object_type=scriptmodule_ct):
name = eventrule.action_parameters.get('script_name') name = eventrule.action_parameters.get('script_name')
obj, created = Script.objects.get_or_create( obj, __ = Script.objects.get_or_create(
module_id=eventrule.action_object_id, module_id=eventrule.action_object_id,
name=name, name=name,
defaults={'is_executable': False} defaults={'is_executable': False}

View File

@ -2,8 +2,9 @@ from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from dcim.constants import LOCATION_SCOPE_TYPES
from ipam.choices import * from ipam.choices import *
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, PREFIX_SCOPE_TYPES from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
from ipam.models import Aggregate, IPAddress, IPRange, Prefix from ipam.models import Aggregate, IPAddress, IPRange, Prefix
from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer
@ -47,7 +48,7 @@ class PrefixSerializer(NetBoxModelSerializer):
vrf = VRFSerializer(nested=True, required=False, allow_null=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True)
scope_type = ContentTypeField( scope_type = ContentTypeField(
queryset=ContentType.objects.filter( queryset=ContentType.objects.filter(
model__in=PREFIX_SCOPE_TYPES model__in=LOCATION_SCOPE_TYPES
), ),
allow_null=True, allow_null=True,
required=False, required=False,

View File

@ -18,7 +18,7 @@ class IPAMConfig(AppConfig):
# Register denormalized fields # Register denormalized fields
denormalized.register(Prefix, '_site', { denormalized.register(Prefix, '_site', {
'_region': 'region', '_region': 'region',
'_sitegroup': 'group', '_site_group': 'group',
}) })
denormalized.register(Prefix, '_location', { denormalized.register(Prefix, '_location', {
'_site': 'site', '_site': 'site',

View File

@ -23,11 +23,6 @@ VRF_RD_MAX_LENGTH = 21
PREFIX_LENGTH_MIN = 1 PREFIX_LENGTH_MIN = 1
PREFIX_LENGTH_MAX = 127 # IPv6 PREFIX_LENGTH_MAX = 127 # IPv6
# models values for ContentTypes which may be Prefix scope types
PREFIX_SCOPE_TYPES = (
'region', 'sitegroup', 'site', 'location',
)
# #
# IPAddresses # IPAddresses

View File

@ -1,5 +1,6 @@
import django_filters import django_filters
import netaddr import netaddr
from dcim.base_filtersets import ScopedFilterSet
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
@ -9,7 +10,7 @@ from drf_spectacular.utils import extend_schema_field
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
from circuits.models import Provider from circuits.models import Provider
from dcim.models import Device, Interface, Location, Region, Site, SiteGroup from dcim.models import Device, Interface, Region, Site, SiteGroup
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from utilities.filters import ( from utilities.filters import (
@ -273,7 +274,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'description', 'weight') fields = ('id', 'name', 'slug', 'description', 'weight')
class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
family = django_filters.NumberFilter( family = django_filters.NumberFilter(
field_name='prefix', field_name='prefix',
lookup_expr='family' lookup_expr='family'
@ -334,57 +335,6 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='rd', to_field_name='rd',
label=_('VRF (RD)'), label=_('VRF (RD)'),
) )
scope_type = ContentTypeFilter()
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
to_field_name='slug',
label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='_sitegroup',
lookup_expr='in',
label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='_sitegroup',
lookup_expr='in',
to_field_name='slug',
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
field_name='_site',
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='_site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site (slug)'),
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
to_field_name='slug',
label=_('Location (slug)'),
)
vlan_id = django_filters.ModelMultipleChoiceFilter( vlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
label=_('VLAN (ID)'), label=_('VLAN (ID)'),

View File

@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.forms.mixins import ScopedBulkEditForm
from dcim.models import Region, Site, SiteGroup from dcim.models import Region, Site, SiteGroup
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
@ -205,20 +206,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = ('description',) nullable_fields = ('description',)
class PrefixBulkEditForm(NetBoxModelBulkEditForm): class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):
scope_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
required=False,
label=_('Scope type')
)
scope = DynamicModelChoiceField(
label=_('Scope'),
queryset=Site.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True
)
vlan_group = DynamicModelChoiceField( vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
required=False, required=False,
@ -286,20 +274,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
'vlan', 'vrf', 'tenant', 'role', 'scope', 'description', 'comments', 'vlan', 'vrf', 'tenant', 'role', 'scope', 'description', 'comments',
) )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if scope_type_id := get_field_value(self, 'scope_type'):
try:
scope_type = ContentType.objects.get(pk=scope_type_id)
model = scope_type.model_class()
self.fields['scope'].queryset = model.objects.all()
self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
self.fields['scope'].disabled = False
self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
class IPRangeBulkEditForm(NetBoxModelBulkEditForm): class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(

View File

@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.models import Device, Interface, Site from dcim.models import Device, Interface, Site
from dcim.forms.mixins import ScopedImportForm
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.models import * from ipam.models import *
@ -154,7 +155,7 @@ class RoleImportForm(NetBoxModelImportForm):
fields = ('name', 'slug', 'weight', 'description', 'tags') fields = ('name', 'slug', 'weight', 'description', 'tags')
class PrefixImportForm(NetBoxModelImportForm): class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
vrf = CSVModelChoiceField( vrf = CSVModelChoiceField(
label=_('VRF'), label=_('VRF'),
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
@ -169,11 +170,6 @@ class PrefixImportForm(NetBoxModelImportForm):
to_field_name='name', to_field_name='name',
help_text=_('Assigned tenant') help_text=_('Assigned tenant')
) )
scope_type = CSVContentTypeField(
queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES),
required=False,
label=_('Scope type (app & model)')
)
vlan_group = CSVModelChoiceField( vlan_group = CSVModelChoiceField(
label=_('VLAN group'), label=_('VLAN group'),
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
@ -208,7 +204,7 @@ class PrefixImportForm(NetBoxModelImportForm):
'mark_utilized', 'description', 'comments', 'tags', 'mark_utilized', 'description', 'comments', 'tags',
) )
labels = { labels = {
'scope_id': 'Scope ID', 'scope_id': _('Scope ID'),
} }
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):

View File

@ -4,6 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.models import Device, Interface, Site from dcim.models import Device, Interface, Site
from dcim.forms.mixins import ScopedForm
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.formfields import IPNetworkFormField from ipam.formfields import IPNetworkFormField
@ -197,25 +198,12 @@ class RoleForm(NetBoxModelForm):
] ]
class PrefixForm(TenancyForm, NetBoxModelForm): class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
label=_('VRF') label=_('VRF')
) )
scope_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES),
widget=HTMXSelect(),
required=False,
label=_('Scope type')
)
scope = DynamicModelChoiceField(
label=_('Scope'),
queryset=Site.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True
)
vlan = DynamicModelChoiceField( vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
@ -248,36 +236,6 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
'tenant', 'description', 'comments', 'tags', 'tenant', 'description', 'comments', 'tags',
] ]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance is not None and instance.scope:
initial['scope'] = instance.scope
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
if scope_type_id := get_field_value(self, 'scope_type'):
try:
scope_type = ContentType.objects.get(pk=scope_type_id)
model = scope_type.model_class()
self.fields['scope'].queryset = model.objects.all()
self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
self.fields['scope'].disabled = False
self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
if self.instance and scope_type_id != self.instance.scope_type_id:
self.initial['scope'] = None
def clean(self):
super().clean()
# Assign the selected scope (if any)
self.instance.scope = self.cleaned_data.get('scope')
class IPRangeForm(TenancyForm, NetBoxModelForm): class IPRangeForm(TenancyForm, NetBoxModelForm):
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(

View File

@ -154,7 +154,7 @@ class IPRangeType(NetBoxObjectType):
@strawberry_django.type( @strawberry_django.type(
models.Prefix, models.Prefix,
exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_sitegroup'), exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
filters=PrefixFilter filters=PrefixFilter
) )
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType): class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):

View File

@ -108,8 +108,8 @@ class NetIn(Lookup):
return self.rhs return self.rhs
def as_sql(self, qn, connection): def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection) lhs = self.process_lhs(qn, connection)[0]
rhs, rhs_params = self.process_rhs(qn, connection) rhs_params = self.process_rhs(qn, connection)[1]
with_mask, without_mask = [], [] with_mask, without_mask = [], []
for address in rhs_params[0]: for address in rhs_params[0]:
if '/' in address: if '/' in address:

View File

@ -11,11 +11,11 @@ def populate_denormalized_fields(apps, schema_editor):
prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site') prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site')
for prefix in prefixes: for prefix in prefixes:
prefix._region_id = prefix.site.region_id prefix._region_id = prefix.site.region_id
prefix._sitegroup_id = prefix.site.group_id prefix._site_group_id = prefix.site.group_id
prefix._site_id = prefix.site_id prefix._site_id = prefix.site_id
# Note: Location cannot be set prior to migration # Note: Location cannot be set prior to migration
Prefix.objects.bulk_update(prefixes, ['_region', '_sitegroup', '_site']) Prefix.objects.bulk_update(prefixes, ['_region', '_site_group', '_site'])
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -29,22 +29,22 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='prefix', model_name='prefix',
name='_location', name='_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.location'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location'),
), ),
migrations.AddField( migrations.AddField(
model_name='prefix', model_name='prefix',
name='_region', name='_region',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.region'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region'),
), ),
migrations.AddField( migrations.AddField(
model_name='prefix', model_name='prefix',
name='_site', name='_site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.site'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'),
), ),
migrations.AddField( migrations.AddField(
model_name='prefix', model_name='prefix',
name='_sitegroup', name='_site_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.sitegroup'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup'),
), ),
# Populate denormalized FK values # Populate denormalized FK values

View File

@ -0,0 +1,32 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0075_vlan_qinq'),
('dcim', '0197_natural_sort_collation'),
]
operations = [
migrations.AlterField(
model_name='asnrange',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='routetarget',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=21, unique=True),
),
migrations.AlterField(
model_name='vlangroup',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100),
),
migrations.AlterField(
model_name='vrf',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100),
),
]

View File

@ -16,7 +16,8 @@ class ASNRange(OrganizationalModel):
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100, max_length=100,
unique=True unique=True,
db_collation="natural_sort"
) )
slug = models.SlugField( slug = models.SlugField(
verbose_name=_('slug'), verbose_name=_('slug'),

View File

@ -1,5 +1,4 @@
import netaddr import netaddr
from django.apps import apps
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@ -9,6 +8,7 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType from core.models import ObjectType
from dcim.models.mixins import CachedScopeMixin
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.fields import IPNetworkField, IPAddressField from ipam.fields import IPNetworkField, IPAddressField
@ -198,7 +198,7 @@ class Role(OrganizationalModel):
return self.name return self.name
class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, PrimaryModel):
""" """
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be scoped to certain A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be scoped to certain
areas and/or assigned to VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. areas and/or assigned to VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role.
@ -208,22 +208,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
verbose_name=_('prefix'), verbose_name=_('prefix'),
help_text=_('IPv4 or IPv6 network with mask') help_text=_('IPv4 or IPv6 network with mask')
) )
scope_type = models.ForeignKey(
to='contenttypes.ContentType',
on_delete=models.PROTECT,
limit_choices_to=Q(model__in=PREFIX_SCOPE_TYPES),
related_name='+',
blank=True,
null=True
)
scope_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
scope = GenericForeignKey(
ct_field='scope_type',
fk_field='scope_id'
)
vrf = models.ForeignKey( vrf = models.ForeignKey(
to='ipam.VRF', to='ipam.VRF',
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -272,36 +256,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
help_text=_("Treat as fully utilized") help_text=_("Treat as fully utilized")
) )
# Cached associations to enable efficient filtering
_location = models.ForeignKey(
to='dcim.Location',
on_delete=models.CASCADE,
related_name='_prefixes',
blank=True,
null=True
)
_site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
related_name='_prefixes',
blank=True,
null=True
)
_region = models.ForeignKey(
to='dcim.Region',
on_delete=models.CASCADE,
related_name='_prefixes',
blank=True,
null=True
)
_sitegroup = models.ForeignKey(
to='dcim.SiteGroup',
on_delete=models.CASCADE,
related_name='_prefixes',
blank=True,
null=True
)
# Cached depth & child counts # Cached depth & child counts
_depth = models.PositiveSmallIntegerField( _depth = models.PositiveSmallIntegerField(
default=0, default=0,
@ -368,25 +322,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def cache_related_objects(self):
self._region = self._sitegroup = self._site = self._location = None
if self.scope_type:
scope_type = self.scope_type.model_class()
if scope_type == apps.get_model('dcim', 'region'):
self._region = self.scope
elif scope_type == apps.get_model('dcim', 'sitegroup'):
self._sitegroup = self.scope
elif scope_type == apps.get_model('dcim', 'site'):
self._region = self.scope.region
self._sitegroup = self.scope.group
self._site = self.scope
elif scope_type == apps.get_model('dcim', 'location'):
self._region = self.scope.site.region
self._sitegroup = self.scope.site.group
self._site = self.scope.site
self._location = self.scope
cache_related_objects.alters_data = True
@property @property
def family(self): def family(self):
return self.prefix.version if self.prefix else None return self.prefix.version if self.prefix else None

View File

@ -35,7 +35,8 @@ class VLANGroup(OrganizationalModel):
""" """
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100 max_length=100,
db_collation="natural_sort"
) )
slug = models.SlugField( slug = models.SlugField(
verbose_name=_('slug'), verbose_name=_('slug'),

View File

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

View File

@ -42,7 +42,7 @@ class PrefixOrderingTestCase(OrderingTestBase):
""" """
This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs
""" """
vrf1, vrf2, vrf3 = list(VRF.objects.all()) vrf1, vrf2 = VRF.objects.all()[:2]
prefixes = ( prefixes = (
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/16')), Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/24')), Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/24')),
@ -106,7 +106,7 @@ class PrefixOrderingTestCase(OrderingTestBase):
VRF A:10.1.1.0/24 VRF A:10.1.1.0/24
None: 192.168.0.0/16 None: 192.168.0.0/16
""" """
vrf1, vrf2, vrf3 = list(VRF.objects.all()) vrf1 = VRF.objects.first()
prefixes = [ prefixes = [
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/8')), Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/8')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/16')), Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/16')),
@ -130,7 +130,7 @@ class IPAddressOrderingTestCase(OrderingTestBase):
""" """
This function tests ordering with the inclusion of vrfs This function tests ordering with the inclusion of vrfs
""" """
vrf1, vrf2, vrf3 = list(VRF.objects.all()) vrf1, vrf2 = VRF.objects.all()[:2]
addresses = ( addresses = (
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.0.1/24')), IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.1.1/24')), IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.1.1/24')),

View File

@ -107,7 +107,7 @@ class ObjectPermissionMixin:
return perms return perms
def has_perm(self, user_obj, perm, obj=None): def has_perm(self, user_obj, perm, obj=None):
app_label, action, model_name = resolve_permission(perm) app_label, __, model_name = resolve_permission(perm)
# Superusers implicitly have all permissions # Superusers implicitly have all permissions
if user_obj.is_active and user_obj.is_superuser: if user_obj.is_active and user_obj.is_superuser:

View File

@ -213,7 +213,6 @@ class PluginTest(TestCase):
self.assertEqual(get_plugin_config(plugin, 'bar'), None) self.assertEqual(get_plugin_config(plugin, 'bar'), None)
self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456) self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456)
def test_events_pipeline(self): def test_events_pipeline(self):
""" """
Check that events pipeline is registered. Check that events pipeline is registered.

View File

@ -49,7 +49,7 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME):
template = loader.get_template(template_name) template = loader.get_template(template_name)
except TemplateDoesNotExist: except TemplateDoesNotExist:
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html') return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
type_, error, traceback = sys.exc_info() type_, error = sys.exc_info()[:2]
return HttpResponseServerError(template.render({ return HttpResponseServerError(template.render({
'error': error, 'error': error,

View File

@ -22,6 +22,14 @@
<th scope="row">{% trans "Status" %}</th> <th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td> <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Scope" %}</th>
{% if object.scope %}
<td>{{ object.scope|linkify }} ({% trans object.scope_type.name %})</td>
{% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}
</tr>
<tr> <tr>
<th scope="row">{% trans "Description" %}</th> <th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>

View File

@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0016_charfield_null_choices'),
('dcim', '0197_natural_sort_collation'),
]
operations = [
migrations.AlterField(
model_name='contact',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100),
),
migrations.AlterField(
model_name='tenant',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100),
),
migrations.AlterField(
model_name='tenantgroup',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
]

View File

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

View File

@ -18,7 +18,8 @@ class TenantGroup(NestedGroupModel):
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100, max_length=100,
unique=True unique=True,
db_collation="natural_sort"
) )
slug = models.SlugField( slug = models.SlugField(
verbose_name=_('slug'), verbose_name=_('slug'),
@ -39,7 +40,8 @@ class Tenant(ContactsMixin, PrimaryModel):
""" """
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100 max_length=100,
db_collation="natural_sort"
) )
slug = models.SlugField( slug = models.SlugField(
verbose_name=_('slug'), verbose_name=_('slug'),

View File

@ -129,7 +129,7 @@ def get_annotations_for_serializer(serializer_class, fields_to_include=None):
for field_name, field in serializer_class._declared_fields.items(): for field_name, field in serializer_class._declared_fields.items():
if field_name in fields_to_include and type(field) is RelatedObjectCountField: if field_name in fields_to_include and type(field) is RelatedObjectCountField:
related_field = model._meta.get_field(field.relation).field related_field = getattr(model, field.relation).field
annotations[field_name] = count_related(related_field.model, related_field.name) annotations[field_name] = count_related(related_field.model, related_field.name)
return annotations return annotations

View File

@ -49,7 +49,7 @@ def handle_rest_api_exception(request, *args, **kwargs):
""" """
Handle exceptions and return a useful error message for REST API requests. Handle exceptions and return a useful error message for REST API requests.
""" """
type_, error, traceback = sys.exc_info() type_, error = sys.exc_info()[:2]
data = { data = {
'error': str(error), 'error': str(error),
'exception': type_.__name__, 'exception': type_.__name__,

View File

@ -5,7 +5,6 @@ from django.db import models
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from utilities.ordering import naturalize
from .forms.widgets import ColorSelect from .forms.widgets import ColorSelect
from .validators import ColorValidator from .validators import ColorValidator
@ -40,7 +39,7 @@ class NaturalOrderingField(models.CharField):
""" """
description = "Stores a representation of its target field suitable for natural ordering" description = "Stores a representation of its target field suitable for natural ordering"
def __init__(self, target_field, naturalize_function=naturalize, *args, **kwargs): def __init__(self, target_field, naturalize_function, *args, **kwargs):
self.target_field = target_field self.target_field = target_field
self.naturalize_function = naturalize_function self.naturalize_function = naturalize_function
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -83,7 +83,7 @@ class CountersTest(TestCase):
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_mptt_child_delete(self): def test_mptt_child_delete(self):
device1, device2 = Device.objects.all() device1 = Device.objects.first()
inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1') inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1')
InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1) InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1)
device1.refresh_from_db() device1.refresh_from_db()

View File

@ -59,6 +59,14 @@ class ClusterSerializer(NetBoxModelSerializer):
) )
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = serializers.SerializerMethodField(read_only=True) scope = serializers.SerializerMethodField(read_only=True)
allocated_vcpus = serializers.DecimalField(
read_only=True,
max_digits=8,
decimal_places=2,
)
allocated_memory = serializers.IntegerField(read_only=True)
allocated_disk = serializers.IntegerField(read_only=True)
# Related object counts # Related object counts
device_count = RelatedObjectCountField('devices') device_count = RelatedObjectCountField('devices')
@ -69,7 +77,7 @@ class ClusterSerializer(NetBoxModelSerializer):
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'scope_type', 'scope_id', 'scope', 'id', 'url', 'display_url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'scope_type', 'scope_id', 'scope',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'virtualmachine_count', 'virtualmachine_count', 'allocated_vcpus', 'allocated_memory', 'allocated_disk'
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'virtualmachine_count') brief_fields = ('id', 'url', 'display', 'name', 'description', 'virtualmachine_count')
@ -80,5 +88,3 @@ class ClusterSerializer(NetBoxModelSerializer):
serializer = get_serializer_for_model(obj.scope) serializer = get_serializer_for_model(obj.scope)
context = {'request': self.context['request']} context = {'request': self.context['request']}
return serializer(obj.scope, nested=True, context=context).data return serializer(obj.scope, nested=True, context=context).data

View File

@ -1,3 +1,4 @@
from django.db.models import Sum
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
@ -33,7 +34,11 @@ class ClusterGroupViewSet(NetBoxModelViewSet):
class ClusterViewSet(NetBoxModelViewSet): class ClusterViewSet(NetBoxModelViewSet):
queryset = Cluster.objects.all() queryset = Cluster.objects.prefetch_related('virtual_machines').annotate(
allocated_vcpus=Sum('virtual_machines__vcpus'),
allocated_memory=Sum('virtual_machines__memory'),
allocated_disk=Sum('virtual_machines__disk'),
)
serializer_class = serializers.ClusterSerializer serializer_class = serializers.ClusterSerializer
filterset_class = filtersets.ClusterFilterSet filterset_class = filtersets.ClusterFilterSet

View File

@ -2,7 +2,8 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dcim.filtersets import CommonInterfaceFilterSet, ScopedFilterSet from dcim.filtersets import CommonInterfaceFilterSet
from dcim.base_filtersets import ScopedFilterSet
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate from extras.models import ConfigTemplate

View File

@ -25,7 +25,6 @@ class ComponentType(NetBoxObjectType):
""" """
Base type for device/VM components Base type for device/VM components
""" """
_name: str
virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]
@ -48,7 +47,7 @@ class ClusterType(VLANGroupsMixin, NetBoxObjectType):
Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')], Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
Annotated["SiteType", strawberry.lazy('dcim.graphql.types')], Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
], strawberry.union("ClusterScopeType")] | None: ], strawberry.union("ClusterScopeType")] | None:
return self.scope return self.scope
@strawberry_django.type( @strawberry_django.type(
@ -77,7 +76,6 @@ class ClusterTypeType(OrganizationalObjectType):
filters=VirtualMachineFilter filters=VirtualMachineFilter
) )
class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType): class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType):
_name: str
interface_count: BigInt interface_count: BigInt
virtual_disk_count: BigInt virtual_disk_count: BigInt
interface_count: BigInt interface_count: BigInt
@ -102,6 +100,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType):
filters=VMInterfaceFilter filters=VMInterfaceFilter
) )
class VMInterfaceType(IPAddressesMixin, ComponentType): class VMInterfaceType(IPAddressesMixin, ComponentType):
_name: str
mac_address: str | None mac_address: str | None
parent: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None parent: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None

View File

@ -3,17 +3,17 @@ from django.db import migrations, models
def copy_site_assignments(apps, schema_editor): def copy_site_assignments(apps, schema_editor):
""" """
Copy site ForeignKey values to the scope GFK. Copy site ForeignKey values to the scope GFK.
""" """
ContentType = apps.get_model('contenttypes', 'ContentType') ContentType = apps.get_model('contenttypes', 'ContentType')
Cluster = apps.get_model('virtualization', 'Cluster') Cluster = apps.get_model('virtualization', 'Cluster')
Site = apps.get_model('dcim', 'Site') Site = apps.get_model('dcim', 'Site')
Cluster.objects.filter(site__isnull=False).update( Cluster.objects.filter(site__isnull=False).update(
scope_type=ContentType.objects.get_for_model(Site), scope_type=ContentType.objects.get_for_model(Site),
scope_id=models.F('site_id') scope_id=models.F('site_id')
) )
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -0,0 +1,41 @@
# Generated by Django 5.0.9 on 2024-11-14 19:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0196_qinq_svlan'),
('virtualization', '0045_clusters_cached_relations'),
]
operations = [
migrations.AlterField(
model_name='cluster',
name='_location',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location'
),
),
migrations.AlterField(
model_name='cluster',
name='_region',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region'
),
),
migrations.AlterField(
model_name='cluster',
name='_site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'),
),
migrations.AlterField(
model_name='cluster',
name='_site_group',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup'
),
),
]

View File

@ -0,0 +1,43 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0046_alter_cluster__location_alter_cluster__region_and_more'),
('dcim', '0197_natural_sort_collation'),
]
operations = [
migrations.AlterModelOptions(
name='virtualmachine',
options={'ordering': ('name', 'pk')},
),
migrations.AlterModelOptions(
name='virtualdisk',
options={'ordering': ('virtual_machine', 'name')},
),
migrations.RemoveField(
model_name='virtualmachine',
name='_name',
),
migrations.AlterField(
model_name='virtualdisk',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='virtualmachine',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=64),
),
migrations.AlterField(
model_name='cluster',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100),
),
migrations.RemoveField(
model_name='virtualdisk',
name='_name',
),
]

View File

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

View File

@ -69,12 +69,8 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=64 max_length=64,
) db_collation="natural_sort"
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
) )
status = models.CharField( status = models.CharField(
max_length=50, max_length=50,
@ -152,7 +148,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
) )
class Meta: class Meta:
ordering = ('_name', 'pk') # Name may be non-unique ordering = ('name', 'pk') # Name may be non-unique
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
Lower('name'), 'cluster', 'tenant', Lower('name'), 'cluster', 'tenant',
@ -273,13 +269,8 @@ class ComponentModel(NetBoxModel):
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=64 max_length=64,
) db_collation="natural_sort"
_name = NaturalOrderingField(
target_field='name',
naturalize_function=naturalize_interface,
max_length=100,
blank=True
) )
description = models.CharField( description = models.CharField(
verbose_name=_('description'), verbose_name=_('description'),
@ -289,7 +280,6 @@ class ComponentModel(NetBoxModel):
class Meta: class Meta:
abstract = True abstract = True
ordering = ('virtual_machine', CollateAsChar('_name'))
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('virtual_machine', 'name'), fields=('virtual_machine', 'name'),
@ -311,10 +301,9 @@ class ComponentModel(NetBoxModel):
class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
virtual_machine = models.ForeignKey( name = models.CharField(
to='virtualization.VirtualMachine', verbose_name=_('name'),
on_delete=models.CASCADE, max_length=64,
related_name='interfaces' # Override ComponentModel
) )
_name = NaturalOrderingField( _name = NaturalOrderingField(
target_field='name', target_field='name',
@ -322,6 +311,11 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
max_length=100, max_length=100,
blank=True blank=True
) )
virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine',
on_delete=models.CASCADE,
related_name='interfaces' # Override ComponentModel
)
ip_addresses = GenericRelation( ip_addresses = GenericRelation(
to='ipam.IPAddress', to='ipam.IPAddress',
content_type_field='assigned_object_type', content_type_field='assigned_object_type',
@ -358,6 +352,7 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
class Meta(ComponentModel.Meta): class Meta(ComponentModel.Meta):
verbose_name = _('interface') verbose_name = _('interface')
verbose_name_plural = _('interfaces') verbose_name_plural = _('interfaces')
ordering = ('virtual_machine', CollateAsChar('_name'))
def clean(self): def clean(self):
super().clean() super().clean()
@ -416,3 +411,4 @@ class VirtualDisk(ComponentModel, TrackingModelMixin):
class Meta(ComponentModel.Meta): class Meta(ComponentModel.Meta):
verbose_name = _('virtual disk') verbose_name = _('virtual disk')
verbose_name_plural = _('virtual disks') verbose_name_plural = _('virtual disks')
ordering = ('virtual_machine', 'name')

View File

@ -53,7 +53,6 @@ VMINTERFACE_BUTTONS = """
class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.Column( name = tables.Column(
verbose_name=_('Name'), verbose_name=_('Name'),
order_by=('_name',),
linkify=True linkify=True
) )
status = columns.ChoiceFieldColumn( status = columns.ChoiceFieldColumn(

View File

@ -0,0 +1,47 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0006_charfield_null_choices'),
('dcim', '0197_natural_sort_collation'),
]
operations = [
migrations.AlterField(
model_name='ikepolicy',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='ikeproposal',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='ipsecpolicy',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='ipsecprofile',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='ipsecproposal',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='l2vpn',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='tunnel',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
]

View File

@ -22,7 +22,8 @@ class IKEProposal(PrimaryModel):
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100, max_length=100,
unique=True unique=True,
db_collation="natural_sort"
) )
authentication_method = models.CharField( authentication_method = models.CharField(
verbose_name=('authentication method'), verbose_name=('authentication method'),
@ -67,7 +68,8 @@ class IKEPolicy(PrimaryModel):
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100, max_length=100,
unique=True unique=True,
db_collation="natural_sort"
) )
version = models.PositiveSmallIntegerField( version = models.PositiveSmallIntegerField(
verbose_name=_('version'), verbose_name=_('version'),
@ -125,7 +127,8 @@ class IPSecProposal(PrimaryModel):
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100, max_length=100,
unique=True unique=True,
db_collation="natural_sort"
) )
encryption_algorithm = models.CharField( encryption_algorithm = models.CharField(
verbose_name=_('encryption'), verbose_name=_('encryption'),
@ -176,7 +179,8 @@ class IPSecPolicy(PrimaryModel):
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100, max_length=100,
unique=True unique=True,
db_collation="natural_sort"
) )
proposals = models.ManyToManyField( proposals = models.ManyToManyField(
to='vpn.IPSecProposal', to='vpn.IPSecProposal',
@ -211,7 +215,8 @@ class IPSecProfile(PrimaryModel):
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100, max_length=100,
unique=True unique=True,
db_collation="natural_sort"
) )
mode = models.CharField( mode = models.CharField(
verbose_name=_('mode'), verbose_name=_('mode'),

View File

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

View File

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

View File

@ -1,9 +1,13 @@
from rest_framework import serializers from rest_framework import serializers
from dcim.constants import LOCATION_SCOPE_TYPES
from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from ipam.api.serializers_.vlans import VLANSerializer from ipam.api.serializers_.vlans import VLANSerializer
from netbox.api.fields import ChoiceField from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
from wireless.choices import * from wireless.choices import *
from wireless.models import WirelessLAN, WirelessLANGroup from wireless.models import WirelessLAN, WirelessLANGroup
from .nested import NestedWirelessLANGroupSerializer from .nested import NestedWirelessLANGroupSerializer
@ -34,12 +38,30 @@ class WirelessLANSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)
auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True)
auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True)
scope_type = ContentTypeField(
queryset=ContentType.objects.filter(
model__in=LOCATION_SCOPE_TYPES
),
allow_null=True,
required=False,
default=None
)
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = WirelessLAN model = WirelessLAN
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'tenant', 'id', 'url', 'display_url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'scope_type', 'scope_id', 'scope',
'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'ssid', 'description') brief_fields = ('id', 'url', 'display', 'ssid', 'description')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_scope(self, obj):
if obj.scope_id is None:
return None
serializer = get_serializer_for_model(obj.scope)
context = {'request': self.context['request']}
return serializer(obj.scope, nested=True, context=context).data

View File

@ -2,6 +2,7 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from dcim.choices import LinkStatusChoices from dcim.choices import LinkStatusChoices
from dcim.base_filtersets import ScopedFilterSet
from dcim.models import Interface from dcim.models import Interface
from ipam.models import VLAN from ipam.models import VLAN
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
@ -43,7 +44,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'description') fields = ('id', 'name', 'slug', 'description')
class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class WirelessLANFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
group_id = TreeNodeMultipleChoiceFilter( group_id = TreeNodeMultipleChoiceFilter(
queryset=WirelessLANGroup.objects.all(), queryset=WirelessLANGroup.objects.all(),
field_name='group', field_name='group',
@ -74,7 +75,7 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta: class Meta:
model = WirelessLAN model = WirelessLAN
fields = ('id', 'ssid', 'auth_psk', 'description') fields = ('id', 'ssid', 'auth_psk', 'scope_id', 'description')
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -2,6 +2,7 @@ from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices from dcim.choices import LinkStatusChoices
from dcim.forms.mixins import ScopedBulkEditForm
from ipam.models import VLAN from ipam.models import VLAN
from netbox.choices import * from netbox.choices import *
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
@ -39,7 +40,7 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = ('parent', 'description') nullable_fields = ('parent', 'description')
class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):
status = forms.ChoiceField( status = forms.ChoiceField(
label=_('Status'), label=_('Status'),
choices=add_blank_choice(WirelessLANStatusChoices), choices=add_blank_choice(WirelessLANStatusChoices),
@ -89,10 +90,11 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
model = WirelessLAN model = WirelessLAN
fieldsets = ( fieldsets = (
FieldSet('group', 'ssid', 'status', 'vlan', 'tenant', 'description'), FieldSet('group', 'ssid', 'status', 'vlan', 'tenant', 'description'),
FieldSet('scope_type', 'scope', name=_('Scope')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
) )
nullable_fields = ( nullable_fields = (
'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments', 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'scope', 'comments',
) )

View File

@ -1,6 +1,7 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices from dcim.choices import LinkStatusChoices
from dcim.forms.mixins import ScopedImportForm
from dcim.models import Interface from dcim.models import Interface
from ipam.models import VLAN from ipam.models import VLAN
from netbox.choices import * from netbox.choices import *
@ -32,7 +33,7 @@ class WirelessLANGroupImportForm(NetBoxModelImportForm):
fields = ('name', 'slug', 'parent', 'description', 'tags') fields = ('name', 'slug', 'parent', 'description', 'tags')
class WirelessLANImportForm(NetBoxModelImportForm): class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm):
group = CSVModelChoiceField( group = CSVModelChoiceField(
label=_('Group'), label=_('Group'),
queryset=WirelessLANGroup.objects.all(), queryset=WirelessLANGroup.objects.all(),
@ -75,9 +76,12 @@ class WirelessLANImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = WirelessLAN model = WirelessLAN
fields = ( fields = (
'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'scope_type', 'scope_id',
'comments', 'tags', 'description', 'comments', 'tags',
) )
labels = {
'scope_id': _('Scope ID'),
}
class WirelessLinkImportForm(NetBoxModelImportForm): class WirelessLinkImportForm(NetBoxModelImportForm):

View File

@ -2,6 +2,7 @@ from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices from dcim.choices import LinkStatusChoices
from dcim.models import Location, Region, Site, SiteGroup
from netbox.choices import * from netbox.choices import *
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm from tenancy.forms import TenancyFilterForm
@ -33,6 +34,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('ssid', 'group_id', 'status', name=_('Attributes')), FieldSet('ssid', 'group_id', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
) )
@ -65,6 +67,31 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Pre-shared key'), label=_('Pre-shared key'),
required=False required=False
) )
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
label=_('Location')
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -2,6 +2,7 @@ from django.forms import PasswordInput
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.models import Device, Interface, Location, Site from dcim.models import Device, Interface, Location, Site
from dcim.forms.mixins import ScopedForm
from ipam.models import VLAN from ipam.models import VLAN
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
@ -35,7 +36,7 @@ class WirelessLANGroupForm(NetBoxModelForm):
] ]
class WirelessLANForm(TenancyForm, NetBoxModelForm): class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm):
group = DynamicModelChoiceField( group = DynamicModelChoiceField(
label=_('Group'), label=_('Group'),
queryset=WirelessLANGroup.objects.all(), queryset=WirelessLANGroup.objects.all(),
@ -51,6 +52,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm):
fieldsets = ( fieldsets = (
FieldSet('ssid', 'group', 'vlan', 'status', 'description', 'tags', name=_('Wireless LAN')), FieldSet('ssid', 'group', 'vlan', 'status', 'description', 'tags', name=_('Wireless LAN')),
FieldSet('scope_type', 'scope', name=_('Scope')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
) )
@ -59,7 +61,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm):
model = WirelessLAN model = WirelessLAN
fields = [ fields = [
'ssid', 'group', 'status', 'vlan', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'ssid', 'group', 'status', 'vlan', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk',
'description', 'comments', 'tags', 'scope_type', 'description', 'comments', 'tags',
] ]
widgets = { widgets = {
'auth_psk': PasswordInput( 'auth_psk': PasswordInput(

View File

@ -1,4 +1,4 @@
from typing import Annotated, List from typing import Annotated, List, Union
import strawberry import strawberry
import strawberry_django import strawberry_django
@ -28,7 +28,7 @@ class WirelessLANGroupType(OrganizationalObjectType):
@strawberry_django.type( @strawberry_django.type(
models.WirelessLAN, models.WirelessLAN,
fields='__all__', exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
filters=WirelessLANFilter filters=WirelessLANFilter
) )
class WirelessLANType(NetBoxObjectType): class WirelessLANType(NetBoxObjectType):
@ -38,6 +38,15 @@ class WirelessLANType(NetBoxObjectType):
interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.field
def scope(self) -> Annotated[Union[
Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
], strawberry.union("WirelessLANScopeType")] | None:
return self.scope
@strawberry_django.type( @strawberry_django.type(
models.WirelessLink, models.WirelessLink,

View File

@ -0,0 +1,77 @@
# Generated by Django 5.0.9 on 2024-11-04 16:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0196_qinq_svlan'),
('wireless', '0010_charfield_null_choices'),
]
operations = [
migrations.AddField(
model_name='wirelesslan',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='_%(class)ss',
to='dcim.location',
),
),
migrations.AddField(
model_name='wirelesslan',
name='_region',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='_%(class)ss',
to='dcim.region',
),
),
migrations.AddField(
model_name='wirelesslan',
name='_site',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='_%(class)ss',
to='dcim.site',
),
),
migrations.AddField(
model_name='wirelesslan',
name='_site_group',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='_%(class)ss',
to='dcim.sitegroup',
),
),
migrations.AddField(
model_name='wirelesslan',
name='scope_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='wirelesslan',
name='scope_type',
field=models.ForeignKey(
blank=True,
limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype',
),
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 5.0.9 on 2024-11-14 19:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0196_qinq_svlan'),
('wireless', '0011_wirelesslan__location_wirelesslan__region_and_more'),
]
operations = [
migrations.AlterField(
model_name='wirelesslan',
name='_location',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location'
),
),
migrations.AlterField(
model_name='wirelesslan',
name='_region',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region'
),
),
migrations.AlterField(
model_name='wirelesslan',
name='_site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'),
),
migrations.AlterField(
model_name='wirelesslan',
name='_site_group',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup'
),
),
]

View File

@ -0,0 +1,17 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wireless', '0012_alter_wirelesslan__location_and_more'),
('dcim', '0197_natural_sort_collation'),
]
operations = [
migrations.AlterField(
model_name='wirelesslangroup',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
]

View File

@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices from dcim.choices import LinkStatusChoices
from dcim.constants import WIRELESS_IFACE_TYPES from dcim.constants import WIRELESS_IFACE_TYPES
from dcim.models.mixins import CachedScopeMixin
from netbox.models import NestedGroupModel, PrimaryModel from netbox.models import NestedGroupModel, PrimaryModel
from netbox.models.mixins import DistanceMixin from netbox.models.mixins import DistanceMixin
from .choices import * from .choices import *
@ -51,7 +52,8 @@ class WirelessLANGroup(NestedGroupModel):
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100, max_length=100,
unique=True unique=True,
db_collation="natural_sort"
) )
slug = models.SlugField( slug = models.SlugField(
verbose_name=_('slug'), verbose_name=_('slug'),
@ -71,7 +73,7 @@ class WirelessLANGroup(NestedGroupModel):
verbose_name_plural = _('wireless LAN groups') verbose_name_plural = _('wireless LAN groups')
class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): class WirelessLAN(WirelessAuthenticationBase, CachedScopeMixin, PrimaryModel):
""" """
A wireless network formed among an arbitrary number of access point and clients. A wireless network formed among an arbitrary number of access point and clients.
""" """
@ -107,7 +109,7 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel):
null=True null=True
) )
clone_fields = ('ssid', 'group', 'tenant', 'description') clone_fields = ('ssid', 'group', 'scope_type', 'scope_id', 'tenant', 'description')
class Meta: class Meta:
ordering = ('ssid', 'pk') ordering = ('ssid', 'pk')

View File

@ -51,6 +51,13 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable):
status = columns.ChoiceFieldColumn( status = columns.ChoiceFieldColumn(
verbose_name=_('Status'), verbose_name=_('Status'),
) )
scope_type = columns.ContentTypeColumn(
verbose_name=_('Scope Type'),
)
scope = tables.Column(
verbose_name=_('Scope'),
linkify=True
)
interface_count = tables.Column( interface_count = tables.Column(
verbose_name=_('Interfaces') verbose_name=_('Interfaces')
) )
@ -65,7 +72,7 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable):
model = WirelessLAN model = WirelessLAN
fields = ( fields = (
'pk', 'ssid', 'group', 'status', 'tenant', 'tenant_group', 'vlan', 'interface_count', 'auth_type', 'pk', 'ssid', 'group', 'status', 'tenant', 'tenant_group', 'vlan', 'interface_count', 'auth_type',
'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'created', 'last_updated', 'auth_cipher', 'auth_psk', 'scope', 'scope_type', 'description', 'comments', 'tags', 'created', 'last_updated',
) )
default_columns = ('pk', 'ssid', 'group', 'status', 'description', 'vlan', 'auth_type', 'interface_count') default_columns = ('pk', 'ssid', 'group', 'status', 'description', 'vlan', 'auth_type', 'interface_count')

View File

@ -1,7 +1,7 @@
from django.urls import reverse from django.urls import reverse
from dcim.choices import InterfaceTypeChoices from dcim.choices import InterfaceTypeChoices
from dcim.models import Interface from dcim.models import Interface, Site
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.testing import APITestCase, APIViewTestCases, create_test_device from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from wireless.choices import * from wireless.choices import *
@ -53,6 +53,12 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
tenants = ( tenants = (
Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'), Tenant(name='Tenant 2', slug='tenant-2'),
@ -94,6 +100,8 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase):
'status': WirelessLANStatusChoices.STATUS_DISABLED, 'status': WirelessLANStatusChoices.STATUS_DISABLED,
'tenant': tenants[0].pk, 'tenant': tenants[0].pk,
'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE, 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE,
'scope_type': 'dcim.site',
'scope_id': sites[1].pk,
}, },
] ]

View File

@ -1,7 +1,7 @@
from django.test import TestCase from django.test import TestCase
from dcim.choices import InterfaceTypeChoices, LinkStatusChoices from dcim.choices import InterfaceTypeChoices, LinkStatusChoices
from dcim.models import Interface from dcim.models import Interface, Location, Region, Site, SiteGroup
from ipam.models import VLAN from ipam.models import VLAN
from netbox.choices import DistanceUnitChoices from netbox.choices import DistanceUnitChoices
from tenancy.models import Tenant from tenancy.models import Tenant
@ -110,6 +110,36 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
VLAN.objects.bulk_create(vlans) VLAN.objects.bulk_create(vlans)
regions = (
Region(name='Test Region 1', slug='test-region-1'),
Region(name='Test Region 2', slug='test-region-2'),
Region(name='Test Region 3', slug='test-region-3'),
)
for r in regions:
r.save()
site_groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for site_group in site_groups:
site_group.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
)
Site.objects.bulk_create(sites)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
Location(name='Location 2', slug='location-2', site=sites[2]),
)
for location in locations:
location.save()
tenants = ( tenants = (
Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'), Tenant(name='Tenant 2', slug='tenant-2'),
@ -127,7 +157,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_type=WirelessAuthTypeChoices.TYPE_OPEN,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO,
auth_psk='PSK1', auth_psk='PSK1',
description='foobar1' description='foobar1',
scope=sites[0]
), ),
WirelessLAN( WirelessLAN(
ssid='WLAN2', ssid='WLAN2',
@ -138,7 +169,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_type=WirelessAuthTypeChoices.TYPE_WEP,
auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP,
auth_psk='PSK2', auth_psk='PSK2',
description='foobar2' description='foobar2',
scope=locations[0]
), ),
WirelessLAN( WirelessLAN(
ssid='WLAN3', ssid='WLAN3',
@ -149,12 +181,14 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES,
auth_psk='PSK3', auth_psk='PSK3',
description='foobar3' description='foobar3',
scope=locations[1]
), ),
) )
WirelessLAN.objects.bulk_create(wireless_lans) for wireless_lan in wireless_lans:
wireless_lan.save()
device = create_test_device('Device 1') device = create_test_device('Device 1', site=sites[0])
interfaces = ( interfaces = (
Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_80211N), Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_80211N),
Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_80211N), Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_80211N),
@ -217,6 +251,38 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_location(self):
locations = Location.objects.all()[:1]
params = {'location_id': [locations[0].pk,]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'location': [locations[0].slug,]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_scope_type(self):
params = {'scope_type': 'dcim.location'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = WirelessLink.objects.all() queryset = WirelessLink.objects.all()

View File

@ -1,7 +1,8 @@
from django.contrib.contenttypes.models import ContentType
from wireless.choices import * from wireless.choices import *
from wireless.models import * from wireless.models import *
from dcim.choices import InterfaceTypeChoices, LinkStatusChoices from dcim.choices import InterfaceTypeChoices, LinkStatusChoices
from dcim.models import Interface from dcim.models import Interface, Site
from netbox.choices import DistanceUnitChoices from netbox.choices import DistanceUnitChoices
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.testing import ViewTestCases, create_tags, create_test_device from utilities.testing import ViewTestCases, create_tags, create_test_device
@ -56,6 +57,12 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
tenants = ( tenants = (
Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'), Tenant(name='Tenant 2', slug='tenant-2'),
@ -98,15 +105,17 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'ssid': 'WLAN2', 'ssid': 'WLAN2',
'group': groups[1].pk, 'group': groups[1].pk,
'status': WirelessLANStatusChoices.STATUS_DISABLED, 'status': WirelessLANStatusChoices.STATUS_DISABLED,
'scope_type': ContentType.objects.get_for_model(Site).pk,
'scope': sites[1].pk,
'tenant': tenants[1].pk, 'tenant': tenants[1].pk,
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
"group,ssid,status,tenant", "group,ssid,status,tenant,scope_type,scope_id",
f"Wireless LAN Group 2,WLAN4,{WirelessLANStatusChoices.STATUS_ACTIVE},{tenants[0].name}", f"Wireless LAN Group 2,WLAN4,{WirelessLANStatusChoices.STATUS_ACTIVE},{tenants[0].name},,",
f"Wireless LAN Group 2,WLAN5,{WirelessLANStatusChoices.STATUS_DISABLED},{tenants[1].name}", f"Wireless LAN Group 2,WLAN5,{WirelessLANStatusChoices.STATUS_DISABLED},{tenants[1].name},dcim.site,{sites[0].pk}",
f"Wireless LAN Group 2,WLAN6,{WirelessLANStatusChoices.STATUS_RESERVED},{tenants[2].name}", f"Wireless LAN Group 2,WLAN6,{WirelessLANStatusChoices.STATUS_RESERVED},{tenants[2].name},dcim.site,{sites[1].pk}",
) )
cls.csv_update_data = ( cls.csv_update_data = (

View File

@ -1,2 +1,4 @@
[lint] [lint]
extend-select = ["E1", "E2", "E3", "W"]
ignore = ["E501", "F403", "F405"] ignore = ["E501", "F403", "F405"]
preview = true