From c60a0f4f56f0cf5f259eb0b4520b84e43741e876 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 27 Sep 2024 05:33:02 -0700 Subject: [PATCH 01/65] 16136 remove Django Admin (#17619) * 16136 remove Django Admin * 16136 fix plugin test * 16136 fix migrations * Revert "16136 fix migrations" This reverts commit 80296fa1ecc294e4df8d11d11ea6dc10921517b0. * Remove obsolete admin module from dummy plugin * Remove obsolete admin site configuration * Remove unused import statement * Remove obsolete admin module * Misc cleanup --------- Co-authored-by: Jeremy Stretch --- docs/configuration/miscellaneous.md | 8 -------- netbox/netbox/admin.py | 14 -------------- netbox/netbox/configuration_testing.py | 2 -- netbox/netbox/plugins/urls.py | 2 -- netbox/netbox/settings.py | 5 ----- netbox/netbox/tests/dummy_plugin/admin.py | 9 --------- netbox/netbox/tests/test_plugins.py | 6 ------ netbox/netbox/urls.py | 5 ----- netbox/templates/inc/user_menu.html | 5 ----- netbox/users/admin.py | 5 ----- 10 files changed, 61 deletions(-) delete mode 100644 netbox/netbox/admin.py delete mode 100644 netbox/netbox/tests/dummy_plugin/admin.py delete mode 100644 netbox/users/admin.py diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index 124de3037..576eb8739 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -96,14 +96,6 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da --- -## DJANGO_ADMIN_ENABLED - -Default: False - -Setting this to True installs the `django.contrib.admin` app and enables the [Django admin UI](https://docs.djangoproject.com/en/5.0/ref/contrib/admin/). This may be necessary to support older plugins which do not integrate with the native NetBox interface. - ---- - ## ENFORCE_GLOBAL_UNIQUE !!! tip "Dynamic Configuration Parameter" diff --git a/netbox/netbox/admin.py b/netbox/netbox/admin.py deleted file mode 100644 index cdfacc141..000000000 --- a/netbox/netbox/admin.py +++ /dev/null @@ -1,14 +0,0 @@ -from django.conf import settings -from django.contrib.admin import site as admin_site -from taggit.models import Tag - - -# Override default AdminSite attributes so we can avoid creating and -# registering our own class -admin_site.site_header = 'NetBox Administration' -admin_site.site_title = 'NetBox' -admin_site.site_url = '/{}'.format(settings.BASE_PATH) -admin_site.index_template = 'admin/index.html' - -# Unregister the unused stock Tag model provided by django-taggit -admin_site.unregister(Tag) diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 346cd89d2..cec05cabb 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -39,8 +39,6 @@ REDIS = { SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' -DJANGO_ADMIN_ENABLED = True - DEFAULT_PERMISSIONS = {} LOGGING = { diff --git a/netbox/netbox/plugins/urls.py b/netbox/netbox/plugins/urls.py index 075bda811..7a9f30c7e 100644 --- a/netbox/netbox/plugins/urls.py +++ b/netbox/netbox/plugins/urls.py @@ -3,13 +3,11 @@ from importlib import import_module from django.apps import apps from django.conf import settings from django.conf.urls import include -from django.contrib.admin.views.decorators import staff_member_required from django.urls import path from django.utils.module_loading import import_string, module_has_submodule from . import views -# Initialize URL base, API, and admin URL patterns for plugins plugin_patterns = [] plugin_api_patterns = [ path('', views.PluginsAPIRootView.as_view(), name='api-root'), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 358f41ff8..206a58cff 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -110,7 +110,6 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', { 'users.delete_token': ({'user': '$user'},), }) DEVELOPER = getattr(configuration, 'DEVELOPER', False) -DJANGO_ADMIN_ENABLED = getattr(configuration, 'DJANGO_ADMIN_ENABLED', False) DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs')) EMAIL = getattr(configuration, 'EMAIL', {}) EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', ( @@ -373,7 +372,6 @@ SERVER_EMAIL = EMAIL.get('FROM_EMAIL') # INSTALLED_APPS = [ - 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -411,8 +409,6 @@ INSTALLED_APPS = [ ] if not DEBUG: INSTALLED_APPS.remove('debug_toolbar') -if not DJANGO_ADMIN_ENABLED: - INSTALLED_APPS.remove('django.contrib.admin') # Middleware MIDDLEWARE = [ @@ -549,7 +545,6 @@ EXEMPT_EXCLUDE_MODELS = ( # All URLs starting with a string listed here are exempt from maintenance mode enforcement MAINTENANCE_EXEMPT_PATHS = ( - f'/{BASE_PATH}admin/', f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration LOGIN_URL, LOGIN_REDIRECT_URL, diff --git a/netbox/netbox/tests/dummy_plugin/admin.py b/netbox/netbox/tests/dummy_plugin/admin.py deleted file mode 100644 index 83bc22ad8..000000000 --- a/netbox/netbox/tests/dummy_plugin/admin.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.contrib import admin - -from netbox.admin import admin_site -from .models import DummyModel - - -@admin.register(DummyModel, site=admin_site) -class DummyModelAdmin(admin.ModelAdmin): - list_display = ('name', 'number') diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index 351fef9e2..ba44378c5 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -36,12 +36,6 @@ class PluginTest(TestCase): instance.delete() self.assertIsNone(instance.pk) - def test_admin(self): - - # Test admin view URL resolution - url = reverse('admin:dummy_plugin_dummymodel_add') - self.assertEqual(url, '/admin/dummy_plugin/dummymodel/add/') - @override_settings(LOGIN_REQUIRED=False) def test_views(self): diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index b0175ec04..08c9a46a8 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -77,11 +77,6 @@ _patterns = [ path('api/plugins/', include((plugin_api_patterns, 'plugins-api'))), ] -# Django admin UI -if settings.DJANGO_ADMIN_ENABLED: - from .admin import admin_site - _patterns.append(path('admin/', admin_site.urls)) - # django-debug-toolbar if settings.DEBUG: import debug_toolbar diff --git a/netbox/templates/inc/user_menu.html b/netbox/templates/inc/user_menu.html index 1b6757416..e27be3323 100644 --- a/netbox/templates/inc/user_menu.html +++ b/netbox/templates/inc/user_menu.html @@ -36,11 +36,6 @@ + {% if object.vlan_translation_policy %} +
+
+ {% include 'inc/panel_table.html' with table=vlan_translation_table heading="VLAN Translation" %} +
+
+ {% endif %} {% if object.is_bridge %}
diff --git a/netbox/templates/ipam/vlantranslationpolicy.html b/netbox/templates/ipam/vlantranslationpolicy.html new file mode 100644 index 000000000..5217db913 --- /dev/null +++ b/netbox/templates/ipam/vlantranslationpolicy.html @@ -0,0 +1,55 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block content %} +
+
+
+

{% trans "VLAN Translation Policy" %}

+ + + + + + + + + +
{% trans "Name" %}{{ object.name|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %} +
+
+
+
+
+

+ {% trans "VLAN Translation Rules" %} + {% if perms.ipam.add_vlantranslationrule %} + + {% endif %} +

+ {% htmx_table 'ipam:vlantranslationrule_list' policy_id=object.pk %} +
+
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/vlantranslationrule.html b/netbox/templates/ipam/vlantranslationrule.html new file mode 100644 index 000000000..7f3aad2ad --- /dev/null +++ b/netbox/templates/ipam/vlantranslationrule.html @@ -0,0 +1,45 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block content %} +
+
+
+

{% trans "VLAN Translation Rule" %}

+ + + + + + + + + + + + + + + + + +
{% trans "Policy" %}{{ object.policy|linkify }}
{% trans "Local VID" %}{{ object.local_vid }}
{% trans "Remote VID" %}{{ object.remote_vid }}
{% trans "Description" %}{{ object.description }}
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 0d679680d..13cc8aa2f 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -67,6 +67,10 @@ {% trans "Tunnel" %} {{ object.tunnel_termination.tunnel|linkify|placeholder }} + + {% trans "VLAN Translation" %} + {{ object.vlan_translation_policy|linkify|placeholder }} +
{% include 'inc/panels/tags.html' %} @@ -100,6 +104,13 @@ {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
+{% if object.vlan_translation_policy %} +
+
+ {% include 'inc/panel_table.html' with table=vlan_translation_table heading="VLAN Translation" %} +
+
+{% endif %}
{% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %} diff --git a/netbox/virtualization/api/serializers_/virtualmachines.py b/netbox/virtualization/api/serializers_/virtualmachines.py index 1b224c16a..2c00cac96 100644 --- a/netbox/virtualization/api/serializers_/virtualmachines.py +++ b/netbox/virtualization/api/serializers_/virtualmachines.py @@ -8,7 +8,7 @@ from dcim.api.serializers_.sites import SiteSerializer from dcim.choices import InterfaceModeChoices from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from ipam.api.serializers_.ip import IPAddressSerializer -from ipam.api.serializers_.vlans import VLANSerializer +from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer from ipam.api.serializers_.vrfs import VRFSerializer from ipam.models import VLAN from netbox.api.fields import ChoiceField, SerializedPKRelatedField @@ -89,6 +89,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer): required=False, many=True ) + vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True) l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) count_ipaddresses = serializers.IntegerField(read_only=True) @@ -105,6 +106,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer): 'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', + 'vlan_translation_policy', ] brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description') diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 9ffc914ab..5971fc894 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.forms.common import InterfaceCommonForm from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.models import ConfigTemplate -from ipam.models import IPAddress, VLAN, VLANGroup, VRF +from ipam.models import IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ConfirmationForm @@ -343,20 +343,25 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): required=False, label=_('VRF') ) + vlan_translation_policy = DynamicModelChoiceField( + queryset=VLANTranslationPolicy.objects.all(), + required=False, + label=_('VLAN Translation Policy') + ) fieldsets = ( FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')), FieldSet('vrf', 'mac_address', name=_('Addressing')), FieldSet('mtu', 'enabled', name=_('Operation')), FieldSet('parent', 'bridge', name=_('Related Interfaces')), - FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), + FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy', name=_('802.1Q Switching')), ) class Meta: model = VMInterface fields = [ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'vlan_translation_policy', ] labels = { 'mode': '802.1Q Mode', diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 2d872322b..bed65a3b3 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -100,6 +100,7 @@ class VMInterfaceType(IPAddressesMixin, ComponentType): bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None + vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] bridge_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]] diff --git a/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py b/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py new file mode 100644 index 000000000..e0992c9c8 --- /dev/null +++ b/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.9 on 2024-10-11 19:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0074_vlantranslationpolicy_vlantranslationrule'), + ('virtualization', '0041_charfield_null_choices'), + ] + + operations = [ + migrations.AddField( + model_name='vminterface', + name='vlan_translation_policy', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlantranslationpolicy'), + ), + ] diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index d2e6cc05f..cd598274f 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -1,7 +1,7 @@ from django.test import TestCase from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup -from ipam.models import IPAddress, VRF +from ipam.models import IPAddress, VLANTranslationPolicy, VRF from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.choices import * @@ -561,6 +561,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) VirtualMachine.objects.bulk_create(vms) + vlan_translation_policies = ( + VLANTranslationPolicy(name='Policy 1'), + VLANTranslationPolicy(name='Policy 2'), + VLANTranslationPolicy(name='Policy 3'), + ) + VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies) + interfaces = ( VMInterface( virtual_machine=vms[0], @@ -569,7 +576,8 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): mtu=100, mac_address='00-00-00-00-00-01', vrf=vrfs[0], - description='foobar1' + description='foobar1', + vlan_translation_policy=vlan_translation_policies[0], ), VMInterface( virtual_machine=vms[1], @@ -578,7 +586,8 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): mtu=200, mac_address='00-00-00-00-00-02', vrf=vrfs[1], - description='foobar2' + description='foobar2', + vlan_translation_policy=vlan_translation_policies[0], ), VMInterface( virtual_machine=vms[2], @@ -658,6 +667,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_vlan_translation_policy(self): + vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2] + params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vlan_translation_policy': [vlan_translation_policies[0].name, vlan_translation_policies[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class VirtualDiskTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VirtualDisk.objects.all() diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index d1d65b1ff..35f2f8f75 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -16,7 +16,7 @@ from dcim.models import Device from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import IPAddress -from ipam.tables import InterfaceVLANTable +from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic from tenancy.views import ObjectContactsView @@ -516,6 +516,14 @@ class VMInterfaceView(generic.ObjectView): orderable=False ) + # Get VLAN translation rules + vlan_translation_table = None + if instance.vlan_translation_policy: + vlan_translation_table = VLANTranslationRuleTable( + data=instance.vlan_translation_policy.rules.all(), + orderable=False + ) + # Get assigned VLANs and annotate whether each is tagged or untagged vlans = [] if instance.untagged_vlan is not None: @@ -533,6 +541,7 @@ class VMInterfaceView(generic.ObjectView): return { 'child_interfaces_table': child_interfaces_tables, 'vlan_table': vlan_table, + 'vlan_translation_table': vlan_translation_table, } From a8eb455f3e83cc79f38a51ae32266911aa4e2f16 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 31 Oct 2024 06:55:08 -0700 Subject: [PATCH 20/65] 9604 Add Termination to CircuitTermination (#17821) * 9604 add scope type to CircuitTermination * 9604 add scope type to CircuitTermination * 9604 add scope type to CircuitTermination * 9604 model_forms * 9604 form filtersets * 9604 form bulk_import * 9604 form bulk_edit * 9604 serializers * 9604 graphql * 9604 tests and detail template * 9604 fix migration merge * 9604 fix tests * 9604 fix tests * 9604 fix table * 9604 updates * fix tests * fix tests * fix tests * fix tests * fix tests * fix tests * fix tests * 9604 remove provider_network * 9604 fix tests * 9604 fix tests * 9604 fix forms * 9604 review changes * 9604 scope->termination * 9604 fix _circuit_terminations * 9604 fix _circuit_terminations * 9604 sitegroup -> site_group * 9604 update docs * 9604 fix form termination side reference * Misc cleanup * Fix terminations in circuits table * Fix missing imports * Clean up termination attrs display * Add termination & type to CircuitTerminationTable * Update cable tracing logic --------- Co-authored-by: Jeremy Stretch --- docs/models/circuits/circuittermination.md | 8 +- netbox/circuits/api/serializers_/circuits.py | 51 ++++++++-- netbox/circuits/constants.py | 4 + netbox/circuits/filtersets.py | 76 +++++++++++---- netbox/circuits/forms/bulk_edit.py | 54 +++++++---- netbox/circuits/forms/bulk_import.py | 30 +++--- netbox/circuits/forms/filtersets.py | 25 +++-- netbox/circuits/forms/model_forms.py | 63 ++++++++++--- netbox/circuits/graphql/types.py | 16 +++- .../0047_circuittermination__termination.py | 56 +++++++++++ ...48_circuitterminations_cached_relations.py | 90 ++++++++++++++++++ netbox/circuits/models/circuits.py | 94 ++++++++++++++++--- netbox/circuits/tables/circuits.py | 52 +++++++--- netbox/circuits/tests/test_api.py | 14 +-- netbox/circuits/tests/test_filtersets.py | 48 +++++----- netbox/circuits/tests/test_views.py | 46 +++++---- netbox/circuits/views.py | 11 +-- netbox/dcim/graphql/types.py | 17 +++- netbox/dcim/models/cables.py | 12 +-- netbox/dcim/tests/test_cablepaths.py | 30 +++--- netbox/dcim/tests/test_filtersets.py | 6 +- netbox/dcim/tests/test_models.py | 6 +- netbox/dcim/views.py | 26 ++++- .../inc/circuit_termination_fields.html | 25 ++--- 24 files changed, 649 insertions(+), 211 deletions(-) create mode 100644 netbox/circuits/constants.py create mode 100644 netbox/circuits/migrations/0047_circuittermination__termination.py create mode 100644 netbox/circuits/migrations/0048_circuitterminations_cached_relations.py diff --git a/docs/models/circuits/circuittermination.md b/docs/models/circuits/circuittermination.md index c6aa966d0..791863483 100644 --- a/docs/models/circuits/circuittermination.md +++ b/docs/models/circuits/circuittermination.md @@ -21,13 +21,9 @@ Designates the termination as forming either the A or Z end of the circuit. If selected, the circuit termination will be considered "connected" even if no cable has been connected to it in NetBox. -### Site +### Termination -The [site](../dcim/site.md) with which this circuit termination is associated. Once created, a cable can be connected between the circuit termination and a device interface (or similar component). - -### Provider Network - -Circuits which do not connect to a site modeled by NetBox can instead be terminated to a [provider network](./providernetwork.md) representing an unknown network operated by a [provider](./provider.md). +The [region](../dcim/region.md), [site group](../dcim/sitegroup.md), [site](../dcim/site.md), [location](../dcim/location.md) or [provider network](./providernetwork.md) with which this circuit termination is associated. Once created, a cable can be connected between the circuit termination and a device interface (or similar component). ### Port Speed diff --git a/netbox/circuits/api/serializers_/circuits.py b/netbox/circuits/api/serializers_/circuits.py index 111fa6f87..96a686a65 100644 --- a/netbox/circuits/api/serializers_/circuits.py +++ b/netbox/circuits/api/serializers_/circuits.py @@ -1,11 +1,16 @@ +from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices +from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES from circuits.models import Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType from dcim.api.serializers_.cables import CabledObjectSerializer -from dcim.api.serializers_.sites import SiteSerializer -from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer from netbox.choices import DistanceUnitChoices from tenancy.api.serializers_.tenants import TenantSerializer +from utilities.api import get_serializer_for_model from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer @@ -33,16 +38,33 @@ class CircuitTypeSerializer(NetBoxModelSerializer): class CircuitCircuitTerminationSerializer(WritableNestedSerializer): - site = SiteSerializer(nested=True, allow_null=True) + termination_type = ContentTypeField( + queryset=ContentType.objects.filter( + model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES + ), + allow_null=True, + required=False, + default=None + ) + termination_id = serializers.IntegerField(allow_null=True, required=False, default=None) + termination = serializers.SerializerMethodField(read_only=True) provider_network = ProviderNetworkSerializer(nested=True, allow_null=True) class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'display_url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', + 'id', 'url', 'display_url', 'display', 'termination_type', 'termination_id', 'termination', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', ] + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_termination(self, obj): + if obj.termination_id is None: + return None + serializer = get_serializer_for_model(obj.termination) + context = {'request': self.context['request']} + return serializer(obj.termination, nested=True, context=context).data + class CircuitGroupSerializer(NetBoxModelSerializer): tenant = TenantSerializer(nested=True, required=False, allow_null=True) @@ -95,18 +117,35 @@ class CircuitSerializer(NetBoxModelSerializer): class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer): circuit = CircuitSerializer(nested=True) - site = SiteSerializer(nested=True, required=False, allow_null=True) + termination_type = ContentTypeField( + queryset=ContentType.objects.filter( + model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES + ), + allow_null=True, + required=False, + default=None + ) + termination_id = serializers.IntegerField(allow_null=True, required=False, default=None) + termination = serializers.SerializerMethodField(read_only=True) provider_network = ProviderNetworkSerializer(nested=True, required=False, allow_null=True) class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'display_url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', + 'id', 'url', 'display_url', 'display', 'circuit', 'term_side', 'termination_type', 'termination_id', 'termination', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied') + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_termination(self, obj): + if obj.termination_id is None: + return None + serializer = get_serializer_for_model(obj.termination) + context = {'request': self.context['request']} + return serializer(obj.termination, nested=True, context=context).data + class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_): circuit = CircuitSerializer(nested=True) diff --git a/netbox/circuits/constants.py b/netbox/circuits/constants.py new file mode 100644 index 000000000..8119bc286 --- /dev/null +++ b/netbox/circuits/constants.py @@ -0,0 +1,4 @@ +# models values for ContentTypes which may be CircuitTermination termination types +CIRCUIT_TERMINATION_TERMINATION_TYPES = ( + 'region', 'sitegroup', 'site', 'location', 'providernetwork', +) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index ebd1fe28d..4a2a972f3 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -3,11 +3,11 @@ from django.db.models import Q from django.utils.translation import gettext as _ from dcim.filtersets import CabledObjectFilterSet -from dcim.models import Region, Site, SiteGroup +from dcim.models import Location, Region, Site, SiteGroup from ipam.models import ASN from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet -from utilities.filters import TreeNodeMultipleChoiceFilter +from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -26,37 +26,37 @@ __all__ = ( class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='circuits__terminations__site__region', + field_name='circuits__terminations___region', lookup_expr='in', label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='circuits__terminations__site__region', + field_name='circuits__terminations___region', lookup_expr='in', to_field_name='slug', label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='circuits__terminations__site__group', + field_name='circuits__terminations___site_group', lookup_expr='in', label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='circuits__terminations__site__group', + field_name='circuits__terminations___site_group', lookup_expr='in', to_field_name='slug', label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( - field_name='circuits__terminations__site', + field_name='circuits__terminations___site', queryset=Site.objects.all(), label=_('Site'), ) site = django_filters.ModelMultipleChoiceFilter( - field_name='circuits__terminations__site__slug', + field_name='circuits__terminations___site__slug', queryset=Site.objects.all(), to_field_name='slug', label=_('Site (slug)'), @@ -173,7 +173,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte label=_('Provider account (account)'), ) provider_network_id = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__provider_network', + field_name='terminations___provider_network', queryset=ProviderNetwork.objects.all(), label=_('Provider network (ID)'), ) @@ -193,37 +193,37 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='terminations__site__region', + field_name='terminations___region', lookup_expr='in', label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='terminations__site__region', + field_name='terminations___region', lookup_expr='in', to_field_name='slug', label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='terminations__site__group', + field_name='terminations___site_group', lookup_expr='in', label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='terminations__site__group', + field_name='terminations___site_group', lookup_expr='in', to_field_name='slug', label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__site', + field_name='terminations___site', queryset=Site.objects.all(), label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__site__slug', + field_name='terminations___site__slug', queryset=Site.objects.all(), to_field_name='slug', label=_('Site (slug)'), @@ -263,18 +263,60 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): queryset=Circuit.objects.all(), label=_('Circuit'), ) + termination_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', + 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)'), + ) provider_network_id = django_filters.ModelMultipleChoiceFilter( queryset=ProviderNetwork.objects.all(), + field_name='_provider_network', label=_('ProviderNetwork (ID)'), ) provider_id = django_filters.ModelMultipleChoiceFilter( @@ -292,7 +334,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): class Meta: model = CircuitTermination fields = ( - 'id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'mark_connected', + 'id', 'termination_id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'mark_connected', 'pp_info', 'cable_end', ) diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 5cb7b5d30..e3f0b5d0c 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -1,17 +1,23 @@ from django import forms +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices +from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES from circuits.models import * from dcim.models import Site from ipam.models import ASN from netbox.choices import DistanceUnitChoices from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice -from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField -from utilities.forms.rendering import FieldSet, TabbedGroups -from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions +from utilities.forms import add_blank_choice, get_field_value +from utilities.forms.fields import ( + ColorField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, +) +from utilities.forms.rendering import FieldSet +from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, HTMXSelect, NumberWithOptions +from utilities.templatetags.builtins.filters import bettertitle __all__ = ( 'CircuitBulkEditForm', @@ -197,15 +203,18 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False + termination_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), + widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}), + required=False, + label=_('Termination type') ) - provider_network = DynamicModelChoiceField( - label=_('Provider Network'), - queryset=ProviderNetwork.objects.all(), - required=False + termination = DynamicModelChoiceField( + label=_('Termination'), + queryset=Site.objects.none(), # Initial queryset + required=False, + disabled=True, + selector=True ) port_speed = forms.IntegerField( required=False, @@ -225,15 +234,26 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( FieldSet( 'description', - TabbedGroups( - FieldSet('site', name=_('Site')), - FieldSet('provider_network', name=_('Provider Network')), - ), + 'termination_type', 'termination', 'mark_connected', name=_('Circuit Termination') ), FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')), ) - nullable_fields = ('description') + nullable_fields = ('description', 'termination') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if termination_type_id := get_field_value(self, 'termination_type'): + try: + termination_type = ContentType.objects.get(pk=termination_type_id) + model = termination_type.model_class() + self.fields['termination'].queryset = model.objects.all() + self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower + self.fields['termination'].disabled = False + self.fields['termination'].label = _(bettertitle(model._meta.verbose_name)) + except ObjectDoesNotExist: + pass class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index d5cdc00a7..eab87b1f5 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -1,13 +1,14 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from circuits.choices import * +from circuits.constants import * from circuits.models import * -from dcim.models import Site from netbox.choices import DistanceUnitChoices from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField __all__ = ( 'CircuitImportForm', @@ -127,17 +128,10 @@ class BaseCircuitTerminationImportForm(forms.ModelForm): label=_('Termination'), choices=CircuitTerminationSideChoices, ) - site = CSVModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - to_field_name='name', - required=False - ) - provider_network = CSVModelChoiceField( - label=_('Provider network'), - queryset=ProviderNetwork.objects.all(), - to_field_name='name', - required=False + termination_type = CSVContentTypeField( + queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), + required=False, + label=_('Termination type (app & model)') ) @@ -145,9 +139,12 @@ class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm): class Meta: model = CircuitTermination fields = [ - 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', + 'circuit', 'term_side', 'termination_type', 'termination_id', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description' ] + labels = { + 'termination_id': _('Termination ID'), + } class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm): @@ -155,9 +152,12 @@ class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTermination class Meta: model = CircuitTermination fields = [ - 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', + 'circuit', 'term_side', 'termination_type', 'termination_id', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags' ] + labels = { + 'termination_id': _('Termination ID'), + } class CircuitGroupImportForm(NetBoxModelImportForm): diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 2e9b358e8..b585ce079 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext as _ from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices from circuits.models import * -from dcim.models import Region, Site, SiteGroup +from dcim.models import Location, Region, Site, SiteGroup from ipam.models import ASN from netbox.choices import DistanceUnitChoices from netbox.forms import NetBoxModelFilterSetForm @@ -207,18 +207,29 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm): fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet('circuit_id', 'term_side', name=_('Circuit')), - FieldSet('provider_id', 'provider_network_id', name=_('Provider')), - FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('provider_id', name=_('Provider')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Termination')), + ) + 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, - 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') + ) circuit_id = DynamicModelMultipleChoiceField( queryset=Circuit.objects.all(), required=False, diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index e00034a10..10cd06563 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -1,14 +1,19 @@ +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices +from circuits.constants import * from circuits.models import * from dcim.models import Site from ipam.models import ASN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField -from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups -from utilities.forms.widgets import DatePicker, NumberWithOptions +from utilities.forms import get_field_value +from utilities.forms.fields import CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField +from utilities.forms.rendering import FieldSet, InlineFields +from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions +from utilities.templatetags.builtins.filters import bettertitle __all__ = ( 'CircuitForm', @@ -144,26 +149,24 @@ class CircuitTerminationForm(NetBoxModelForm): queryset=Circuit.objects.all(), selector=True ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), + termination_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), + widget=HTMXSelect(), required=False, - selector=True + label=_('Termination type') ) - provider_network = DynamicModelChoiceField( - label=_('Provider network'), - queryset=ProviderNetwork.objects.all(), + termination = DynamicModelChoiceField( + label=_('Termination'), + queryset=Site.objects.none(), # Initial queryset required=False, + disabled=True, selector=True ) fieldsets = ( FieldSet( 'circuit', 'term_side', 'description', 'tags', - TabbedGroups( - FieldSet('site', name=_('Site')), - FieldSet('provider_network', name=_('Provider Network')), - ), + 'termination_type', 'termination', 'mark_connected', name=_('Circuit Termination') ), FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')), @@ -172,7 +175,7 @@ class CircuitTerminationForm(NetBoxModelForm): class Meta: model = CircuitTermination fields = [ - 'circuit', 'term_side', 'site', 'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', + 'circuit', 'term_side', 'termination_type', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags', ] widgets = { @@ -184,6 +187,36 @@ class CircuitTerminationForm(NetBoxModelForm): ), } + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}) + + if instance is not None and instance.termination: + initial['termination'] = instance.termination + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + if termination_type_id := get_field_value(self, 'termination_type'): + try: + termination_type = ContentType.objects.get(pk=termination_type_id) + model = termination_type.model_class() + self.fields['termination'].queryset = model.objects.all() + self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower + self.fields['termination'].disabled = False + self.fields['termination'].label = _(bettertitle(model._meta.verbose_name)) + except ObjectDoesNotExist: + pass + + if self.instance and termination_type_id != self.instance.termination_type_id: + self.initial['termination'] = None + + def clean(self): + super().clean() + + # Assign the selected termination (if any) + self.instance.termination = self.cleaned_data.get('termination') + class CircuitGroupForm(TenancyForm, NetBoxModelForm): slug = SlugField() diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 45f0d065d..b52f9d18d 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, Union import strawberry import strawberry_django @@ -59,13 +59,21 @@ class ProviderNetworkType(NetBoxObjectType): @strawberry_django.type( models.CircuitTermination, - fields='__all__', + exclude=('termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'), filters=CircuitTerminationFilter ) class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')] - provider_network: Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')] | None - site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None + + @strawberry_django.field + def termination(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')], + Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')], + ], strawberry.union("CircuitTerminationTerminationType")] | None: + return self.termination @strawberry_django.type( diff --git a/netbox/circuits/migrations/0047_circuittermination__termination.py b/netbox/circuits/migrations/0047_circuittermination__termination.py new file mode 100644 index 000000000..cb2c9ca07 --- /dev/null +++ b/netbox/circuits/migrations/0047_circuittermination__termination.py @@ -0,0 +1,56 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_site_assignments(apps, schema_editor): + """ + Copy site ForeignKey values to the Termination GFK. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + Site = apps.get_model('dcim', 'Site') + + CircuitTermination.objects.filter(site__isnull=False).update( + termination_type=ContentType.objects.get_for_model(Site), + termination_id=models.F('site_id') + ) + + ProviderNetwork = apps.get_model('circuits', 'ProviderNetwork') + CircuitTermination.objects.filter(provider_network__isnull=False).update( + termination_type=ContentType.objects.get_for_model(ProviderNetwork), + termination_id=models.F('provider_network_id') + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0046_charfield_null_choices'), + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0193_poweroutlet_color'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='termination_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='circuittermination', + name='termination_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location', 'providernetwork'))), + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='+', + to='contenttypes.contenttype', + ), + ), + + # Copy over existing site assignments + migrations.RunPython( + code=copy_site_assignments, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py b/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py new file mode 100644 index 000000000..628579228 --- /dev/null +++ b/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py @@ -0,0 +1,90 @@ +# Generated by Django 5.0.9 on 2024-10-21 17:34 +import django.db.models.deletion +from django.db import migrations, models + + +def populate_denormalized_fields(apps, schema_editor): + """ + Copy site ForeignKey values to the Termination GFK. + """ + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + + terminations = CircuitTermination.objects.filter(site__isnull=False).prefetch_related('site') + for termination in terminations: + termination._region_id = termination.site.region_id + termination._site_group_id = termination.site.group_id + termination._site_id = termination.site_id + # Note: Location cannot be set prior to migration + + CircuitTermination.objects.bulk_update(terminations, ['_region', '_site_group', '_site']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0047_circuittermination__termination'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='_location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='circuit_terminations', + to='dcim.location', + ), + ), + migrations.AddField( + model_name='circuittermination', + name='_region', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='circuit_terminations', + to='dcim.region', + ), + ), + migrations.AddField( + model_name='circuittermination', + name='_site', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='circuit_terminations', + to='dcim.site', + ), + ), + migrations.AddField( + model_name='circuittermination', + name='_site_group', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='circuit_terminations', + to='dcim.sitegroup', + ), + ), + + # Populate denormalized FK values + migrations.RunPython( + code=populate_denormalized_fields, + reverse_code=migrations.RunPython.noop + ), + + # Delete the site ForeignKey + migrations.RemoveField( + model_name='circuittermination', + name='site', + ), + migrations.RenameField( + model_name='circuittermination', + old_name='provider_network', + new_name='_provider_network', + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 5f749550c..85b22eaa5 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -1,9 +1,13 @@ +from django.apps import apps +from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Q from django.urls import reverse from django.utils.translation import gettext_lazy as _ from circuits.choices import * +from circuits.constants import * from dcim.models import CabledObjectModel from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models.mixins import DistanceMixin @@ -230,22 +234,24 @@ class CircuitTermination( term_side = models.CharField( max_length=1, choices=CircuitTerminationSideChoices, - verbose_name=_('termination') + verbose_name=_('termination side') ) - site = models.ForeignKey( - to='dcim.Site', + termination_type = models.ForeignKey( + to='contenttypes.ContentType', on_delete=models.PROTECT, - related_name='circuit_terminations', + limit_choices_to=Q(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), + related_name='+', blank=True, null=True ) - provider_network = models.ForeignKey( - to='circuits.ProviderNetwork', - on_delete=models.PROTECT, - related_name='circuit_terminations', + termination_id = models.PositiveBigIntegerField( blank=True, null=True ) + termination = GenericForeignKey( + ct_field='termination_type', + fk_field='termination_id' + ) port_speed = models.PositiveIntegerField( verbose_name=_('port speed (Kbps)'), blank=True, @@ -276,6 +282,43 @@ class CircuitTermination( blank=True ) + # Cached associations to enable efficient filtering + _provider_network = models.ForeignKey( + to='circuits.ProviderNetwork', + on_delete=models.PROTECT, + related_name='circuit_terminations', + blank=True, + null=True + ) + _location = models.ForeignKey( + to='dcim.Location', + on_delete=models.CASCADE, + related_name='circuit_terminations', + blank=True, + null=True + ) + _site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + related_name='circuit_terminations', + blank=True, + null=True + ) + _region = models.ForeignKey( + to='dcim.Region', + on_delete=models.CASCADE, + related_name='circuit_terminations', + blank=True, + null=True + ) + _site_group = models.ForeignKey( + to='dcim.SiteGroup', + on_delete=models.CASCADE, + related_name='circuit_terminations', + blank=True, + null=True + ) + class Meta: ordering = ['circuit', 'term_side'] constraints = ( @@ -297,10 +340,35 @@ class CircuitTermination( super().clean() # Must define either site *or* provider network - if self.site is None and self.provider_network is None: - raise ValidationError(_("A circuit termination must attach to either a site or a provider network.")) - if self.site and self.provider_network: - raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network.")) + if self.termination is None: + raise ValidationError(_("A circuit termination must attach to termination.")) + + def save(self, *args, **kwargs): + # Cache objects associated with the terminating object (for filtering) + self.cache_related_objects() + + super().save(*args, **kwargs) + + def cache_related_objects(self): + self._provider_network = self._region = self._site_group = self._site = self._location = None + if self.termination_type: + termination_type = self.termination_type.model_class() + if termination_type == apps.get_model('dcim', 'region'): + self._region = self.termination + elif termination_type == apps.get_model('dcim', 'sitegroup'): + self._site_group = self.termination + elif termination_type == apps.get_model('dcim', 'site'): + self._region = self.termination.region + self._site_group = self.termination.group + self._site = self.termination + elif termination_type == apps.get_model('dcim', 'location'): + self._region = self.termination.site.region + self._site_group = self.termination.site.group + self._site = self.termination.site + self._location = self.termination + elif termination_type == apps.get_model('circuits', 'providernetwork'): + self._provider_network = self.termination + cache_related_objects.alters_data = True def to_objectchange(self, action): objectchange = super().to_objectchange(action) @@ -314,7 +382,7 @@ class CircuitTermination( def get_peer_termination(self): peer_side = 'Z' if self.term_side == 'A' else 'A' try: - return CircuitTermination.objects.prefetch_related('site').get( + return CircuitTermination.objects.prefetch_related('termination').get( circuit=self.circuit, term_side=peer_side ) diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index e79212a14..ab9c661e6 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -18,10 +18,8 @@ __all__ = ( CIRCUITTERMINATION_LINK = """ -{% if value.site %} - {{ value.site }} -{% elif value.provider_network %} - {{ value.provider_network }} +{% if value.termination %} + {{ value.termination }} {% endif %} """ @@ -63,12 +61,12 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): verbose_name=_('Account') ) status = columns.ChoiceFieldColumn() - termination_a = tables.TemplateColumn( + termination_a = columns.TemplateColumn( template_code=CIRCUITTERMINATION_LINK, orderable=False, verbose_name=_('Side A') ) - termination_z = tables.TemplateColumn( + termination_z = columns.TemplateColumn( template_code=CIRCUITTERMINATION_LINK, orderable=False, verbose_name=_('Side Z') @@ -110,22 +108,54 @@ class CircuitTerminationTable(NetBoxTable): linkify=True, accessor='circuit.provider' ) + term_side = tables.Column( + verbose_name=_('Side') + ) + termination_type = columns.ContentTypeColumn( + verbose_name=_('Termination Type'), + ) + termination = tables.Column( + verbose_name=_('Termination Point'), + linkify=True + ) + + # Termination types site = tables.Column( verbose_name=_('Site'), - linkify=True + linkify=True, + accessor='_site' + ) + site_group = tables.Column( + verbose_name=_('Site Group'), + linkify=True, + accessor='_sitegroup' + ) + region = tables.Column( + verbose_name=_('Region'), + linkify=True, + accessor='_region' + ) + location = tables.Column( + verbose_name=_('Location'), + linkify=True, + accessor='_location' ) provider_network = tables.Column( verbose_name=_('Provider Network'), - linkify=True + linkify=True, + accessor='_provider_network' ) class Meta(NetBoxTable.Meta): model = CircuitTermination fields = ( - 'pk', 'id', 'circuit', 'provider', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', - 'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions', + 'pk', 'id', 'circuit', 'provider', 'term_side', 'termination_type', 'termination', 'site_group', 'region', + 'site', 'location', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + 'description', 'created', 'last_updated', 'actions', + ) + default_columns = ( + 'pk', 'id', 'circuit', 'provider', 'term_side', 'termination_type', 'termination', 'description', ) - default_columns = ('pk', 'id', 'circuit', 'provider', 'term_side', 'description') class CircuitGroupTable(NetBoxTable): diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 1edcd531b..1b2e9f3f8 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -181,10 +181,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): Circuit.objects.bulk_create(circuits) circuit_terminations = ( - CircuitTermination(circuit=circuits[0], term_side=SIDE_A, site=sites[0]), - CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, provider_network=provider_networks[0]), - CircuitTermination(circuit=circuits[1], term_side=SIDE_A, site=sites[1]), - CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, provider_network=provider_networks[1]), + CircuitTermination(circuit=circuits[0], term_side=SIDE_A, termination=sites[0]), + CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, termination=provider_networks[0]), + CircuitTermination(circuit=circuits[1], term_side=SIDE_A, termination=sites[1]), + CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, termination=provider_networks[1]), ) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -192,13 +192,15 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): { 'circuit': circuits[2].pk, 'term_side': SIDE_A, - 'site': sites[0].pk, + 'termination_type': 'dcim.site', + 'termination_id': sites[0].pk, 'port_speed': 200000, }, { 'circuit': circuits[2].pk, 'term_side': SIDE_Z, - 'provider_network': provider_networks[0].pk, + 'termination_type': 'circuits.providernetwork', + 'termination_id': provider_networks[0].pk, 'port_speed': 200000, }, ] diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 93958298c..0dbc7172b 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -70,10 +70,12 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): ) Circuit.objects.bulk_create(circuits) - CircuitTermination.objects.bulk_create(( - CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), - CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'), - )) + circuit_terminations = ( + CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'), + CircuitTermination(circuit=circuits[1], termination=sites[0], term_side='A'), + ) + for ct in circuit_terminations: + ct.save() def test_q(self): params = {'q': 'foobar1'} @@ -233,14 +235,15 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): Circuit.objects.bulk_create(circuits) circuit_terminations = (( - CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), - CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'), - CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'), - CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), - CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), - CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), + CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'), + CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A'), + CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A'), + CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'), + CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'), + CircuitTermination(circuit=circuits[5], termination=provider_networks[2], term_side='A'), )) - CircuitTermination.objects.bulk_create(circuit_terminations) + for ct in circuit_terminations: + ct.save() def test_q(self): params = {'q': 'foobar1'} @@ -384,18 +387,19 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): Circuit.objects.bulk_create(circuits) circuit_terminations = (( - CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC', description='foobar1'), - CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'), - CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'), - CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'), - CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'), - CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'), - CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), - CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), - CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), - CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True), + CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC', description='foobar1'), + CircuitTermination(circuit=circuits[0], termination=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'), + CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'), + CircuitTermination(circuit=circuits[1], termination=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'), + CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'), + CircuitTermination(circuit=circuits[2], termination=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'), + CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'), + CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'), + CircuitTermination(circuit=circuits[5], termination=provider_networks[2], term_side='A'), + CircuitTermination(circuit=circuits[6], termination=provider_networks[0], term_side='A', mark_connected=True), )) - CircuitTermination.objects.bulk_create(circuit_terminations) + for ct in circuit_terminations: + ct.save() Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save() diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index b06ade30b..a87e327af 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -1,5 +1,6 @@ import datetime +from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse @@ -190,27 +191,31 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) def test_bulk_import_objects_with_terminations(self): - json_data = """ + site = Site.objects.first() + json_data = f""" [ - { + {{ "cid": "Circuit 7", "provider": "Provider 1", "type": "Circuit Type 1", "status": "active", "description": "Testing Import", "terminations": [ - { + {{ "term_side": "A", - "site": "Site 1" - }, - { + "termination_type": "dcim.site", + "termination_id": "{site.pk}" + }}, + {{ "term_side": "Z", - "site": "Site 1" - } + "termination_type": "dcim.site", + "termination_id": "{site.pk}" + }} ] - } + }} ] """ + initial_count = self._get_queryset().count() data = { 'data': json_data, @@ -336,7 +341,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): +class TestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CircuitTermination @classmethod @@ -359,24 +364,27 @@ class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): Circuit.objects.bulk_create(circuits) circuit_terminations = ( - CircuitTermination(circuit=circuits[0], term_side='A', site=sites[0]), - CircuitTermination(circuit=circuits[0], term_side='Z', site=sites[1]), - CircuitTermination(circuit=circuits[1], term_side='A', site=sites[0]), - CircuitTermination(circuit=circuits[1], term_side='Z', site=sites[1]), + CircuitTermination(circuit=circuits[0], term_side='A', termination=sites[0]), + CircuitTermination(circuit=circuits[0], term_side='Z', termination=sites[1]), + CircuitTermination(circuit=circuits[1], term_side='A', termination=sites[0]), + CircuitTermination(circuit=circuits[1], term_side='Z', termination=sites[1]), ) - CircuitTermination.objects.bulk_create(circuit_terminations) + for ct in circuit_terminations: + ct.save() cls.form_data = { 'circuit': circuits[2].pk, 'term_side': 'A', - 'site': sites[2].pk, + 'termination_type': ContentType.objects.get_for_model(Site).pk, + 'termination': sites[2].pk, 'description': 'New description', } + site = sites[0].pk cls.csv_data = ( - "circuit,term_side,site,description", - "Circuit 3,A,Site 1,Foo", - "Circuit 3,Z,Site 1,Bar", + "circuit,term_side,termination_type,termination_id,description", + f"Circuit 3,A,dcim.site,{site},Foo", + f"Circuit 3,Z,dcim.site,{site},Bar", ) cls.csv_update_data = ( diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 8218960c9..4059065bf 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -158,7 +158,7 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView): instance, extra=( ( - Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance), + Circuit.objects.restrict(request.user, 'view').filter(terminations___provider_network=instance), 'provider_network_id', ), ), @@ -257,8 +257,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitListView(generic.ObjectListView): queryset = Circuit.objects.prefetch_related( - 'tenant__group', 'termination_a__site', 'termination_z__site', - 'termination_a__provider_network', 'termination_z__provider_network', + 'tenant__group', 'termination_a__termination', 'termination_z__termination', ) filterset = filtersets.CircuitFilterSet filterset_form = forms.CircuitFilterForm @@ -298,8 +297,7 @@ class CircuitBulkImportView(generic.BulkImportView): class CircuitBulkEditView(generic.BulkEditView): queryset = Circuit.objects.prefetch_related( - 'termination_a__site', 'termination_z__site', - 'termination_a__provider_network', 'termination_z__provider_network', + 'tenant__group', 'termination_a__termination', 'termination_z__termination', ) filterset = filtersets.CircuitFilterSet table = tables.CircuitTable @@ -308,8 +306,7 @@ class CircuitBulkEditView(generic.BulkEditView): class CircuitBulkDeleteView(generic.BulkDeleteView): queryset = Circuit.objects.prefetch_related( - 'termination_a__site', 'termination_z__site', - 'termination_a__provider_network', 'termination_z__provider_network', + 'tenant__group', 'termination_a__termination', 'termination_z__termination', ) filterset = filtersets.CircuitFilterSet table = tables.CircuitTable diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index db9f3899d..da7a0af25 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -462,6 +462,10 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] children: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] + @strawberry_django.field + def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: + return self.circuit_terminations.all() + @strawberry_django.type( models.Manufacturer, @@ -705,6 +709,10 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None: return self.parent + @strawberry_django.field + def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: + return self.circuit_terminations.all() + @strawberry_django.type( models.Site, @@ -726,10 +734,13 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]] - circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]] clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]] vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] + @strawberry_django.field + def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: + return self.circuit_terminations.all() + @strawberry_django.type( models.SiteGroup, @@ -746,6 +757,10 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None: return self.parent + @strawberry_django.field + def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: + return self.circuit_terminations.all() + @strawberry_django.type( models.VirtualChassis, diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 9f47a63d3..744ec025c 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -344,7 +344,7 @@ class CableTermination(ChangeLoggedModel): ) # A CircuitTermination attached to a ProviderNetwork cannot have a Cable - if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None: + if self.termination_type.model == 'circuittermination' and self.termination._provider_network is not None: raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled.")) def save(self, *args, **kwargs): @@ -690,19 +690,19 @@ class CablePath(models.Model): ).first() if circuit_termination is None: break - elif circuit_termination.provider_network: + elif circuit_termination._provider_network: # Circuit terminates to a ProviderNetwork path.extend([ [object_to_path_node(circuit_termination)], - [object_to_path_node(circuit_termination.provider_network)], + [object_to_path_node(circuit_termination._provider_network)], ]) is_complete = True break - elif circuit_termination.site and not circuit_termination.cable: - # Circuit terminates to a Site + elif circuit_termination.termination and not circuit_termination.cable: + # Circuit terminates to a Region/Site/etc. path.extend([ [object_to_path_node(circuit_termination)], - [object_to_path_node(circuit_termination.site)], + [object_to_path_node(circuit_termination.termination)], ]) break diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index f7c337bdf..b504d389a 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1167,7 +1167,7 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [CT1] """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') - circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A') # Create cable 1 cable1 = Cable( @@ -1198,7 +1198,7 @@ class CablePathTestCase(TestCase): """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') - circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A') # Create cable 1 cable1 = Cable( @@ -1214,7 +1214,7 @@ class CablePathTestCase(TestCase): ) # Create CT2 - circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z') # Check for partial path to site self.assertPathExists( @@ -1266,7 +1266,7 @@ class CablePathTestCase(TestCase): interface2 = Interface.objects.create(device=self.device, name='Interface 2') interface3 = Interface.objects.create(device=self.device, name='Interface 3') interface4 = Interface.objects.create(device=self.device, name='Interface 4') - circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A') # Create cable 1 cable1 = Cable( @@ -1282,7 +1282,7 @@ class CablePathTestCase(TestCase): ) # Create CT2 - circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z') # Check for partial path to site self.assertPathExists( @@ -1335,8 +1335,8 @@ class CablePathTestCase(TestCase): """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') site2 = Site.objects.create(name='Site 2', slug='site-2') - circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') - circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site2, term_side='Z') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=site2, term_side='Z') # Create cable 1 cable1 = Cable( @@ -1365,8 +1365,8 @@ class CablePathTestCase(TestCase): """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider) - circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') - circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, provider_network=providernetwork, term_side='Z') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=providernetwork, term_side='Z') # Create cable 1 cable1 = Cable( @@ -1413,8 +1413,8 @@ class CablePathTestCase(TestCase): frontport2_2 = FrontPort.objects.create( device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2 ) - circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') - circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z') # Create cables cable1 = Cable( @@ -1499,10 +1499,10 @@ class CablePathTestCase(TestCase): interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') circuit2 = Circuit.objects.create(provider=self.circuit.provider, type=self.circuit.type, cid='Circuit 2') - circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') - circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') - circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='A') - circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='Z') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z') + circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, termination=self.site, term_side='A') + circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, termination=self.site, term_side='Z') # Create cables cable1 = Cable( diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 0a6417022..c5ca01db2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -5135,7 +5135,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): provider = Provider.objects.create(name='Provider 1', slug='provider-1') circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') circuit = Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type) - circuit_termination = CircuitTermination.objects.create(circuit=circuit, term_side='A', site=sites[0]) + circuit_termination = CircuitTermination.objects.create(circuit=circuit, term_side='A', termination=sites[0]) # Cables cables = ( @@ -5308,9 +5308,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): def test_site(self): site = Site.objects.all()[:2] params = {'site_id': [site[0].pk, site[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 12) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 11) params = {'site': [site[0].slug, site[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 12) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 11) def test_tenant(self): tenant = Tenant.objects.all()[:2] diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index c11badbdd..f0fe4da3b 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -762,9 +762,9 @@ class CableTestCase(TestCase): circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1') circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2') - CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A') - CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z') - CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A') + CircuitTermination.objects.create(circuit=circuit1, termination=site, term_side='A') + CircuitTermination.objects.create(circuit=circuit1, termination=site, term_side='Z') + CircuitTermination.objects.create(circuit=circuit2, termination=provider_network, term_side='A') def test_cable_creation(self): """ diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 38c3f68c3..9a821a384 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -242,6 +242,10 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView): extra=( (Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), (Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), + ( + Circuit.objects.restrict(request.user, 'view').filter(terminations___region=instance).distinct(), + 'region_id' + ), ), ), } @@ -324,6 +328,10 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): extra=( (Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), (Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), + ( + Circuit.objects.restrict(request.user, 'view').filter(terminations___site_group=instance).distinct(), + 'site_group_id' + ), ), ), } @@ -404,8 +412,10 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): scope_id=instance.pk ), 'site'), (ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'), - (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), - 'site_id'), + ( + Circuit.objects.restrict(request.user, 'view').filter(terminations___site=instance).distinct(), + 'site_id' + ), ), ), } @@ -475,7 +485,17 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView): def get_extra_context(self, request, instance): locations = instance.get_descendants(include_self=True) return { - 'related_models': self.get_related_models(request, locations, [CableTermination]), + 'related_models': self.get_related_models( + request, + locations, + [CableTermination], + ( + ( + Circuit.objects.restrict(request.user, 'view').filter(terminations___location=instance).distinct(), + 'location_id' + ), + ), + ), } diff --git a/netbox/templates/circuits/inc/circuit_termination_fields.html b/netbox/templates/circuits/inc/circuit_termination_fields.html index 97d194f24..94c4599b0 100644 --- a/netbox/templates/circuits/inc/circuit_termination_fields.html +++ b/netbox/templates/circuits/inc/circuit_termination_fields.html @@ -1,18 +1,19 @@ {% load helpers %} {% load i18n %} -{% if termination.site %} - {% trans "Site" %} - - {% if termination.site.region %} - {{ termination.site.region|linkify }} / - {% endif %} - {{ termination.site|linkify }} - + {% trans "Termination point" %} + {% if termination.termination %} + + {{ termination.termination|linkify }} +
{% trans termination.termination_type.name|bettertitle %}
+ + {% else %} + {{ ''|placeholder }} + {% endif %} - {% trans "Termination" %} + {% trans "Connection" %} {% if termination.mark_connected %} @@ -57,12 +58,6 @@ {% endif %} -{% else %} - - {% trans "Provider Network" %} - {{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }} - -{% endif %} {% trans "Speed" %} From 8767fd818610bd5efc48ada5aec0760d26eae467 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 31 Oct 2024 14:17:06 -0400 Subject: [PATCH 21/65] Closes #13428: Q-in-Q VLANs (#17822) * Initial work on #13428 (QinQ) * Misc cleanup; add tests for Q-in-Q fields * Address PR feedback --- docs/models/dcim/interface.md | 4 ++ docs/models/ipam/vlan.md | 8 ++++ docs/models/virtualization/vminterface.md | 4 ++ .../api/serializers_/device_components.py | 9 ++-- netbox/dcim/choices.py | 2 + netbox/dcim/filtersets.py | 6 ++- netbox/dcim/forms/common.py | 2 + netbox/dcim/forms/model_forms.py | 18 ++++++- netbox/dcim/graphql/types.py | 1 + netbox/dcim/migrations/0196_qinq_svlan.py | 28 +++++++++++ netbox/dcim/models/device_components.py | 47 +++++++++++++------ netbox/dcim/tables/devices.py | 16 ++++--- netbox/dcim/tests/test_api.py | 6 +++ netbox/dcim/tests/test_filtersets.py | 29 ++++++++++-- netbox/ipam/api/serializers_/nested.py | 8 ++++ netbox/ipam/api/serializers_/vlans.py | 7 ++- netbox/ipam/choices.py | 11 +++++ netbox/ipam/filtersets.py | 11 +++++ netbox/ipam/forms/bulk_edit.py | 16 ++++++- netbox/ipam/forms/bulk_import.py | 18 ++++++- netbox/ipam/forms/filtersets.py | 12 +++++ netbox/ipam/forms/model_forms.py | 12 ++++- netbox/ipam/graphql/types.py | 6 ++- netbox/ipam/migrations/0075_vlan_qinq.py | 30 ++++++++++++ netbox/ipam/models/vlans.py | 40 +++++++++++++++- netbox/ipam/tables/vlans.py | 9 +++- netbox/ipam/tests/test_api.py | 7 +++ netbox/ipam/tests/test_filtersets.py | 24 ++++++++++ netbox/ipam/tests/test_models.py | 21 +++++++++ netbox/templates/ipam/vlan.html | 31 ++++++++++++ netbox/templates/ipam/vlan_edit.html | 8 ++++ .../api/serializers_/virtualmachines.py | 7 +-- netbox/virtualization/forms/model_forms.py | 20 ++++++-- netbox/virtualization/graphql/types.py | 1 + ...042_vminterface_vlan_translation_policy.py | 2 - .../migrations/0043_qinq_svlan.py | 28 +++++++++++ .../virtualization/models/virtualmachines.py | 14 ------ .../virtualization/tables/virtualmachines.py | 7 +-- netbox/virtualization/tests/test_api.py | 8 ++++ .../virtualization/tests/test_filtersets.py | 24 ++++++++-- 40 files changed, 492 insertions(+), 70 deletions(-) create mode 100644 netbox/dcim/migrations/0196_qinq_svlan.py create mode 100644 netbox/ipam/migrations/0075_vlan_qinq.py create mode 100644 netbox/virtualization/migrations/0043_qinq_svlan.py diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 869cb8510..fb7198682 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -120,6 +120,10 @@ The "native" (untagged) VLAN for the interface. Valid only when one of the above The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above. +### Q-in-Q SVLAN + +The assigned service VLAN (for Q-in-Q/802.1ad interfaces). + ### Wireless Role Indicates the configured role for wireless interfaces (access point or station). diff --git a/docs/models/ipam/vlan.md b/docs/models/ipam/vlan.md index 2dd5ec2d3..dc547ddbc 100644 --- a/docs/models/ipam/vlan.md +++ b/docs/models/ipam/vlan.md @@ -26,3 +26,11 @@ The user-defined functional [role](./role.md) assigned to the VLAN. ### VLAN Group or Site The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned. + +### Q-in-Q Role + +For VLANs which comprise a Q-in-Q/IEEE 802.1ad topology, this field indicates whether the VLAN is treated as a service or customer VLAN. + +### Q-in-Q Service VLAN + +The designated parent service VLAN for a Q-in-Q customer VLAN. This may be set only for Q-in-Q custom VLANs. diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md index 1e022b091..4a0c474f9 100644 --- a/docs/models/virtualization/vminterface.md +++ b/docs/models/virtualization/vminterface.md @@ -53,6 +53,10 @@ The "native" (untagged) VLAN for the interface. Valid only when one of the above The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above. +### Q-in-Q SVLAN + +The assigned service VLAN (for Q-in-Q/802.1ad interfaces). + ### VRF The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned. diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index 3be19bb58..57111c2af 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -196,6 +196,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect required=False, many=True ) + qinq_svlan = VLANSerializer(nested=True, required=False, allow_null=True) vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True) l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) @@ -223,10 +224,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect 'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', - 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', - 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', - 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', 'vlan_translation_policy' + 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected', + 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', + 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', + 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index fee587e6b..e1a99e0db 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1258,11 +1258,13 @@ class InterfaceModeChoices(ChoiceSet): MODE_ACCESS = 'access' MODE_TAGGED = 'tagged' MODE_TAGGED_ALL = 'tagged-all' + MODE_Q_IN_Q = 'q-in-q' CHOICES = ( (MODE_ACCESS, _('Access')), (MODE_TAGGED, _('Tagged')), (MODE_TAGGED_ALL, _('Tagged (All)')), + (MODE_Q_IN_Q, _('Q-in-Q (802.1ad)')), ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index c11a7ef00..0371f882b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1647,7 +1647,8 @@ class CommonInterfaceFilterSet(django_filters.FilterSet): return queryset return queryset.filter( Q(untagged_vlan_id=value) | - Q(tagged_vlans=value) + Q(tagged_vlans=value) | + Q(qinq_svlan=value) ) def filter_vlan(self, queryset, name, value): @@ -1656,7 +1657,8 @@ class CommonInterfaceFilterSet(django_filters.FilterSet): return queryset return queryset.filter( Q(untagged_vlan_id__vid=value) | - Q(tagged_vlans__vid=value) + Q(tagged_vlans__vid=value) | + Q(qinq_svlan__vid=value) ) diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py index 4341ec041..bae7fd222 100644 --- a/netbox/dcim/forms/common.py +++ b/netbox/dcim/forms/common.py @@ -37,6 +37,8 @@ class InterfaceCommonForm(forms.Form): del self.fields['vlan_group'] del self.fields['untagged_vlan'] del self.fields['tagged_vlans'] + if interface_mode != InterfaceModeChoices.MODE_Q_IN_Q: + del self.fields['qinq_svlan'] def clean(self): super().clean() diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 1ab9f138b..2fcdbe5fd 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -7,6 +7,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.models import ConfigTemplate +from ipam.choices import VLANQinQRoleChoices from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm @@ -1372,6 +1373,16 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): 'available_on_device': '$device', } ) + qinq_svlan = DynamicModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + label=_('Q-in-Q Service VLAN'), + query_params={ + 'group_id': '$vlan_group', + 'available_on_device': '$device', + 'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE, + } + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -1396,7 +1407,10 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), FieldSet('poe_mode', 'poe_type', name=_('PoE')), - FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy', name=_('802.1Q Switching')), + FieldSet( + 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', + name=_('802.1Q Switching') + ), FieldSet( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', name=_('Wireless') @@ -1409,7 +1423,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): 'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'vlan_translation_policy', + 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags', ] widgets = { 'speed': NumberWithOptions( diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index da7a0af25..5965fdcec 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -385,6 +385,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None + qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]] diff --git a/netbox/dcim/migrations/0196_qinq_svlan.py b/netbox/dcim/migrations/0196_qinq_svlan.py new file mode 100644 index 000000000..9012d74f3 --- /dev/null +++ b/netbox/dcim/migrations/0196_qinq_svlan.py @@ -0,0 +1,28 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0195_interface_vlan_translation_policy'), + ('ipam', '0075_vlan_qinq'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='qinq_svlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_svlan', to='ipam.vlan'), + ), + migrations.AlterField( + model_name='interface', + name='tagged_vlans', + field=models.ManyToManyField(blank=True, related_name='%(class)ss_as_tagged', to='ipam.vlan'), + ), + migrations.AlterField( + model_name='interface', + name='untagged_vlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_as_untagged', to='ipam.vlan'), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 14f4120b5..36fd02add 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -547,17 +547,48 @@ class BaseInterface(models.Model): blank=True, verbose_name=_('bridge interface') ) + untagged_vlan = models.ForeignKey( + to='ipam.VLAN', + on_delete=models.SET_NULL, + related_name='%(class)ss_as_untagged', + null=True, + blank=True, + verbose_name=_('untagged VLAN') + ) + tagged_vlans = models.ManyToManyField( + to='ipam.VLAN', + related_name='%(class)ss_as_tagged', + blank=True, + verbose_name=_('tagged VLANs') + ) + qinq_svlan = models.ForeignKey( + to='ipam.VLAN', + on_delete=models.SET_NULL, + related_name='%(class)ss_svlan', + null=True, + blank=True, + verbose_name=_('Q-in-Q SVLAN') + ) vlan_translation_policy = models.ForeignKey( to='ipam.VLANTranslationPolicy', on_delete=models.PROTECT, null=True, blank=True, - verbose_name=_('VLAN Translation Policy'), + verbose_name=_('VLAN Translation Policy') ) class Meta: abstract = True + def clean(self): + super().clean() + + # SVLAN can be defined only for Q-in-Q interfaces + if self.qinq_svlan and self.mode != InterfaceModeChoices.MODE_Q_IN_Q: + raise ValidationError({ + 'qinq_svlan': _("Only Q-in-Q interfaces may specify a service VLAN.") + }) + def save(self, *args, **kwargs): # Remove untagged VLAN assignment for non-802.1Q interfaces @@ -697,20 +728,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd blank=True, verbose_name=_('wireless LANs') ) - untagged_vlan = models.ForeignKey( - to='ipam.VLAN', - on_delete=models.SET_NULL, - related_name='interfaces_as_untagged', - null=True, - blank=True, - verbose_name=_('untagged VLAN') - ) - tagged_vlans = models.ManyToManyField( - to='ipam.VLAN', - related_name='interfaces_as_tagged', - blank=True, - verbose_name=_('tagged VLANs') - ) vrf = models.ForeignKey( to='ipam.VRF', on_delete=models.SET_NULL, diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index b39a2b87f..fed33401c 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -585,6 +585,10 @@ class BaseInterfaceTable(NetBoxTable): orderable=False, verbose_name=_('Tagged VLANs') ) + qinq_svlan = tables.Column( + verbose_name=_('Q-in-Q SVLAN'), + linkify=True + ) def value_ip_addresses(self, value): return ",".join([str(obj.address) for obj in value.all()]) @@ -635,11 +639,11 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi model = models.Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', - 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', - 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', - 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', - 'last_updated', + 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', + 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', + 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', + 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', + 'inventory_items', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -676,7 +680,7 @@ class DeviceInterfaceTable(InterfaceTable): 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', - 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', + 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 1b460cd59..f78722b67 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -7,6 +7,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.models import ConfigTemplate +from ipam.choices import VLANQinQRoleChoices from ipam.models import ASN, RIR, VLAN, VRF from netbox.api.serializers import GenericObjectSerializer from tenancy.models import Tenant @@ -1618,6 +1619,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase VLAN(name='VLAN 1', vid=1), VLAN(name='VLAN 2', vid=2), VLAN(name='VLAN 3', vid=3), + VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), ) VLAN.objects.bulk_create(vlans) @@ -1676,18 +1678,22 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'vdcs': [vdcs[1].pk], 'name': 'Interface 7', 'type': InterfaceTypeChoices.TYPE_80211A, + 'mode': InterfaceModeChoices.MODE_Q_IN_Q, 'tx_power': 10, 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], 'rf_channel': WirelessChannelChoices.CHANNEL_5G_32, + 'qinq_svlan': vlans[3].pk, }, { 'device': device.pk, 'vdcs': [vdcs[1].pk], 'name': 'Interface 8', 'type': InterfaceTypeChoices.TYPE_80211A, + 'mode': InterfaceModeChoices.MODE_Q_IN_Q, 'tx_power': 10, 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], 'rf_channel': "", + 'qinq_svlan': vlans[3].pk, }, ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index c5ca01db2..993c2fa4e 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4,7 +4,8 @@ from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from dcim.choices import * from dcim.filtersets import * from dcim.models import * -from ipam.models import ASN, IPAddress, RIR, VLANTranslationPolicy, VRF +from ipam.choices import VLANQinQRoleChoices +from ipam.models import ASN, IPAddress, RIR, VLAN, VLANTranslationPolicy, VRF from netbox.choices import ColorChoices, WeightUnitChoices from tenancy.models import Tenant, TenantGroup from users.models import User @@ -3520,7 +3521,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = Interface.objects.all() filterset = InterfaceFilterSet - ignore_fields = ('tagged_vlans', 'untagged_vlan', 'vdcs') + ignore_fields = ('tagged_vlans', 'untagged_vlan', 'qinq_svlan', 'vdcs') @classmethod def setUpTestData(cls): @@ -3669,6 +3670,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil ) VirtualDeviceContext.objects.bulk_create(vdcs) + vlans = ( + VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), + VLAN(name='SVLAN 2', vid=1002, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), + VLAN(name='SVLAN 3', vid=1003, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), + ) + VLAN.objects.bulk_create(vlans) + vlan_translation_policies = ( VLANTranslationPolicy(name='Policy 1'), VLANTranslationPolicy(name='Policy 2'), @@ -3753,6 +3761,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil duplex='full', poe_mode=InterfacePoEModeChoices.MODE_PD, poe_type=InterfacePoETypeChoices.TYPE_2_8023AT, + mode=InterfaceModeChoices.MODE_Q_IN_Q, + qinq_svlan=vlans[0], vlan_translation_policy=vlan_translation_policies[1], ), Interface( @@ -3762,7 +3772,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, - tx_power=40 + tx_power=40, + mode=InterfaceModeChoices.MODE_Q_IN_Q, + qinq_svlan=vlans[1] ), Interface( device=devices[4], @@ -3771,7 +3783,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, - tx_power=40 + tx_power=40, + mode=InterfaceModeChoices.MODE_Q_IN_Q, + qinq_svlan=vlans[2] ), Interface( device=devices[4], @@ -4027,6 +4041,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil params = {'vdc_identifier': vdc.values_list('identifier', flat=True)} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_vlan(self): + vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first() + params = {'vlan_id': vlan.pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'vlan': vlan.vid} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_vlan_translation_policy(self): vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2] params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]} diff --git a/netbox/ipam/api/serializers_/nested.py b/netbox/ipam/api/serializers_/nested.py index 5297565bb..b56b15984 100644 --- a/netbox/ipam/api/serializers_/nested.py +++ b/netbox/ipam/api/serializers_/nested.py @@ -6,6 +6,7 @@ from ..field_serializers import IPAddressField __all__ = ( 'NestedIPAddressSerializer', + 'NestedVLANSerializer', ) @@ -16,3 +17,10 @@ class NestedIPAddressSerializer(WritableNestedSerializer): class Meta: model = models.IPAddress fields = ['id', 'url', 'display_url', 'display', 'family', 'address'] + + +class NestedVLANSerializer(WritableNestedSerializer): + + class Meta: + model = models.VLAN + fields = ['id', 'url', 'display', 'vid', 'name', 'description'] diff --git a/netbox/ipam/api/serializers_/vlans.py b/netbox/ipam/api/serializers_/vlans.py index ee06357a5..05fdd5813 100644 --- a/netbox/ipam/api/serializers_/vlans.py +++ b/netbox/ipam/api/serializers_/vlans.py @@ -11,6 +11,7 @@ from netbox.api.serializers import NetBoxModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer from utilities.api import get_serializer_for_model from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer +from .nested import NestedVLANSerializer from .roles import RoleSerializer __all__ = ( @@ -64,6 +65,8 @@ class VLANSerializer(NetBoxModelSerializer): tenant = TenantSerializer(nested=True, required=False, allow_null=True) status = ChoiceField(choices=VLANStatusChoices, required=False) role = RoleSerializer(nested=True, required=False, allow_null=True) + qinq_role = ChoiceField(choices=VLANQinQRoleChoices, required=False) + qinq_svlan = NestedVLANSerializer(required=False, allow_null=True, default=None) l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) # Related object counts @@ -73,8 +76,8 @@ class VLANSerializer(NetBoxModelSerializer): model = VLAN fields = [ 'id', 'url', 'display_url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', - 'description', 'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', - 'prefix_count', + 'description', 'qinq_role', 'qinq_svlan', 'comments', 'l2vpn_termination', 'tags', 'custom_fields', + 'created', 'last_updated', 'prefix_count', ] brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description') diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 017fd0430..4d9c0bdd4 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -157,6 +157,17 @@ class VLANStatusChoices(ChoiceSet): ] +class VLANQinQRoleChoices(ChoiceSet): + + ROLE_SERVICE = 's-vlan' + ROLE_CUSTOMER = 'c-vlan' + + CHOICES = [ + (ROLE_SERVICE, _('Service'), 'blue'), + (ROLE_CUSTOMER, _('Customer'), 'orange'), + ] + + # # Services # diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 017a34ac4..88c869a50 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -1041,6 +1041,17 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): queryset=VirtualMachine.objects.all(), method='get_for_virtualmachine' ) + qinq_role = django_filters.MultipleChoiceFilter( + choices=VLANQinQRoleChoices + ) + qinq_svlan_id = django_filters.ModelMultipleChoiceFilter( + queryset=VLAN.objects.all(), + label=_('Q-in-Q SVLAN (ID)'), + ) + qinq_svlan_vid = MultiValueNumberFilter( + field_name='qinq_svlan__vid', + label=_('Q-in-Q SVLAN number (1-4094)'), + ) l2vpn_id = django_filters.ModelMultipleChoiceFilter( field_name='l2vpn_terminations__l2vpn', queryset=L2VPN.objects.all(), diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 49a79623c..c323a41c1 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -527,15 +527,29 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + qinq_role = forms.ChoiceField( + label=_('Q-in-Q role'), + choices=add_blank_choice(VLANQinQRoleChoices), + required=False + ) + qinq_svlan = DynamicModelChoiceField( + label=_('Q-in-Q SVLAN'), + queryset=VLAN.objects.all(), + required=False, + query_params={ + 'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE, + } + ) comments = CommentField() model = VLAN fieldsets = ( FieldSet('status', 'role', 'tenant', 'description'), + FieldSet('qinq_role', 'qinq_svlan', name=_('Q-in-Q')), FieldSet('region', 'site_group', 'site', 'group', name=_('Site & Group')), ) nullable_fields = ( - 'site', 'group', 'tenant', 'role', 'description', 'comments', + 'site', 'group', 'tenant', 'role', 'description', 'qinq_role', 'qinq_svlan', 'comments', ) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index cd34a6d84..3be4ccc59 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -461,10 +461,26 @@ class VLANImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Functional role') ) + qinq_role = CSVChoiceField( + label=_('Q-in-Q role'), + choices=VLANStatusChoices, + required=False, + help_text=_('Operational status') + ) + qinq_svlan = CSVModelChoiceField( + label=_('Q-in-Q SVLAN'), + queryset=VLAN.objects.all(), + required=False, + to_field_name='vid', + help_text=_("Service VLAN (for Q-in-Q/802.1ad customer VLANs)") + ) class Meta: model = VLAN - fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments', 'tags') + fields = ( + 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'qinq_role', 'qinq_svlan', + 'comments', 'tags', + ) class VLANTranslationPolicyImportForm(NetBoxModelImportForm): diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index b9bee6d97..3f951512b 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -506,6 +506,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): FieldSet('q', 'filter_id', 'tag'), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')), + FieldSet('qinq_role', 'qinq_svlan_id', name=_('Q-in-Q/802.1ad')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) selector_fields = ('filter_id', 'q', 'site_id') @@ -552,6 +553,17 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): required=False, label=_('VLAN ID') ) + qinq_role = forms.MultipleChoiceField( + label=_('Q-in-Q role'), + choices=VLANQinQRoleChoices, + required=False + ) + qinq_svlan_id = DynamicModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + null_option='None', + label=_('Q-in-Q SVLAN') + ) l2vpn_id = DynamicModelMultipleChoiceField( queryset=L2VPN.objects.all(), required=False, diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 629c1a481..3d0cd3dd1 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -683,13 +683,21 @@ class VLANForm(TenancyForm, NetBoxModelForm): queryset=Role.objects.all(), required=False ) + qinq_svlan = DynamicModelChoiceField( + label=_('Q-in-Q SVLAN'), + queryset=VLAN.objects.all(), + required=False, + query_params={ + 'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE, + } + ) comments = CommentField() class Meta: model = VLAN fields = [ - 'site', 'group', 'vid', 'name', 'status', 'role', 'tenant_group', 'tenant', 'description', 'comments', - 'tags', + 'site', 'group', 'vid', 'name', 'status', 'role', 'tenant_group', 'tenant', 'qinq_role', 'qinq_svlan', + 'description', 'comments', 'tags', ] diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index ef50138c2..2ef63cf0c 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -236,7 +236,7 @@ class ServiceTemplateType(NetBoxObjectType): @strawberry_django.type( models.VLAN, - fields='__all__', + exclude=('qinq_svlan',), filters=VLANFilter ) class VLANType(NetBoxObjectType): @@ -252,6 +252,10 @@ class VLANType(NetBoxObjectType): interfaces_as_tagged: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] vminterfaces_as_tagged: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]] + @strawberry_django.field + def qinq_svlan(self) -> Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None: + return self.qinq_svlan + @strawberry_django.type( models.VLANGroup, diff --git a/netbox/ipam/migrations/0075_vlan_qinq.py b/netbox/ipam/migrations/0075_vlan_qinq.py new file mode 100644 index 000000000..8a3b8a39a --- /dev/null +++ b/netbox/ipam/migrations/0075_vlan_qinq.py @@ -0,0 +1,30 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0074_vlantranslationpolicy_vlantranslationrule'), + ] + + operations = [ + migrations.AddField( + model_name='vlan', + name='qinq_role', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AddField( + model_name='vlan', + name='qinq_svlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='qinq_cvlans', to='ipam.vlan'), + ), + migrations.AddConstraint( + model_name='vlan', + constraint=models.UniqueConstraint(fields=('qinq_svlan', 'vid'), name='ipam_vlan_unique_qinq_svlan_vid'), + ), + migrations.AddConstraint( + model_name='vlan', + constraint=models.UniqueConstraint(fields=('qinq_svlan', 'name'), name='ipam_vlan_unique_qinq_svlan_name'), + ), + ] diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index ff8394839..7832cfc67 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -204,6 +204,21 @@ class VLAN(PrimaryModel): null=True, help_text=_("The primary function of this VLAN") ) + qinq_svlan = models.ForeignKey( + to='self', + on_delete=models.PROTECT, + related_name='qinq_cvlans', + blank=True, + null=True + ) + qinq_role = models.CharField( + verbose_name=_('Q-in-Q role'), + max_length=50, + choices=VLANQinQRoleChoices, + blank=True, + null=True, + help_text=_("Customer/service VLAN designation (for Q-in-Q/IEEE 802.1ad)") + ) l2vpn_terminations = GenericRelation( to='vpn.L2VPNTermination', content_type_field='assigned_object_type', @@ -214,7 +229,7 @@ class VLAN(PrimaryModel): objects = VLANQuerySet.as_manager() clone_fields = [ - 'site', 'group', 'tenant', 'status', 'role', 'description', + 'site', 'group', 'tenant', 'status', 'role', 'description', 'qinq_role', 'qinq_svlan', ] class Meta: @@ -228,6 +243,14 @@ class VLAN(PrimaryModel): fields=('group', 'name'), name='%(app_label)s_%(class)s_unique_group_name' ), + models.UniqueConstraint( + fields=('qinq_svlan', 'vid'), + name='%(app_label)s_%(class)s_unique_qinq_svlan_vid' + ), + models.UniqueConstraint( + fields=('qinq_svlan', 'name'), + name='%(app_label)s_%(class)s_unique_qinq_svlan_name' + ), ) verbose_name = _('VLAN') verbose_name_plural = _('VLANs') @@ -255,9 +278,24 @@ class VLAN(PrimaryModel): ).format(ranges=ranges_to_string(self.group.vid_ranges), group=self.group) }) + # Only Q-in-Q customer VLANs may be assigned to a service VLAN + if self.qinq_svlan and self.qinq_role != VLANQinQRoleChoices.ROLE_CUSTOMER: + raise ValidationError({ + 'qinq_svlan': _("Only Q-in-Q customer VLANs maybe assigned to a service VLAN.") + }) + + # A Q-in-Q customer VLAN must be assigned to a service VLAN + if self.qinq_role == VLANQinQRoleChoices.ROLE_CUSTOMER and not self.qinq_svlan: + raise ValidationError({ + 'qinq_role': _("A Q-in-Q customer VLAN must be assigned to a service VLAN.") + }) + def get_status_color(self): return VLANStatusChoices.colors.get(self.status) + def get_qinq_role_color(self): + return VLANQinQRoleChoices.colors.get(self.qinq_role) + def get_interfaces(self): # Return all device interfaces assigned to this VLAN return Interface.objects.filter( diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 1a06bb700..d34ff5f45 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -132,6 +132,13 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable): verbose_name=_('Role'), linkify=True ) + qinq_role = columns.ChoiceFieldColumn( + verbose_name=_('Q-in-Q role') + ) + qinq_svlan = tables.Column( + verbose_name=_('Q-in-Q SVLAN'), + linkify=True + ) l2vpn = tables.Column( accessor=tables.A('l2vpn_termination__l2vpn'), linkify=True, @@ -154,7 +161,7 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable): model = VLAN fields = ( 'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role', - 'description', 'comments', 'tags', 'l2vpn', 'created', 'last_updated', + 'qinq_role', 'qinq_svlan', 'description', 'comments', 'tags', 'l2vpn', 'created', 'last_updated', ) default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description') row_attrs = { diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index cd3e47342..4e8456e5a 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -980,6 +980,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase): VLAN(name='VLAN 1', vid=1, group=vlan_groups[0]), VLAN(name='VLAN 2', vid=2, group=vlan_groups[0]), VLAN(name='VLAN 3', vid=3, group=vlan_groups[0]), + VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), ) VLAN.objects.bulk_create(vlans) @@ -999,6 +1000,12 @@ class VLANTest(APIViewTestCases.APIViewTestCase): 'name': 'VLAN 6', 'group': vlan_groups[1].pk, }, + { + 'vid': 2001, + 'name': 'CVLAN 1', + 'qinq_role': VLANQinQRoleChoices.ROLE_CUSTOMER, + 'qinq_svlan': vlans[3].pk, + }, ] def test_delete_vlan_with_prefix(self): diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index b402a8426..f651c970d 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1630,6 +1630,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 4', slug='site-4', region=regions[0], group=site_groups[0]), Site(name='Site 5', slug='site-5', region=regions[1], group=site_groups[1]), Site(name='Site 6', slug='site-6', region=regions[2], group=site_groups[2]), + Site(name='Site 7', slug='site-7'), ) Site.objects.bulk_create(sites) @@ -1784,9 +1785,21 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): # Create one globally available VLAN VLAN(vid=1000, name='Global VLAN'), + + # Create some Q-in-Q service VLANs + VLAN(vid=2001, name='SVLAN 1', site=sites[6], qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), + VLAN(vid=2002, name='SVLAN 2', site=sites[6], qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), + VLAN(vid=2003, name='SVLAN 3', site=sites[6], qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), ) VLAN.objects.bulk_create(vlans) + # Create Q-in-Q customer VLANs + VLAN.objects.bulk_create([ + VLAN(vid=3001, name='CVLAN 1', site=sites[6], qinq_svlan=vlans[29], qinq_role=VLANQinQRoleChoices.ROLE_CUSTOMER), + VLAN(vid=3002, name='CVLAN 2', site=sites[6], qinq_svlan=vlans[30], qinq_role=VLANQinQRoleChoices.ROLE_CUSTOMER), + VLAN(vid=3003, name='CVLAN 3', site=sites[6], qinq_svlan=vlans[31], qinq_role=VLANQinQRoleChoices.ROLE_CUSTOMER), + ]) + # Assign VLANs to device interfaces interfaces[0].untagged_vlan = vlans[0] interfaces[0].tagged_vlans.add(vlans[1]) @@ -1897,6 +1910,17 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'vminterface_id': vminterface_id} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_qinq_role(self): + params = {'qinq_role': [VLANQinQRoleChoices.ROLE_SERVICE, VLANQinQRoleChoices.ROLE_CUSTOMER]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_qinq_svlan(self): + vlans = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE)[:2] + params = {'qinq_svlan_id': [vlans[0].pk, vlans[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'qinq_svlan_vid': [vlans[0].vid, vlans[1].vid]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class VLANTranslationPolicyTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VLANTranslationPolicy.objects.all() diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index d14fa0657..917b50f33 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -586,3 +586,24 @@ class TestVLANGroup(TestCase): vlangroup.vid_ranges = string_to_ranges('2-2') vlangroup.full_clean() vlangroup.save() + + +class TestVLAN(TestCase): + + @classmethod + def setUpTestData(cls): + VLAN.objects.bulk_create(( + VLAN(name='VLAN 1', vid=1, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), + )) + + def test_qinq_role(self): + svlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first() + + vlan = VLAN( + name='VLAN X', + vid=999, + qinq_role=VLANQinQRoleChoices.ROLE_SERVICE, + qinq_svlan=svlan + ) + with self.assertRaises(ValidationError): + vlan.full_clean() diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 95a4f7856..a10a1439a 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -62,6 +62,22 @@ {% trans "Description" %} {{ object.description|placeholder }} + + {% trans "Q-in-Q Role" %} + + {% if object.qinq_role %} + {% badge object.get_qinq_role_display bg_color=object.get_qinq_role_color %} + {% else %} + {{ ''|placeholder }} + {% endif %} + + + {% if object.qinq_role == 'c-vlan' %} + + {% trans "Q-in-Q SVLAN" %} + {{ object.qinq_svlan|linkify|placeholder }} + + {% endif %} {% trans "L2VPN" %} {{ object.l2vpn_termination.l2vpn|linkify|placeholder }} @@ -92,6 +108,21 @@ {% htmx_table 'ipam:prefix_list' vlan_id=object.pk %}
+ {% if object.qinq_role == 's-vlan' %} +
+

+ {% trans "Customer VLANs" %} + {% if perms.ipam.add_vlan %} + + {% endif %} +

+ {% htmx_table 'ipam:vlan_list' qinq_svlan_id=object.pk %} +
+ {% endif %} {% plugin_full_width_page object %}
diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index 814fc6b78..885844580 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -17,6 +17,14 @@ {% render_field form.tags %} +
+
+

{% trans "Q-in-Q (802.1ad)" %}

+
+ {% render_field form.qinq_role %} + {% render_field form.qinq_svlan %} +
+

{% trans "Tenancy" %}

diff --git a/netbox/virtualization/api/serializers_/virtualmachines.py b/netbox/virtualization/api/serializers_/virtualmachines.py index 2c00cac96..9b7000def 100644 --- a/netbox/virtualization/api/serializers_/virtualmachines.py +++ b/netbox/virtualization/api/serializers_/virtualmachines.py @@ -89,6 +89,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer): required=False, many=True ) + qinq_svlan = VLANSerializer(nested=True, required=False, allow_null=True) vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True) l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) @@ -104,9 +105,9 @@ class VMInterfaceSerializer(NetBoxModelSerializer): model = VMInterface fields = [ 'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', - 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination', - 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', - 'vlan_translation_policy', + 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', + 'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', + 'count_ipaddresses', 'count_fhrp_groups', ] brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description') diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 5971fc894..4527e7f4c 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.forms.common import InterfaceCommonForm from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.models import ConfigTemplate +from ipam.choices import VLANQinQRoleChoices from ipam.models import IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm @@ -338,6 +339,16 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): 'available_on_virtualmachine': '$virtual_machine', } ) + qinq_svlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + label=_('Q-in-Q Service VLAN'), + query_params={ + 'group_id': '$vlan_group', + 'available_on_virtualmachine': '$virtual_machine', + 'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE, + } + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -354,17 +365,20 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): FieldSet('vrf', 'mac_address', name=_('Addressing')), FieldSet('mtu', 'enabled', name=_('Operation')), FieldSet('parent', 'bridge', name=_('Related Interfaces')), - FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy', name=_('802.1Q Switching')), + FieldSet( + 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', + name=_('802.1Q Switching') + ), ) class Meta: model = VMInterface fields = [ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'vlan_translation_policy', + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags', ] labels = { - 'mode': '802.1Q Mode', + 'mode': _('802.1Q Mode'), } widgets = { 'mode': HTMXSelect(), diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index bed65a3b3..79b5cb216 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -100,6 +100,7 @@ class VMInterfaceType(IPAddressesMixin, ComponentType): bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None + qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] diff --git a/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py b/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py index e0992c9c8..3a6d5e481 100644 --- a/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py +++ b/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py @@ -1,5 +1,3 @@ -# Generated by Django 5.0.9 on 2024-10-11 19:45 - import django.db.models.deletion from django.db import migrations, models diff --git a/netbox/virtualization/migrations/0043_qinq_svlan.py b/netbox/virtualization/migrations/0043_qinq_svlan.py new file mode 100644 index 000000000..422289fb7 --- /dev/null +++ b/netbox/virtualization/migrations/0043_qinq_svlan.py @@ -0,0 +1,28 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0075_vlan_qinq'), + ('virtualization', '0042_vminterface_vlan_translation_policy'), + ] + + operations = [ + migrations.AddField( + model_name='vminterface', + name='qinq_svlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_svlan', to='ipam.vlan'), + ), + migrations.AlterField( + model_name='vminterface', + name='tagged_vlans', + field=models.ManyToManyField(blank=True, related_name='%(class)ss_as_tagged', to='ipam.vlan'), + ), + migrations.AlterField( + model_name='vminterface', + name='untagged_vlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_as_untagged', to='ipam.vlan'), + ), + ] diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 0767b2c13..da8419e88 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -322,20 +322,6 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): max_length=100, blank=True ) - untagged_vlan = models.ForeignKey( - to='ipam.VLAN', - on_delete=models.SET_NULL, - related_name='vminterfaces_as_untagged', - null=True, - blank=True, - verbose_name=_('untagged VLAN') - ) - tagged_vlans = models.ManyToManyField( - to='ipam.VLAN', - related_name='vminterfaces_as_tagged', - blank=True, - verbose_name=_('tagged VLANs') - ) ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 8a2a26bb9..4a3138711 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -151,8 +151,8 @@ class VMInterfaceTable(BaseInterfaceTable): model = VMInterface fields = ( 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', - 'last_updated', + 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', + 'created', 'last_updated', ) default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') @@ -175,7 +175,8 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): model = VMInterface fields = ( 'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', + 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', + 'actions', ) default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses') row_attrs = { diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 69728f67c..521064fc6 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -4,6 +4,7 @@ from rest_framework import status from dcim.choices import InterfaceModeChoices from dcim.models import Site from extras.models import ConfigTemplate +from ipam.choices import VLANQinQRoleChoices from ipam.models import VLAN, VRF from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine from virtualization.choices import * @@ -270,6 +271,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): VLAN(name='VLAN 1', vid=1), VLAN(name='VLAN 2', vid=2), VLAN(name='VLAN 3', vid=3), + VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), ) VLAN.objects.bulk_create(vlans) @@ -307,6 +309,12 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'untagged_vlan': vlans[2].pk, 'vrf': vrfs[2].pk, }, + { + 'virtual_machine': virtualmachine.pk, + 'name': 'Interface 7', + 'mode': InterfaceModeChoices.MODE_Q_IN_Q, + 'qinq_svlan': vlans[3].pk, + }, ] def test_bulk_delete_child_interfaces(self): diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index cd598274f..0c7079bba 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -1,7 +1,9 @@ from django.test import TestCase +from dcim.choices import InterfaceModeChoices from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup -from ipam.models import IPAddress, VLANTranslationPolicy, VRF +from ipam.choices import VLANQinQRoleChoices +from ipam.models import IPAddress, VLAN, VLANTranslationPolicy, VRF from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.choices import * @@ -528,7 +530,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VMInterface.objects.all() filterset = VMInterfaceFilterSet - ignore_fields = ('tagged_vlans', 'untagged_vlan',) + ignore_fields = ('tagged_vlans', 'untagged_vlan', 'qinq_svlan') @classmethod def setUpTestData(cls): @@ -554,6 +556,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) VRF.objects.bulk_create(vrfs) + vlans = ( + VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), + VLAN(name='SVLAN 2', vid=1002, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), + VLAN(name='SVLAN 3', vid=1003, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), + ) + VLAN.objects.bulk_create(vlans) + vms = ( VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]), VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]), @@ -596,7 +605,9 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): mtu=300, mac_address='00-00-00-00-00-03', vrf=vrfs[2], - description='foobar3' + description='foobar3', + mode=InterfaceModeChoices.MODE_Q_IN_Q, + qinq_svlan=vlans[0] ), ) VMInterface.objects.bulk_create(interfaces) @@ -667,6 +678,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_vlan(self): + vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first() + params = {'vlan_id': vlan.pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'vlan': vlan.vid} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_vlan_translation_policy(self): vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2] params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]} From 6dc75d8db1e7750f37ba5f50d65e05c430352146 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 1 Nov 2024 11:18:23 -0700 Subject: [PATCH 22/65] 7699 Add Scope to Cluster (#17848) * 7699 Add Scope to Cluster * 7699 Serializer * 7699 filterset * 7699 bulk_edit * 7699 bulk_import * 7699 model_form * 7699 graphql, tables * 7699 fixes * 7699 fixes * 7699 fixes * 7699 fixes * 7699 fix tests * 7699 fix graphql tests for clusters reference * 7699 fix dcim tests * 7699 fix ipam tests * 7699 fix tests * 7699 use mixin for model * 7699 change mixin name * 7699 scope form * 7699 scope form * 7699 scoped form, fitlerset * 7699 review changes * 7699 move ScopedFilterset * 7699 move CachedScopeMixin * 7699 review changes * 7699 review changes * 7699 refactor mixins * 7699 _sitegroup -> _site_group * 7699 update docstring * Misc cleanup * Update migrations --------- Co-authored-by: Jeremy Stretch --- docs/models/virtualization/cluster.md | 4 +- netbox/dcim/constants.py | 5 + netbox/dcim/filtersets.py | 58 ++++++++++ netbox/dcim/forms/mixins.py | 105 ++++++++++++++++++ netbox/dcim/graphql/types.py | 17 +++ netbox/dcim/models/devices.py | 11 +- netbox/dcim/models/mixins.py | 85 ++++++++++++++ netbox/dcim/tests/test_models.py | 9 +- netbox/extras/tests/test_models.py | 4 +- netbox/ipam/querysets.py | 2 +- netbox/ipam/tests/test_filtersets.py | 9 +- netbox/templates/virtualization/cluster.html | 8 +- .../api/serializers_/clusters.py | 31 +++++- netbox/virtualization/apps.py | 2 +- netbox/virtualization/filtersets.py | 42 +------ netbox/virtualization/forms/bulk_edit.py | 28 +---- netbox/virtualization/forms/bulk_import.py | 8 +- netbox/virtualization/forms/filtersets.py | 19 ++-- netbox/virtualization/forms/model_forms.py | 14 +-- netbox/virtualization/graphql/types.py | 15 ++- .../migrations/0044_cluster_scope.py | 51 +++++++++ .../0045_clusters_cached_relations.py | 94 ++++++++++++++++ netbox/virtualization/models/clusters.py | 48 +++++--- .../virtualization/models/virtualmachines.py | 4 +- netbox/virtualization/tables/clusters.py | 11 +- netbox/virtualization/tests/test_api.py | 10 +- .../virtualization/tests/test_filtersets.py | 18 +-- netbox/virtualization/tests/test_models.py | 9 +- netbox/virtualization/tests/test_views.py | 23 ++-- 29 files changed, 588 insertions(+), 156 deletions(-) create mode 100644 netbox/dcim/forms/mixins.py create mode 100644 netbox/virtualization/migrations/0044_cluster_scope.py create mode 100644 netbox/virtualization/migrations/0045_clusters_cached_relations.py diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md index 50b5dbd1d..9acdb2bc4 100644 --- a/docs/models/virtualization/cluster.md +++ b/docs/models/virtualization/cluster.md @@ -23,6 +23,6 @@ The cluster's operational status. !!! tip Additional statuses may be defined by setting `Cluster.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. -### Site +### Scope -The [site](../dcim/site.md) with which the cluster is associated. +The [region](../dcim/region.md), [site](../dcim/site.md), [site group](../dcim/sitegroup.md) or [location](../dcim/location.md) with which this cluster is associated. diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index ba3e6464b..4927b0198 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -123,3 +123,8 @@ COMPATIBLE_TERMINATION_TYPES = { 'powerport': ['poweroutlet', 'powerfeed'], 'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], } + +# Models which can serve to scope an object by location +LOCATION_SCOPE_TYPES = ( + 'region', 'sitegroup', 'site', 'location', +) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0371f882b..df66ad77b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -73,6 +73,7 @@ __all__ = ( 'RearPortFilterSet', 'RearPortTemplateFilterSet', 'RegionFilterSet', + 'ScopedFilterSet', 'SiteFilterSet', 'SiteGroupFilterSet', 'VirtualChassisFilterSet', @@ -2344,3 +2345,60 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet): class Meta: model = Interface 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)'), + ) diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py new file mode 100644 index 000000000..98862af10 --- /dev/null +++ b/netbox/dcim/forms/mixins.py @@ -0,0 +1,105 @@ +from django import forms +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.utils.translation import gettext_lazy as _ + +from dcim.constants import LOCATION_SCOPE_TYPES +from dcim.models import Site +from utilities.forms import get_field_value +from utilities.forms.fields import ( + ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField, +) +from utilities.templatetags.builtins.filters import bettertitle +from utilities.forms.widgets import HTMXSelect + +__all__ = ( + 'ScopedBulkEditForm', + 'ScopedForm', + 'ScopedImportForm', +) + + +class ScopedForm(forms.Form): + scope_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=LOCATION_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 + ) + + 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) + self._set_scoped_values() + + def clean(self): + super().clean() + + # Assign the selected scope (if any) + self.instance.scope = self.cleaned_data.get('scope') + + def _set_scoped_values(self): + 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 + + +class ScopedBulkEditForm(forms.Form): + scope_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=LOCATION_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 + ) + + 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 ScopedImportForm(forms.Form): + scope_type = CSVContentTypeField( + queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES), + required=False, + label=_('Scope type (app & model)') + ) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 5965fdcec..6493ec6b1 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -463,6 +463,10 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] children: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] + @strawberry_django.field + def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: + return self._clusters.all() + @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: return self.circuit_terminations.all() @@ -710,6 +714,10 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None: return self.parent + @strawberry_django.field + def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: + return self._clusters.all() + @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: return self.circuit_terminations.all() @@ -735,9 +743,14 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]] + circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]] clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]] vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] + @strawberry_django.field + def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: + return self._clusters.all() + @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: return self.circuit_terminations.all() @@ -758,6 +771,10 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None: return self.parent + @strawberry_django.field + def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: + return self._clusters.all() + @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: return self.circuit_terminations.all() diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index b9ba2bb64..47f4ee6c9 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -958,10 +958,17 @@ class Device( }) # A Device can only be assigned to a Cluster in the same Site (or no Site) - if self.cluster and self.cluster.site is not None and self.cluster.site != self.site: + if self.cluster and self.cluster._site is not None and self.cluster._site != self.site: raise ValidationError({ 'cluster': _("The assigned cluster belongs to a different site ({site})").format( - site=self.cluster.site + site=self.cluster._site + ) + }) + + if self.cluster and self.cluster._location is not None and self.cluster._location != self.location: + raise ValidationError({ + 'cluster': _("The assigned cluster belongs to a different location ({location})").format( + site=self.cluster._location ) }) diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index c9be451a0..1df3364c4 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -1,6 +1,10 @@ +from django.apps import apps +from django.contrib.contenttypes.fields import GenericForeignKey from django.db import models +from dcim.constants import LOCATION_SCOPE_TYPES __all__ = ( + 'CachedScopeMixin', 'RenderConfigMixin', ) @@ -27,3 +31,84 @@ class RenderConfigMixin(models.Model): return self.role.config_template if self.platform and self.platform.config_template: return self.platform.config_template + + +class CachedScopeMixin(models.Model): + """ + Mixin for adding a GenericForeignKey scope to a model that can point to a Region, SiteGroup, Site, or Location. + Includes cached fields for each to allow efficient filtering. Appropriate validation must be done in the clean() + method as this does not have any as validation is generally model-specific. + """ + scope_type = models.ForeignKey( + to='contenttypes.ContentType', + on_delete=models.PROTECT, + limit_choices_to=models.Q(model__in=LOCATION_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' + ) + + _location = models.ForeignKey( + to='dcim.Location', + on_delete=models.CASCADE, + related_name='_%(class)ss', + blank=True, + null=True + ) + _site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + related_name='_%(class)ss', + blank=True, + null=True + ) + _region = models.ForeignKey( + to='dcim.Region', + on_delete=models.CASCADE, + related_name='_%(class)ss', + blank=True, + null=True + ) + _site_group = models.ForeignKey( + to='dcim.SiteGroup', + on_delete=models.CASCADE, + related_name='_%(class)ss', + blank=True, + null=True + ) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + # Cache objects associated with the terminating object (for filtering) + self.cache_related_objects() + + super().save(*args, **kwargs) + + def cache_related_objects(self): + self._region = self._site_group = 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._site_group = self.scope + elif scope_type == apps.get_model('dcim', 'site'): + self._region = self.scope.region + self._site_group = self.scope.group + self._site = self.scope + elif scope_type == apps.get_model('dcim', 'location'): + self._region = self.scope.site.region + self._site_group = self.scope.site.group + self._site = self.scope.site + self._location = self.scope + cache_related_objects.alters_data = True diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index f0fe4da3b..8d43d67ea 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -601,11 +601,12 @@ class DeviceTestCase(TestCase): Site.objects.bulk_create(sites) clusters = ( - Cluster(name='Cluster 1', type=cluster_type, site=sites[0]), - Cluster(name='Cluster 2', type=cluster_type, site=sites[1]), - Cluster(name='Cluster 3', type=cluster_type, site=None), + Cluster(name='Cluster 1', type=cluster_type, scope=sites[0]), + Cluster(name='Cluster 2', type=cluster_type, scope=sites[1]), + Cluster(name='Cluster 3', type=cluster_type, scope=None), ) - Cluster.objects.bulk_create(clusters) + for cluster in clusters: + cluster.save() device_type = DeviceType.objects.first() device_role = DeviceRole.objects.first() diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 188a06a3f..c90390dd1 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -274,7 +274,7 @@ class ConfigContextTest(TestCase): name="Cluster", group=cluster_group, type=cluster_type, - site=site, + scope=site, ) region_context = ConfigContext.objects.create( @@ -366,7 +366,7 @@ class ConfigContextTest(TestCase): """ site = Site.objects.first() cluster_type = ClusterType.objects.create(name="Cluster Type") - cluster = Cluster.objects.create(name="Cluster", type=cluster_type, site=site) + cluster = Cluster.objects.create(name="Cluster", type=cluster_type, scope=site) vm_role = DeviceRole.objects.first() # Create a ConfigContext associated with the site diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 771e9b3b9..77ab8194a 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -148,7 +148,7 @@ class VLANQuerySet(RestrictedQuerySet): # Find all relevant VLANGroups q = Q() - site = vm.site or vm.cluster.site + site = vm.site or vm.cluster._site if vm.cluster: # Add VLANGroups scoped to the assigned cluster (or its group) q |= Q( diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index f651c970d..28e8cda1e 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1675,11 +1675,12 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clusters = ( - Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0], site=sites[0]), - Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], site=sites[1]), - Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2], site=sites[2]), + Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0], scope=sites[0]), + Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], scope=sites[1]), + Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2], scope=sites[2]), ) - Cluster.objects.bulk_create(clusters) + for cluster in clusters: + cluster.save() virtual_machines = ( VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]), diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index d79d8075c..4155dacb2 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -39,8 +39,12 @@ - {% trans "Site" %} - {{ object.site|linkify|placeholder }} + {% trans "Scope" %} + {% if object.scope %} + {{ object.scope|linkify }} ({% trans object.scope_type.name %}) + {% else %} + {{ ''|placeholder }} + {% endif %}
diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py index b64b6e7ba..101a5b5a3 100644 --- a/netbox/virtualization/api/serializers_/clusters.py +++ b/netbox/virtualization/api/serializers_/clusters.py @@ -1,9 +1,13 @@ -from dcim.api.serializers_.sites import SiteSerializer -from netbox.api.fields import ChoiceField, RelatedObjectCountField +from dcim.constants import LOCATION_SCOPE_TYPES +from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers +from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField from netbox.api.serializers import NetBoxModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType +from utilities.api import get_serializer_for_model __all__ = ( 'ClusterGroupSerializer', @@ -45,7 +49,16 @@ class ClusterSerializer(NetBoxModelSerializer): group = ClusterGroupSerializer(nested=True, required=False, allow_null=True, default=None) status = ChoiceField(choices=ClusterStatusChoices, required=False) tenant = TenantSerializer(nested=True, required=False, allow_null=True) - site = SiteSerializer(nested=True, required=False, allow_null=True, default=None) + 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) # Related object counts device_count = RelatedObjectCountField('devices') @@ -54,8 +67,18 @@ class ClusterSerializer(NetBoxModelSerializer): class Meta: model = Cluster fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', + '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', 'virtualmachine_count', ] brief_fields = ('id', 'url', 'display', 'name', 'description', 'virtualmachine_count') + + @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 + + diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py index ebcc591bf..65ce0f112 100644 --- a/netbox/virtualization/apps.py +++ b/netbox/virtualization/apps.py @@ -17,7 +17,7 @@ class VirtualizationConfig(AppConfig): # Register denormalized fields denormalized.register(VirtualMachine, 'cluster', { - 'site': 'site', + 'site': '_site', }) # Register counters diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index ec0831f9f..ac72bea12 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from django.utils.translation import gettext as _ -from dcim.filtersets import CommonInterfaceFilterSet +from dcim.filtersets import CommonInterfaceFilterSet, ScopedFilterSet from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate @@ -37,43 +37,7 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet) fields = ('id', 'name', 'slug', 'description') -class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): - region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='site__region', - lookup_expr='in', - label=_('Region (ID)'), - ) - region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='site__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(), - label=_('Site (ID)'), - ) - site = django_filters.ModelMultipleChoiceFilter( - field_name='site__slug', - queryset=Site.objects.all(), - to_field_name='slug', - label=_('Site (slug)'), - ) +class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ScopedFilterSet, ContactModelFilterSet): group_id = django_filters.ModelMultipleChoiceFilter( queryset=ClusterGroup.objects.all(), label=_('Parent group (ID)'), @@ -101,7 +65,7 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte class Meta: model = Cluster - fields = ('id', 'name', 'description') + fields = ('id', 'name', 'description', 'scope_id') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 2bd3434ac..aaeb259b9 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -3,7 +3,8 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN -from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup +from dcim.forms.mixins import ScopedBulkEditForm +from dcim.models import Device, DeviceRole, Platform, Site from extras.models import ConfigTemplate from ipam.models import VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm @@ -55,7 +56,7 @@ class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('description',) -class ClusterBulkEditForm(NetBoxModelBulkEditForm): +class ClusterBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): type = DynamicModelChoiceField( label=_('Type'), queryset=ClusterType.objects.all(), @@ -77,25 +78,6 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - region = DynamicModelChoiceField( - label=_('Region'), - queryset=Region.objects.all(), - required=False, - ) - site_group = DynamicModelChoiceField( - label=_('Site group'), - queryset=SiteGroup.objects.all(), - required=False, - ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) description = forms.CharField( label=_('Description'), max_length=200, @@ -106,10 +88,10 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): model = Cluster fieldsets = ( FieldSet('type', 'group', 'status', 'tenant', 'description'), - FieldSet('region', 'site_group', 'site', name=_('Site')), + FieldSet('scope_type', 'scope', name=_('Scope')), ) nullable_fields = ( - 'group', 'site', 'tenant', 'description', 'comments', + 'group', 'scope', 'tenant', 'description', 'comments', ) diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 17efc567a..9ccdd68f7 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,6 +1,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import InterfaceModeChoices +from dcim.forms.mixins import ScopedImportForm from dcim.models import Device, DeviceRole, Platform, Site from extras.models import ConfigTemplate from ipam.models import VRF @@ -36,7 +37,7 @@ class ClusterGroupImportForm(NetBoxModelImportForm): fields = ('name', 'slug', 'description', 'tags') -class ClusterImportForm(NetBoxModelImportForm): +class ClusterImportForm(ScopedImportForm, NetBoxModelImportForm): type = CSVModelChoiceField( label=_('Type'), queryset=ClusterType.objects.all(), @@ -72,7 +73,10 @@ class ClusterImportForm(NetBoxModelImportForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'status', 'site', 'tenant', 'description', 'comments', 'tags') + fields = ('name', 'type', 'group', 'status', 'scope_type', 'scope_id', 'tenant', 'description', 'comments', 'tags') + labels = { + 'scope_id': _('Scope ID'), + } class VirtualMachineImportForm(NetBoxModelImportForm): diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 7c040d948..695641e4e 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -1,7 +1,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Location, Platform, Region, Site, SiteGroup from extras.forms import LocalConfigContextFilterForm from extras.models import ConfigTemplate from ipam.models import VRF @@ -43,7 +43,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet('group_id', 'type_id', 'status', name=_('Attributes')), - FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) @@ -58,11 +58,6 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi required=False, label=_('Region') ) - status = forms.MultipleChoiceField( - label=_('Status'), - choices=ClusterStatusChoices, - required=False - ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, @@ -78,6 +73,16 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi }, label=_('Site') ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Location') + ) + status = forms.MultipleChoiceField( + label=_('Status'), + choices=ClusterStatusChoices, + required=False + ) group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), required=False, diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 4527e7f4c..44c67d389 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from dcim.forms.common import InterfaceCommonForm +from dcim.forms.mixins import ScopedForm from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.models import ConfigTemplate from ipam.choices import VLANQinQRoleChoices @@ -58,7 +59,7 @@ class ClusterGroupForm(NetBoxModelForm): ) -class ClusterForm(TenancyForm, NetBoxModelForm): +class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm): type = DynamicModelChoiceField( label=_('Type'), queryset=ClusterType.objects.all() @@ -68,23 +69,18 @@ class ClusterForm(TenancyForm, NetBoxModelForm): queryset=ClusterGroup.objects.all(), required=False ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False, - selector=True - ) comments = CommentField() fieldsets = ( - FieldSet('name', 'type', 'group', 'site', 'status', 'description', 'tags', name=_('Cluster')), + FieldSet('name', 'type', 'group', 'status', 'description', 'tags', name=_('Cluster')), + FieldSet('scope_type', 'scope', name=_('Scope')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'status', 'tenant', 'site', 'description', 'comments', 'tags', + 'name', 'type', 'group', 'status', 'tenant', 'scope_type', 'description', 'comments', 'tags', ) diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 79b5cb216..f51e0e3f5 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, Union import strawberry import strawberry_django @@ -31,18 +31,25 @@ class ComponentType(NetBoxObjectType): @strawberry_django.type( models.Cluster, - fields='__all__', + exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'), filters=ClusterFilter ) class ClusterType(VLANGroupsMixin, NetBoxObjectType): type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')] | None tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None - site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None - virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] devices: List[Annotated["DeviceType", 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("ClusterScopeType")] | None: + return self.scope + @strawberry_django.type( models.ClusterGroup, diff --git a/netbox/virtualization/migrations/0044_cluster_scope.py b/netbox/virtualization/migrations/0044_cluster_scope.py new file mode 100644 index 000000000..63a888ac3 --- /dev/null +++ b/netbox/virtualization/migrations/0044_cluster_scope.py @@ -0,0 +1,51 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_site_assignments(apps, schema_editor): + """ + Copy site ForeignKey values to the scope GFK. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + Cluster = apps.get_model('virtualization', 'Cluster') + Site = apps.get_model('dcim', 'Site') + + Cluster.objects.filter(site__isnull=False).update( + scope_type=ContentType.objects.get_for_model(Site), + scope_id=models.F('site_id') + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('virtualization', '0043_qinq_svlan'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='scope_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='cluster', + 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', + ), + ), + + # Copy over existing site assignments + migrations.RunPython( + code=copy_site_assignments, + reverse_code=migrations.RunPython.noop + ), + + ] diff --git a/netbox/virtualization/migrations/0045_clusters_cached_relations.py b/netbox/virtualization/migrations/0045_clusters_cached_relations.py new file mode 100644 index 000000000..ff851aa7c --- /dev/null +++ b/netbox/virtualization/migrations/0045_clusters_cached_relations.py @@ -0,0 +1,94 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def populate_denormalized_fields(apps, schema_editor): + """ + Copy the denormalized fields for _region, _site_group and _site from existing site field. + """ + Cluster = apps.get_model('virtualization', 'Cluster') + + clusters = Cluster.objects.filter(site__isnull=False).prefetch_related('site') + for cluster in clusters: + cluster._region_id = cluster.site.region_id + cluster._site_group_id = cluster.site.group_id + cluster._site_id = cluster.site_id + # Note: Location cannot be set prior to migration + + Cluster.objects.bulk_update(clusters, ['_region', '_site_group', '_site']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0044_cluster_scope'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + 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='cluster', + 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='cluster', + 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='cluster', + name='_site_group', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='_%(class)ss', + to='dcim.sitegroup', + ), + ), + + # Populate denormalized FK values + migrations.RunPython( + code=populate_denormalized_fields, + reverse_code=migrations.RunPython.noop + ), + + migrations.RemoveConstraint( + model_name='cluster', + name='virtualization_cluster_unique_site_name', + ), + # Delete the site ForeignKey + migrations.RemoveField( + model_name='cluster', + name='site', + ), + migrations.AddConstraint( + model_name='cluster', + constraint=models.UniqueConstraint( + fields=('_site', 'name'), name='virtualization_cluster_unique__site_name' + ), + ), + ] diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index b8921c603..601ee7f23 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -1,9 +1,11 @@ +from django.apps import apps from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ from dcim.models import Device +from dcim.models.mixins import CachedScopeMixin from netbox.models import OrganizationalModel, PrimaryModel from netbox.models.features import ContactsMixin from virtualization.choices import * @@ -42,7 +44,7 @@ class ClusterGroup(ContactsMixin, OrganizationalModel): verbose_name_plural = _('cluster groups') -class Cluster(ContactsMixin, PrimaryModel): +class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ @@ -76,13 +78,6 @@ class Cluster(ContactsMixin, PrimaryModel): blank=True, null=True ) - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.PROTECT, - related_name='clusters', - blank=True, - null=True - ) # Generic relations vlan_groups = GenericRelation( @@ -93,7 +88,7 @@ class Cluster(ContactsMixin, PrimaryModel): ) clone_fields = ( - 'type', 'group', 'status', 'tenant', 'site', + 'scope_type', 'scope_id', 'type', 'group', 'status', 'tenant', ) prerequisite_models = ( 'virtualization.ClusterType', @@ -107,8 +102,8 @@ class Cluster(ContactsMixin, PrimaryModel): name='%(app_label)s_%(class)s_unique_group_name' ), models.UniqueConstraint( - fields=('site', 'name'), - name='%(app_label)s_%(class)s_unique_site_name' + fields=('_site', 'name'), + name='%(app_label)s_%(class)s_unique__site_name' ), ) verbose_name = _('cluster') @@ -123,11 +118,28 @@ class Cluster(ContactsMixin, PrimaryModel): def clean(self): super().clean() + site = location = None + if self.scope_type: + scope_type = self.scope_type.model_class() + if scope_type == apps.get_model('dcim', 'site'): + site = self.scope + elif scope_type == apps.get_model('dcim', 'location'): + location = self.scope + site = location.site + # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. - if not self._state.adding and self.site: - if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=self.site).count(): - raise ValidationError({ - 'site': _( - "{count} devices are assigned as hosts for this cluster but are not in site {site}" - ).format(count=nonsite_devices, site=self.site) - }) + if not self._state.adding: + if site: + if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=site).count(): + raise ValidationError({ + 'scope': _( + "{count} devices are assigned as hosts for this cluster but are not in site {site}" + ).format(count=nonsite_devices, site=site) + }) + if location: + if nonlocation_devices := Device.objects.filter(cluster=self).exclude(location=location).count(): + raise ValidationError({ + 'scope': _( + "{count} devices are assigned as hosts for this cluster but are not in location {location}" + ).format(count=nonlocation_devices, location=location) + }) diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index da8419e88..4ee41e403 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -181,7 +181,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co }) # Validate site for cluster & VM - if self.cluster and self.site and self.cluster.site and self.cluster.site != self.site: + if self.cluster and self.site and self.cluster._site and self.cluster._site != self.site: raise ValidationError({ 'cluster': _( 'The selected cluster ({cluster}) is not assigned to this site ({site}).' @@ -238,7 +238,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co # Assign site from cluster if not set if self.cluster and not self.site: - self.site = self.cluster.site + self.site = self.cluster._site super().save(*args, **kwargs) diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index d3c799fb9..91807e35b 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -73,8 +73,11 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): status = columns.ChoiceFieldColumn( verbose_name=_('Status'), ) - site = tables.Column( - verbose_name=_('Site'), + scope_type = columns.ContentTypeColumn( + verbose_name=_('Scope Type'), + ) + scope = tables.Column( + verbose_name=_('Scope'), linkify=True ) device_count = columns.LinkedCountColumn( @@ -97,7 +100,7 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Cluster fields = ( - 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'site', 'description', 'comments', - 'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'scope', 'scope_type', 'description', + 'comments', 'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count') diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 521064fc6..149b64684 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -113,7 +113,8 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), ) - Cluster.objects.bulk_create(clusters) + for cluster in clusters: + cluster.save() cls.create_data = [ { @@ -157,11 +158,12 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): Site.objects.bulk_create(sites) clusters = ( - Cluster(name='Cluster 1', type=clustertype, site=sites[0], group=clustergroup), - Cluster(name='Cluster 2', type=clustertype, site=sites[1], group=clustergroup), + Cluster(name='Cluster 1', type=clustertype, scope=sites[0], group=clustergroup), + Cluster(name='Cluster 2', type=clustertype, scope=sites[1], group=clustergroup), Cluster(name='Cluster 3', type=clustertype), ) - Cluster.objects.bulk_create(clusters) + for cluster in clusters: + cluster.save() device1 = create_test_device('device1', site=sites[0], cluster=clusters[0]) device2 = create_test_device('device2', site=sites[1], cluster=clusters[1]) diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 0c7079bba..5a5bf2325 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -138,7 +138,7 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, - site=sites[0], + scope=sites[0], tenant=tenants[0], description='foobar1' ), @@ -147,7 +147,7 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, - site=sites[1], + scope=sites[1], tenant=tenants[1], description='foobar2' ), @@ -156,12 +156,13 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, - site=sites[2], + scope=sites[2], tenant=tenants[2], description='foobar3' ), ) - Cluster.objects.bulk_create(clusters) + for cluster in clusters: + cluster.save() def test_q(self): params = {'q': 'foobar1'} @@ -274,11 +275,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): Site.objects.bulk_create(sites) clusters = ( - Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0]), - Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1]), - Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2]), + Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], scope=sites[0]), + Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], scope=sites[1]), + Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], scope=sites[2]), ) - Cluster.objects.bulk_create(clusters) + for cluster in clusters: + cluster.save() platforms = ( Platform(name='Platform 1', slug='platform-1'), diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index a4e8d7947..7be423bf1 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -54,11 +54,12 @@ class VirtualMachineTestCase(TestCase): Site.objects.bulk_create(sites) clusters = ( - Cluster(name='Cluster 1', type=cluster_type, site=sites[0]), - Cluster(name='Cluster 2', type=cluster_type, site=sites[1]), - Cluster(name='Cluster 3', type=cluster_type, site=None), + Cluster(name='Cluster 1', type=cluster_type, scope=sites[0]), + Cluster(name='Cluster 2', type=cluster_type, scope=sites[1]), + Cluster(name='Cluster 3', type=cluster_type, scope=None), ) - Cluster.objects.bulk_create(clusters) + for cluster in clusters: + cluster.save() # VM with site only should pass VirtualMachine(name='vm1', site=sites[0]).full_clean() diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 3c6a058c9..b9cb7b437 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse from netaddr import EUI @@ -117,11 +118,12 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): ClusterType.objects.bulk_create(clustertypes) clusters = ( - Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), - Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), - Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), + Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, scope=sites[0]), + Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, scope=sites[0]), + Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, scope=sites[0]), ) - Cluster.objects.bulk_create(clusters) + for cluster in clusters: + cluster.save() tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -131,7 +133,8 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'type': clustertypes[1].pk, 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, - 'site': sites[1].pk, + 'scope_type': ContentType.objects.get_for_model(Site).pk, + 'scope': sites[1].pk, 'comments': 'Some comments', 'tags': [t.pk for t in tags], } @@ -155,7 +158,6 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'type': clustertypes[1].pk, 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, - 'site': sites[1].pk, 'comments': 'New comments', } @@ -201,10 +203,11 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clusters = ( - Cluster(name='Cluster 1', type=clustertype, site=sites[0]), - Cluster(name='Cluster 2', type=clustertype, site=sites[1]), + Cluster(name='Cluster 1', type=clustertype, scope=sites[0]), + Cluster(name='Cluster 2', type=clustertype, scope=sites[1]), ) - Cluster.objects.bulk_create(clusters) + for cluster in clusters: + cluster.save() devices = ( create_test_device('device1', site=sites[0], cluster=clusters[0]), @@ -292,7 +295,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): site = Site.objects.create(name='Site 1', slug='site-1') role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') - cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site) + cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, scope=site) virtualmachines = ( VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=role), VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=role), From 4bba92617d88f1eb63e9fc8894d54a928a34d9f2 Mon Sep 17 00:00:00 2001 From: Alexander Haase Date: Fri, 1 Nov 2024 19:56:08 +0100 Subject: [PATCH 23/65] Closes #16971: Add system jobs (#17716) * Fix check for existing jobs If a job is to be enqueued once and no specific scheduled time is specified, any scheduled time of existing jobs will be valid. Only if a specific scheduled time is specified for 'enqueue_once()' can it be evaluated. * Allow system jobs to be registered A new registry key allows background system jobs to be registered and automatically scheduled when rqworker starts. * Test scheduling of system jobs * Fix plugins scheduled job documentation The documentation reflected a non-production state of the JobRunner framework left over from development. Now a more practical example demonstrates the usage. * Allow plugins to register system jobs * Rename system job metadata To clarify which meta-attributes belong to system jobs, each of them is now prefixed with 'system_'. * Add predefined job interval choices * Remove 'system_enabled' JobRunner attribute Previously, the 'system_enabled' attribute was used to control whether a job should run or not. However, this can also be accomplished by evaluating the job's interval. * Fix test * Use a decorator to register system jobs * Specify interval when registering system job * Update documentation --------- Co-authored-by: Jeremy Stretch --- docs/plugins/development/background-jobs.md | 53 +++++++++++++++----- docs/plugins/development/data-backends.md | 2 +- netbox/core/choices.py | 14 ++++++ netbox/core/management/commands/rqworker.py | 11 ++++ netbox/netbox/jobs.py | 21 +++++++- netbox/netbox/registry.py | 1 + netbox/netbox/tests/dummy_plugin/__init__.py | 5 ++ netbox/netbox/tests/dummy_plugin/jobs.py | 9 ++++ netbox/netbox/tests/test_jobs.py | 36 +++++++++++++ netbox/netbox/tests/test_plugins.py | 9 ++++ 10 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 netbox/netbox/tests/dummy_plugin/jobs.py diff --git a/docs/plugins/development/background-jobs.md b/docs/plugins/development/background-jobs.md index 873390a58..d51981b9e 100644 --- a/docs/plugins/development/background-jobs.md +++ b/docs/plugins/development/background-jobs.md @@ -29,6 +29,9 @@ class MyTestJob(JobRunner): You can schedule the background job from within your code (e.g. from a model's `save()` method or a view) by calling `MyTestJob.enqueue()`. This method passes through all arguments to `Job.enqueue()`. However, no `name` argument must be passed, as the background job name will be used instead. +!!! tip + A set of predefined intervals is available at `core.choices.JobIntervalChoices` for convenience. + ### Attributes `JobRunner` attributes are defined under a class named `Meta` within the job. These are optional, but encouraged. @@ -46,26 +49,52 @@ As described above, jobs can be scheduled for immediate execution or at any late #### Example +```python title="models.py" +from django.db import models +from core.choices import JobIntervalChoices +from netbox.models import NetBoxModel +from .jobs import MyTestJob + +class MyModel(NetBoxModel): + foo = models.CharField() + + def save(self, *args, **kwargs): + MyTestJob.enqueue_once(instance=self, interval=JobIntervalChoices.INTERVAL_HOURLY) + return super().save(*args, **kwargs) + + def sync(self): + MyTestJob.enqueue(instance=self) +``` + + +### System Jobs + +Some plugins may implement background jobs that are decoupled from the request/response cycle. Typical use cases would be housekeeping tasks or synchronization jobs. These can be registered as _system jobs_ using the `system_job()` decorator. The job interval must be passed as an integer (in minutes) when registering a system job. System jobs are scheduled automatically when the RQ worker (`manage.py rqworker`) is run. + +#### Example + ```python title="jobs.py" -from netbox.jobs import JobRunner - +from core.choices import JobIntervalChoices +from netbox.jobs import JobRunner, system_job +from .models import MyModel +# Specify a predefined choice or an integer indicating +# the number of minutes between job executions +@system_job(interval=JobIntervalChoices.INTERVAL_HOURLY) class MyHousekeepingJob(JobRunner): class Meta: - name = "Housekeeping" + name = "My Housekeeping Job" def run(self, *args, **kwargs): - # your logic goes here + MyModel.objects.filter(foo='bar').delete() + +system_jobs = ( + MyHousekeepingJob, +) ``` -```python title="__init__.py" -from netbox.plugins import PluginConfig - -class MyPluginConfig(PluginConfig): - def ready(self): - from .jobs import MyHousekeepingJob - MyHousekeepingJob.setup(interval=60) -``` +!!! note + Ensure that any system jobs are imported on initialization. Otherwise, they won't be registered. This can be achieved by extending the PluginConfig's `ready()` method. ## Task queues diff --git a/docs/plugins/development/data-backends.md b/docs/plugins/development/data-backends.md index 8b7226a41..0c6d44d95 100644 --- a/docs/plugins/development/data-backends.md +++ b/docs/plugins/development/data-backends.md @@ -18,6 +18,6 @@ backends = [MyDataBackend] ``` !!! tip - The path to the list of search indexes can be modified by setting `data_backends` in the PluginConfig instance. + The path to the list of data backends can be modified by setting `data_backends` in the PluginConfig instance. ::: netbox.data_backends.DataBackend diff --git a/netbox/core/choices.py b/netbox/core/choices.py index 01a072ce1..442acc26b 100644 --- a/netbox/core/choices.py +++ b/netbox/core/choices.py @@ -72,6 +72,20 @@ class JobStatusChoices(ChoiceSet): ) +class JobIntervalChoices(ChoiceSet): + INTERVAL_MINUTELY = 1 + INTERVAL_HOURLY = 60 + INTERVAL_DAILY = 60 * 24 + INTERVAL_WEEKLY = 60 * 24 * 7 + + CHOICES = ( + (INTERVAL_MINUTELY, _('Minutely')), + (INTERVAL_HOURLY, _('Hourly')), + (INTERVAL_DAILY, _('Daily')), + (INTERVAL_WEEKLY, _('Weekly')), + ) + + # # ObjectChanges # diff --git a/netbox/core/management/commands/rqworker.py b/netbox/core/management/commands/rqworker.py index e1fb6fd11..b2879c3d8 100644 --- a/netbox/core/management/commands/rqworker.py +++ b/netbox/core/management/commands/rqworker.py @@ -2,6 +2,8 @@ import logging from django_rq.management.commands.rqworker import Command as _Command +from netbox.registry import registry + DEFAULT_QUEUES = ('high', 'default', 'low') @@ -14,6 +16,15 @@ class Command(_Command): of only the 'default' queue). """ def handle(self, *args, **options): + # Setup system jobs. + for job, kwargs in registry['system_jobs'].items(): + try: + interval = kwargs['interval'] + except KeyError: + raise TypeError("System job must specify an interval (in minutes).") + logger.debug(f"Scheduling system job {job.name} (interval={interval})") + job.enqueue_once(**kwargs) + # Run the worker with scheduler functionality options['with_scheduler'] = True diff --git a/netbox/netbox/jobs.py b/netbox/netbox/jobs.py index ae8f2f109..965ebc9e9 100644 --- a/netbox/netbox/jobs.py +++ b/netbox/netbox/jobs.py @@ -2,6 +2,7 @@ import logging from abc import ABC, abstractmethod from datetime import timedelta +from django.core.exceptions import ImproperlyConfigured from django.utils.functional import classproperty from django_pglocks import advisory_lock from rq.timeouts import JobTimeoutException @@ -9,12 +10,30 @@ from rq.timeouts import JobTimeoutException from core.choices import JobStatusChoices from core.models import Job, ObjectType from netbox.constants import ADVISORY_LOCK_KEYS +from netbox.registry import registry __all__ = ( 'JobRunner', + 'system_job', ) +def system_job(interval): + """ + Decorator for registering a `JobRunner` class as system background job. + """ + if type(interval) is not int: + raise ImproperlyConfigured("System job interval must be an integer (minutes).") + + def _wrapper(cls): + registry['system_jobs'][cls] = { + 'interval': interval + } + return cls + + return _wrapper + + class JobRunner(ABC): """ Background Job helper class. @@ -129,7 +148,7 @@ class JobRunner(ABC): if job: # If the job parameters haven't changed, don't schedule a new job and keep the current schedule. Otherwise, # delete the existing job and schedule a new job instead. - if (schedule_at and job.scheduled == schedule_at) and (job.interval == interval): + if (not schedule_at or job.scheduled == schedule_at) and (job.interval == interval): return job job.delete() diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index 0920cbccf..48d7921f2 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -30,6 +30,7 @@ registry = Registry({ 'models': collections.defaultdict(set), 'plugins': dict(), 'search': dict(), + 'system_jobs': dict(), 'tables': collections.defaultdict(dict), 'views': collections.defaultdict(dict), 'widgets': dict(), diff --git a/netbox/netbox/tests/dummy_plugin/__init__.py b/netbox/netbox/tests/dummy_plugin/__init__.py index 6ab62d638..2ca7c290c 100644 --- a/netbox/netbox/tests/dummy_plugin/__init__.py +++ b/netbox/netbox/tests/dummy_plugin/__init__.py @@ -21,5 +21,10 @@ class DummyPluginConfig(PluginConfig): 'netbox.tests.dummy_plugin.events.process_events_queue' ] + def ready(self): + super().ready() + + from . import jobs # noqa: F401 + config = DummyPluginConfig diff --git a/netbox/netbox/tests/dummy_plugin/jobs.py b/netbox/netbox/tests/dummy_plugin/jobs.py new file mode 100644 index 000000000..3b9dc7a5f --- /dev/null +++ b/netbox/netbox/tests/dummy_plugin/jobs.py @@ -0,0 +1,9 @@ +from core.choices import JobIntervalChoices +from netbox.jobs import JobRunner, system_job + + +@system_job(interval=JobIntervalChoices.INTERVAL_HOURLY) +class DummySystemJob(JobRunner): + + def run(self, *args, **kwargs): + pass diff --git a/netbox/netbox/tests/test_jobs.py b/netbox/netbox/tests/test_jobs.py index 52a7bd97a..e3e24a235 100644 --- a/netbox/netbox/tests/test_jobs.py +++ b/netbox/netbox/tests/test_jobs.py @@ -90,6 +90,15 @@ class EnqueueTest(JobRunnerTestCase): self.assertEqual(job1, job2) self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1) + def test_enqueue_once_twice_same_no_schedule_at(self): + instance = DataSource() + schedule_at = self.get_schedule_at() + job1 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at) + job2 = TestJobRunner.enqueue_once(instance) + + self.assertEqual(job1, job2) + self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1) + def test_enqueue_once_twice_different_schedule_at(self): instance = DataSource() job1 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at()) @@ -127,3 +136,30 @@ class EnqueueTest(JobRunnerTestCase): self.assertNotEqual(job1, job2) self.assertRaises(Job.DoesNotExist, job1.refresh_from_db) self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1) + + +class SystemJobTest(JobRunnerTestCase): + """ + Test that system jobs can be scheduled. + + General functionality already tested by `JobRunnerTest` and `EnqueueTest`. + """ + + def test_scheduling(self): + # Can job be enqueued? + job = TestJobRunner.enqueue(schedule_at=self.get_schedule_at()) + self.assertIsInstance(job, Job) + self.assertEqual(TestJobRunner.get_jobs().count(), 1) + + # Can job be deleted again? + job.delete() + self.assertRaises(Job.DoesNotExist, job.refresh_from_db) + self.assertEqual(TestJobRunner.get_jobs().count(), 0) + + def test_enqueue_once(self): + schedule_at = self.get_schedule_at() + job1 = TestJobRunner.enqueue_once(schedule_at=schedule_at) + job2 = TestJobRunner.enqueue_once(schedule_at=schedule_at) + + self.assertEqual(job1, job2) + self.assertEqual(TestJobRunner.get_jobs().count(), 1) diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index 16778667d..db82d0a75 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -5,8 +5,10 @@ from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.urls import reverse +from core.choices import JobIntervalChoices from netbox.tests.dummy_plugin import config as dummy_config from netbox.tests.dummy_plugin.data_backends import DummyBackend +from netbox.tests.dummy_plugin.jobs import DummySystemJob from netbox.plugins.navigation import PluginMenu from netbox.plugins.utils import get_plugin_config from netbox.graphql.schema import Query @@ -130,6 +132,13 @@ class PluginTest(TestCase): self.assertIn('dummy', registry['data_backends']) self.assertIs(registry['data_backends']['dummy'], DummyBackend) + def test_system_jobs(self): + """ + Check registered system jobs. + """ + self.assertIn(DummySystemJob, registry['system_jobs']) + self.assertEqual(registry['system_jobs'][DummySystemJob]['interval'], JobIntervalChoices.INTERVAL_HOURLY) + def test_queues(self): """ Check that plugin queues are registered with the accurate name. From 812ce8471a17656a82f6599e018e5b5319febfaf Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 7 Nov 2024 07:28:02 -0800 Subject: [PATCH 24/65] 10711 Add Scope to WirelessLAN (#17877) * 7699 Add Scope to Cluster * 7699 Serializer * 7699 filterset * 7699 bulk_edit * 7699 bulk_import * 7699 model_form * 7699 graphql, tables * 7699 fixes * 7699 fixes * 7699 fixes * 7699 fixes * 7699 fix tests * 7699 fix graphql tests for clusters reference * 7699 fix dcim tests * 7699 fix ipam tests * 7699 fix tests * 7699 use mixin for model * 7699 change mixin name * 7699 scope form * 7699 scope form * 7699 scoped form, fitlerset * 7699 review changes * 7699 move ScopedFilterset * 7699 move CachedScopeMixin * 7699 review changes * 10711 Add Scope to WirelessLAN * 10711 Add Scope to WirelessLAN * 10711 Add Scope to WirelessLAN * 10711 Add Scope to WirelessLAN * 10711 Add Scope to WirelessLAN * 7699 review changes * 7699 refactor mixins * 7699 _sitegroup -> _site_group * 7699 update docstring * fix model * remove old constants, update filtersets * 10711 fix GraphQL * 10711 fix API * 10711 add tests * 10711 review changes * 10711 add tests * 10711 add scope to detail template * 10711 add api test * Extend CSV test data --------- Co-authored-by: Jeremy Stretch --- docs/models/wireless/wirelesslan.md | 4 + netbox/templates/wireless/wirelesslan.html | 8 ++ .../wireless/api/serializers_/wirelesslans.py | 28 ++++++- netbox/wireless/filtersets.py | 5 +- netbox/wireless/forms/bulk_edit.py | 6 +- netbox/wireless/forms/bulk_import.py | 12 ++- netbox/wireless/forms/filtersets.py | 27 +++++++ netbox/wireless/forms/model_forms.py | 6 +- netbox/wireless/graphql/types.py | 13 +++- ...__location_wirelesslan__region_and_more.py | 77 ++++++++++++++++++ netbox/wireless/models.py | 5 +- netbox/wireless/tables/wirelesslan.py | 9 ++- netbox/wireless/tests/test_api.py | 10 ++- netbox/wireless/tests/test_filtersets.py | 78 +++++++++++++++++-- netbox/wireless/tests/test_views.py | 19 +++-- 15 files changed, 277 insertions(+), 30 deletions(-) create mode 100644 netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py diff --git a/docs/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md index 0f50fa75f..a448c42a2 100644 --- a/docs/models/wireless/wirelesslan.md +++ b/docs/models/wireless/wirelesslan.md @@ -43,3 +43,7 @@ The security cipher used to apply wireless authentication. Options include: ### 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. + +### 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. diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index 493c36132..54473ea54 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -22,6 +22,14 @@ {% trans "Status" %} {% badge object.get_status_display bg_color=object.get_status_color %} + + {% trans "Scope" %} + {% if object.scope %} + {{ object.scope|linkify }} ({% trans object.scope_type.name %}) + {% else %} + {{ ''|placeholder }} + {% endif %} + {% trans "Description" %} {{ object.description|placeholder }} diff --git a/netbox/wireless/api/serializers_/wirelesslans.py b/netbox/wireless/api/serializers_/wirelesslans.py index 6c5deeb26..637089277 100644 --- a/netbox/wireless/api/serializers_/wirelesslans.py +++ b/netbox/wireless/api/serializers_/wirelesslans.py @@ -1,9 +1,13 @@ 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 netbox.api.fields import ChoiceField +from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer +from utilities.api import get_serializer_for_model from wireless.choices import * from wireless.models import WirelessLAN, WirelessLANGroup from .nested import NestedWirelessLANGroupSerializer @@ -34,12 +38,30 @@ class WirelessLANSerializer(NetBoxModelSerializer): tenant = TenantSerializer(nested=True, required=False, allow_null=True) auth_type = ChoiceField(choices=WirelessAuthTypeChoices, 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: model = WirelessLAN fields = [ - 'id', 'url', 'display_url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'tenant', - 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', + 'id', 'url', 'display_url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'scope_type', 'scope_id', 'scope', + 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] 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 diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 537b2ec5c..5a4195e6c 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -2,6 +2,7 @@ import django_filters from django.db.models import Q from dcim.choices import LinkStatusChoices +from dcim.filtersets import ScopedFilterSet from dcim.models import Interface from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet @@ -43,7 +44,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'description') -class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): +class WirelessLANFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all(), field_name='group', @@ -74,7 +75,7 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = WirelessLAN - fields = ('id', 'ssid', 'auth_psk', 'description') + fields = ('id', 'ssid', 'auth_psk', 'scope_id', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index c8b378104..5cd3a157a 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -2,6 +2,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ from dcim.choices import LinkStatusChoices +from dcim.forms.mixins import ScopedBulkEditForm from ipam.models import VLAN from netbox.choices import * from netbox.forms import NetBoxModelBulkEditForm @@ -39,7 +40,7 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('parent', 'description') -class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): +class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): status = forms.ChoiceField( label=_('Status'), choices=add_blank_choice(WirelessLANStatusChoices), @@ -89,10 +90,11 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): model = WirelessLAN fieldsets = ( FieldSet('group', 'ssid', 'status', 'vlan', 'tenant', 'description'), + FieldSet('scope_type', 'scope', name=_('Scope')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) 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', ) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index cff3e49af..5bf2d7dcd 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -1,12 +1,13 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import LinkStatusChoices +from dcim.forms.mixins import ScopedImportForm from dcim.models import Interface from ipam.models import VLAN from netbox.choices import * from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField from wireless.choices import * from wireless.models import * @@ -32,7 +33,7 @@ class WirelessLANGroupImportForm(NetBoxModelImportForm): fields = ('name', 'slug', 'parent', 'description', 'tags') -class WirelessLANImportForm(NetBoxModelImportForm): +class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm): group = CSVModelChoiceField( label=_('Group'), queryset=WirelessLANGroup.objects.all(), @@ -75,9 +76,12 @@ class WirelessLANImportForm(NetBoxModelImportForm): class Meta: model = WirelessLAN fields = ( - 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', - 'comments', 'tags', + 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'scope_type', 'scope_id', + 'description', 'comments', 'tags', ) + labels = { + 'scope_id': _('Scope ID'), + } class WirelessLinkImportForm(NetBoxModelImportForm): diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 6439a2516..f62a3be06 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -2,6 +2,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ from dcim.choices import LinkStatusChoices +from dcim.models import Location, Region, Site, SiteGroup from netbox.choices import * from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm @@ -33,6 +34,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): fieldsets = ( FieldSet('q', 'filter_id', 'tag'), 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('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) @@ -65,6 +67,31 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): label=_('Pre-shared key'), 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) diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 7c2594271..877324b8c 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -2,6 +2,7 @@ from django.forms import PasswordInput from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface, Location, Site +from dcim.forms.mixins import ScopedForm from ipam.models import VLAN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm @@ -35,7 +36,7 @@ class WirelessLANGroupForm(NetBoxModelForm): ] -class WirelessLANForm(TenancyForm, NetBoxModelForm): +class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm): group = DynamicModelChoiceField( label=_('Group'), queryset=WirelessLANGroup.objects.all(), @@ -51,6 +52,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): fieldsets = ( FieldSet('ssid', 'group', 'vlan', 'status', 'description', 'tags', name=_('Wireless LAN')), + FieldSet('scope_type', 'scope', name=_('Scope')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) @@ -59,7 +61,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): model = WirelessLAN fields = [ 'ssid', 'group', 'status', 'vlan', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', - 'description', 'comments', 'tags', + 'scope_type', 'description', 'comments', 'tags', ] widgets = { 'auth_psk': PasswordInput( diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index b24525fbe..aa44e9b9f 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, Union import strawberry import strawberry_django @@ -28,7 +28,7 @@ class WirelessLANGroupType(OrganizationalObjectType): @strawberry_django.type( models.WirelessLAN, - fields='__all__', + exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'), filters=WirelessLANFilter ) class WirelessLANType(NetBoxObjectType): @@ -38,6 +38,15 @@ class WirelessLANType(NetBoxObjectType): 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( models.WirelessLink, diff --git a/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py b/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py new file mode 100644 index 000000000..ea4470641 --- /dev/null +++ b/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py @@ -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', + ), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 88c60d494..d78c893a6 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES +from dcim.models.mixins import CachedScopeMixin from netbox.models import NestedGroupModel, PrimaryModel from netbox.models.mixins import DistanceMixin from .choices import * @@ -71,7 +72,7 @@ class WirelessLANGroup(NestedGroupModel): 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. """ @@ -107,7 +108,7 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): null=True ) - clone_fields = ('ssid', 'group', 'tenant', 'description') + clone_fields = ('ssid', 'group', 'scope_type', 'scope_id', 'tenant', 'description') class Meta: ordering = ('ssid', 'pk') diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py index 87ad4ac51..40f52f8a5 100644 --- a/netbox/wireless/tables/wirelesslan.py +++ b/netbox/wireless/tables/wirelesslan.py @@ -51,6 +51,13 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): status = columns.ChoiceFieldColumn( verbose_name=_('Status'), ) + scope_type = columns.ContentTypeColumn( + verbose_name=_('Scope Type'), + ) + scope = tables.Column( + verbose_name=_('Scope'), + linkify=True + ) interface_count = tables.Column( verbose_name=_('Interfaces') ) @@ -65,7 +72,7 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): model = WirelessLAN fields = ( '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') diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py index 4b7545888..f768eafaf 100644 --- a/netbox/wireless/tests/test_api.py +++ b/netbox/wireless/tests/test_api.py @@ -1,7 +1,7 @@ from django.urls import reverse from dcim.choices import InterfaceTypeChoices -from dcim.models import Interface +from dcim.models import Interface, Site from tenancy.models import Tenant from utilities.testing import APITestCase, APIViewTestCases, create_test_device from wireless.choices import * @@ -53,6 +53,12 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + tenants = ( Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), @@ -94,6 +100,8 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase): 'status': WirelessLANStatusChoices.STATUS_DISABLED, 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE, + 'scope_type': 'dcim.site', + 'scope_id': sites[1].pk, }, ] diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index 5c932928c..76ef4e220 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/netbox/wireless/tests/test_filtersets.py @@ -1,7 +1,7 @@ from django.test import TestCase 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 netbox.choices import DistanceUnitChoices from tenancy.models import Tenant @@ -110,6 +110,36 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): ) 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 = ( Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), @@ -127,7 +157,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1', - description='foobar1' + description='foobar1', + scope=sites[0] ), WirelessLAN( ssid='WLAN2', @@ -138,7 +169,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2', - description='foobar2' + description='foobar2', + scope=locations[0] ), WirelessLAN( ssid='WLAN3', @@ -149,12 +181,14 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, 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 = ( Interface(device=device, name='Interface 1', 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]} 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): queryset = WirelessLink.objects.all() diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py index d28d9fde3..713ba81d7 100644 --- a/netbox/wireless/tests/test_views.py +++ b/netbox/wireless/tests/test_views.py @@ -1,7 +1,8 @@ +from django.contrib.contenttypes.models import ContentType from wireless.choices import * from wireless.models import * from dcim.choices import InterfaceTypeChoices, LinkStatusChoices -from dcim.models import Interface +from dcim.models import Interface, Site from netbox.choices import DistanceUnitChoices from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -56,6 +57,12 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + tenants = ( Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), @@ -98,15 +105,17 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'ssid': 'WLAN2', 'group': groups[1].pk, 'status': WirelessLANStatusChoices.STATUS_DISABLED, + 'scope_type': ContentType.objects.get_for_model(Site).pk, + 'scope': sites[1].pk, 'tenant': tenants[1].pk, 'tags': [t.pk for t in tags], } cls.csv_data = ( - "group,ssid,status,tenant", - 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,WLAN6,{WirelessLANStatusChoices.STATUS_RESERVED},{tenants[2].name}", + "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,WLAN5,{WirelessLANStatusChoices.STATUS_DISABLED},{tenants[1].name},dcim.site,{sites[0].pk}", + f"Wireless LAN Group 2,WLAN6,{WirelessLANStatusChoices.STATUS_RESERVED},{tenants[2].name},dcim.site,{sites[1].pk}", ) cls.csv_update_data = ( From a1830488910ff58c8fb05b43a7f3bccd685143ef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 7 Nov 2024 10:24:15 -0500 Subject: [PATCH 25/65] Closes #17951: Extend Ruff ruleset --- .../0047_circuittermination__termination.py | 1 + netbox/circuits/tests/test_views.py | 2 +- netbox/extras/forms/bulk_import.py | 2 +- .../extras/management/commands/runscript.py | 2 +- netbox/extras/migrations/0109_script_model.py | 4 ++-- netbox/ipam/lookups.py | 4 ++-- netbox/ipam/tests/test_ordering.py | 6 +++--- netbox/netbox/authentication/__init__.py | 2 +- netbox/netbox/tests/test_plugins.py | 1 - netbox/netbox/views/errors.py | 2 +- netbox/utilities/error_handlers.py | 2 +- netbox/utilities/tests/test_counters.py | 2 +- .../api/serializers_/clusters.py | 2 -- netbox/virtualization/graphql/types.py | 2 +- .../migrations/0044_cluster_scope.py | 20 +++++++++---------- ruff.toml | 2 ++ 16 files changed, 28 insertions(+), 28 deletions(-) diff --git a/netbox/circuits/migrations/0047_circuittermination__termination.py b/netbox/circuits/migrations/0047_circuittermination__termination.py index cb2c9ca07..0cf2b424f 100644 --- a/netbox/circuits/migrations/0047_circuittermination__termination.py +++ b/netbox/circuits/migrations/0047_circuittermination__termination.py @@ -21,6 +21,7 @@ def copy_site_assignments(apps, schema_editor): termination_id=models.F('provider_network_id') ) + class Migration(migrations.Migration): dependencies = [ diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index a87e327af..f6c626443 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -341,7 +341,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class TestCase(ViewTestCases.PrimaryObjectViewTestCase): +class TestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CircuitTermination @classmethod diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 258df8264..655a5d6ca 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -223,7 +223,7 @@ class EventRuleImportForm(NetBoxModelImportForm): from extras.scripts import get_module_and_script module_name, script_name = action_object.split('.', 1) try: - module, script = get_module_and_script(module_name, script_name) + script = get_module_and_script(module_name, script_name)[1] except ObjectDoesNotExist: raise forms.ValidationError(_("Script {name} not found").format(name=action_object)) self.instance.action_object = script diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index d5fb435ad..847d89396 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -38,7 +38,7 @@ class Command(BaseCommand): data = {} 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 # Take user from command line if provided and exists, other diff --git a/netbox/extras/migrations/0109_script_model.py b/netbox/extras/migrations/0109_script_model.py index 6bfd2c14c..2fa0bf8aa 100644 --- a/netbox/extras/migrations/0109_script_model.py +++ b/netbox/extras/migrations/0109_script_model.py @@ -30,7 +30,7 @@ def get_python_name(scriptmodule): """ 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] @@ -128,7 +128,7 @@ def update_event_rules(apps, schema_editor): for eventrule in EventRule.objects.filter(action_object_type=scriptmodule_ct): 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, name=name, defaults={'is_executable': False} diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py index c6abb5a26..c493b7876 100644 --- a/netbox/ipam/lookups.py +++ b/netbox/ipam/lookups.py @@ -108,8 +108,8 @@ class NetIn(Lookup): return self.rhs def as_sql(self, qn, connection): - lhs, lhs_params = self.process_lhs(qn, connection) - rhs, rhs_params = self.process_rhs(qn, connection) + lhs = self.process_lhs(qn, connection)[0] + rhs_params = self.process_rhs(qn, connection)[1] with_mask, without_mask = [], [] for address in rhs_params[0]: if '/' in address: diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py index 8d69af847..fea6b55e2 100644 --- a/netbox/ipam/tests/test_ordering.py +++ b/netbox/ipam/tests/test_ordering.py @@ -42,7 +42,7 @@ class PrefixOrderingTestCase(OrderingTestBase): """ 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 = ( 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')), @@ -106,7 +106,7 @@ class PrefixOrderingTestCase(OrderingTestBase): VRF A:10.1.1.0/24 None: 192.168.0.0/16 """ - vrf1, vrf2, vrf3 = list(VRF.objects.all()) + vrf1 = VRF.objects.first() 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/16')), @@ -130,7 +130,7 @@ class IPAddressOrderingTestCase(OrderingTestBase): """ This function tests ordering with the inclusion of vrfs """ - vrf1, vrf2, vrf3 = list(VRF.objects.all()) + vrf1, vrf2 = VRF.objects.all()[:2] 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.1.1/24')), diff --git a/netbox/netbox/authentication/__init__.py b/netbox/netbox/authentication/__init__.py index 7c2df4200..83f699e42 100644 --- a/netbox/netbox/authentication/__init__.py +++ b/netbox/netbox/authentication/__init__.py @@ -107,7 +107,7 @@ class ObjectPermissionMixin: return perms 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 if user_obj.is_active and user_obj.is_superuser: diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index db82d0a75..264c8e6f9 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -213,7 +213,6 @@ class PluginTest(TestCase): self.assertEqual(get_plugin_config(plugin, 'bar'), None) self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456) - def test_events_pipeline(self): """ Check that events pipeline is registered. diff --git a/netbox/netbox/views/errors.py b/netbox/netbox/views/errors.py index 9e8ed5a3a..5872a59cd 100644 --- a/netbox/netbox/views/errors.py +++ b/netbox/netbox/views/errors.py @@ -49,7 +49,7 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME): template = loader.get_template(template_name) except TemplateDoesNotExist: return HttpResponseServerError('

Server Error (500)

', content_type='text/html') - type_, error, traceback = sys.exc_info() + type_, error = sys.exc_info()[:2] return HttpResponseServerError(template.render({ 'error': error, diff --git a/netbox/utilities/error_handlers.py b/netbox/utilities/error_handlers.py index 5d2a46424..397098ded 100644 --- a/netbox/utilities/error_handlers.py +++ b/netbox/utilities/error_handlers.py @@ -49,7 +49,7 @@ def handle_rest_api_exception(request, *args, **kwargs): """ 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 = { 'error': str(error), 'exception': type_.__name__, diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py index 45823065e..668965e8a 100644 --- a/netbox/utilities/tests/test_counters.py +++ b/netbox/utilities/tests/test_counters.py @@ -83,7 +83,7 @@ class CountersTest(TestCase): @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) 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') InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1) device1.refresh_from_db() diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py index 101a5b5a3..c0b636e33 100644 --- a/netbox/virtualization/api/serializers_/clusters.py +++ b/netbox/virtualization/api/serializers_/clusters.py @@ -80,5 +80,3 @@ class ClusterSerializer(NetBoxModelSerializer): serializer = get_serializer_for_model(obj.scope) context = {'request': self.context['request']} return serializer(obj.scope, nested=True, context=context).data - - diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index f51e0e3f5..6052c8936 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -48,7 +48,7 @@ class ClusterType(VLANGroupsMixin, NetBoxObjectType): Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')], Annotated["SiteType", strawberry.lazy('dcim.graphql.types')], ], strawberry.union("ClusterScopeType")] | None: - return self.scope + return self.scope @strawberry_django.type( diff --git a/netbox/virtualization/migrations/0044_cluster_scope.py b/netbox/virtualization/migrations/0044_cluster_scope.py index 63a888ac3..b7af25f8b 100644 --- a/netbox/virtualization/migrations/0044_cluster_scope.py +++ b/netbox/virtualization/migrations/0044_cluster_scope.py @@ -3,17 +3,17 @@ from django.db import migrations, models def copy_site_assignments(apps, schema_editor): - """ - Copy site ForeignKey values to the scope GFK. - """ - ContentType = apps.get_model('contenttypes', 'ContentType') - Cluster = apps.get_model('virtualization', 'Cluster') - Site = apps.get_model('dcim', 'Site') + """ + Copy site ForeignKey values to the scope GFK. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + Cluster = apps.get_model('virtualization', 'Cluster') + Site = apps.get_model('dcim', 'Site') - Cluster.objects.filter(site__isnull=False).update( - scope_type=ContentType.objects.get_for_model(Site), - scope_id=models.F('site_id') - ) + Cluster.objects.filter(site__isnull=False).update( + scope_type=ContentType.objects.get_for_model(Site), + scope_id=models.F('site_id') + ) class Migration(migrations.Migration): diff --git a/ruff.toml b/ruff.toml index 854404469..94a0e1c61 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,2 +1,4 @@ [lint] +extend-select = ["E1", "E2", "E3", "W"] ignore = ["E501", "F403", "F405"] +preview = true From 03d413565f289f3f410ec4e76e938a1613e3cfe0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 7 Nov 2024 14:10:15 -0500 Subject: [PATCH 26/65] Fix linter error --- netbox/wireless/forms/bulk_import.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 5bf2d7dcd..f23ccf203 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -7,7 +7,7 @@ from ipam.models import VLAN from netbox.choices import * from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField from wireless.choices import * from wireless.models import * From 75aeaab8eebb3920ead490da242ec6d8a9e0009c Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 15 Nov 2024 04:55:32 -0800 Subject: [PATCH 27/65] 12596 Add Allocated Resources to Cluster API (#17956) * 12596 Add Allocated Resources to Cluster API * 12596 Add Allocated Resources to Cluster API * 12596 Add Allocated Resources to Cluster API * 12596 Add Allocated Resources to Cluster API * 12596 review changes * 12596 review changes --- netbox/virtualization/api/serializers_/clusters.py | 10 +++++++++- netbox/virtualization/api/views.py | 7 ++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py index c0b636e33..450924fef 100644 --- a/netbox/virtualization/api/serializers_/clusters.py +++ b/netbox/virtualization/api/serializers_/clusters.py @@ -59,6 +59,14 @@ class ClusterSerializer(NetBoxModelSerializer): ) scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) 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 device_count = RelatedObjectCountField('devices') @@ -69,7 +77,7 @@ class ClusterSerializer(NetBoxModelSerializer): fields = [ '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', - 'virtualmachine_count', + 'virtualmachine_count', 'allocated_vcpus', 'allocated_memory', 'allocated_disk' ] brief_fields = ('id', 'url', 'display', 'name', 'description', 'virtualmachine_count') diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index fdf1d71be..93980ce28 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,3 +1,4 @@ +from django.db.models import Sum from rest_framework.routers import APIRootView from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin @@ -33,7 +34,11 @@ class ClusterGroupViewSet(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 filterset_class = filtersets.ClusterFilterSet From 6ab0792f02495903045fbc8bb8efb0618e88f631 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 15 Nov 2024 06:32:09 -0800 Subject: [PATCH 28/65] Closes #11279: Replace `_name` natural key sorting with collation (#18009) * 11279 add collation * 11279 add collation * 11279 add collation * 11279 add collation * 11279 fix tables /tests * 11279 fix tests * 11279 refactor VirtualDisk * Clean up migrations * Misc cleanup * Correct errant file inclusion --------- Co-authored-by: Jeremy Stretch --- .../migrations/0049_natural_ordering.py | 22 ++ netbox/circuits/models/providers.py | 6 +- netbox/core/tests/test_changelog.py | 28 -- netbox/dcim/graphql/types.py | 20 +- .../migrations/0197_natural_sort_collation.py | 17 + .../dcim/migrations/0198_natural_ordering.py | 318 ++++++++++++++++++ .../dcim/models/device_component_templates.py | 14 +- netbox/dcim/models/device_components.py | 12 +- netbox/dcim/models/devices.py | 19 +- netbox/dcim/models/power.py | 6 +- netbox/dcim/models/racks.py | 10 +- netbox/dcim/models/sites.py | 11 +- netbox/dcim/tables/devices.py | 18 +- netbox/dcim/tables/devicetypes.py | 8 +- netbox/dcim/tables/racks.py | 1 - netbox/dcim/views.py | 7 +- .../ipam/migrations/0076_natural_ordering.py | 32 ++ netbox/ipam/models/asns.py | 3 +- netbox/ipam/models/vlans.py | 3 +- netbox/ipam/models/vrfs.py | 6 +- .../migrations/0017_natural_ordering.py | 27 ++ netbox/tenancy/models/contacts.py | 3 +- netbox/tenancy/models/tenants.py | 6 +- netbox/utilities/fields.py | 3 +- netbox/virtualization/graphql/types.py | 3 +- .../migrations/0046_natural_ordering.py | 43 +++ netbox/virtualization/models/clusters.py | 3 +- .../virtualization/models/virtualmachines.py | 34 +- .../virtualization/tables/virtualmachines.py | 1 - .../vpn/migrations/0007_natural_ordering.py | 47 +++ netbox/vpn/models/crypto.py | 15 +- netbox/vpn/models/l2vpn.py | 3 +- netbox/vpn/models/tunnels.py | 3 +- .../migrations/0012_natural_ordering.py | 17 + netbox/wireless/models.py | 3 +- 35 files changed, 622 insertions(+), 150 deletions(-) create mode 100644 netbox/circuits/migrations/0049_natural_ordering.py create mode 100644 netbox/dcim/migrations/0197_natural_sort_collation.py create mode 100644 netbox/dcim/migrations/0198_natural_ordering.py create mode 100644 netbox/ipam/migrations/0076_natural_ordering.py create mode 100644 netbox/tenancy/migrations/0017_natural_ordering.py create mode 100644 netbox/virtualization/migrations/0046_natural_ordering.py create mode 100644 netbox/vpn/migrations/0007_natural_ordering.py create mode 100644 netbox/wireless/migrations/0012_natural_ordering.py diff --git a/netbox/circuits/migrations/0049_natural_ordering.py b/netbox/circuits/migrations/0049_natural_ordering.py new file mode 100644 index 000000000..1b4f565e8 --- /dev/null +++ b/netbox/circuits/migrations/0049_natural_ordering.py @@ -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), + ), + ] diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index f0fe77b1a..be81caa54 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -21,7 +21,8 @@ class Provider(ContactsMixin, PrimaryModel): verbose_name=_('name'), max_length=100, unique=True, - help_text=_('Full name of the provider') + help_text=_('Full name of the provider'), + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), @@ -95,7 +96,8 @@ class ProviderNetwork(PrimaryModel): """ name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) provider = models.ForeignKey( to='circuits.Provider', diff --git a/netbox/core/tests/test_changelog.py b/netbox/core/tests/test_changelog.py index c58968ee8..4914dbaf3 100644 --- a/netbox/core/tests/test_changelog.py +++ b/netbox/core/tests/test_changelog.py @@ -76,10 +76,6 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2']) 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): site = Site(name='Site 1', slug='site-1') 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['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): site = Site( name='Site 1', @@ -153,10 +143,6 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) 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): sites = ( 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['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): site = Site(name='Site 1', slug='site-1') site.save() @@ -389,12 +371,6 @@ class ChangeLogAPITest(APITestCase): self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields']) 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): site = Site( name='Site 1', @@ -423,10 +399,6 @@ class ChangeLogAPITest(APITestCase): self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) 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): data = ( { diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 6493ec6b1..fc5f35780 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -76,7 +76,6 @@ class ComponentType( """ Base type for device/VM components """ - _name: str device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] @@ -93,7 +92,6 @@ class ComponentTemplateType( """ Base type for device/VM components """ - _name: str device_type: Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')] @@ -181,7 +179,7 @@ class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin filters=ConsolePortTemplateFilter ) class ConsolePortTemplateType(ModularComponentTemplateType): - _name: str + pass @strawberry_django.type( @@ -199,7 +197,7 @@ class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpoin filters=ConsoleServerPortTemplateFilter ) class ConsoleServerPortTemplateType(ModularComponentTemplateType): - _name: str + pass @strawberry_django.type( @@ -208,7 +206,6 @@ class ConsoleServerPortTemplateType(ModularComponentTemplateType): filters=DeviceFilter ) class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): - _name: str console_port_count: BigInt console_server_port_count: BigInt power_port_count: BigInt @@ -273,7 +270,7 @@ class DeviceBayType(ComponentType): filters=DeviceBayTemplateFilter ) class DeviceBayTemplateType(ComponentTemplateType): - _name: str + pass @strawberry_django.type( @@ -282,7 +279,6 @@ class DeviceBayTemplateType(ComponentTemplateType): filters=InventoryItemTemplateFilter ) class InventoryItemTemplateType(ComponentTemplateType): - _name: str role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] @@ -366,7 +362,6 @@ class FrontPortType(ModularComponentType, CabledObjectMixin): filters=FrontPortTemplateFilter ) class FrontPortTemplateType(ModularComponentTemplateType): - _name: str color: str rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] @@ -377,6 +372,7 @@ class FrontPortTemplateType(ModularComponentTemplateType): filters=InterfaceFilter ) class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin): + _name: str mac_address: str | None wwn: str | None parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None @@ -527,7 +523,7 @@ class ModuleBayType(ModularComponentType): filters=ModuleBayTemplateFilter ) class ModuleBayTemplateType(ModularComponentTemplateType): - _name: str + pass @strawberry_django.type( @@ -588,7 +584,6 @@ class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin filters=PowerOutletTemplateFilter ) class PowerOutletTemplateType(ModularComponentTemplateType): - _name: str power_port: Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')] | None @@ -620,8 +615,6 @@ class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): filters=PowerPortTemplateFilter ) class PowerPortTemplateType(ModularComponentTemplateType): - _name: str - poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] @@ -640,7 +633,6 @@ class RackTypeType(NetBoxObjectType): filters=RackFilter ) class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): - _name: str site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None @@ -693,7 +685,6 @@ class RearPortType(ModularComponentType, CabledObjectMixin): filters=RearPortTemplateFilter ) class RearPortTemplateType(ModularComponentTemplateType): - _name: str color: str frontport_templates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] @@ -729,7 +720,6 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): filters=SiteFilter ) class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): - _name: str time_zone: str | None region: Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None group: Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None diff --git a/netbox/dcim/migrations/0197_natural_sort_collation.py b/netbox/dcim/migrations/0197_natural_sort_collation.py new file mode 100644 index 000000000..a77632b37 --- /dev/null +++ b/netbox/dcim/migrations/0197_natural_sort_collation.py @@ -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", + ), + ] diff --git a/netbox/dcim/migrations/0198_natural_ordering.py b/netbox/dcim/migrations/0198_natural_ordering.py new file mode 100644 index 000000000..83e94a195 --- /dev/null +++ b/netbox/dcim/migrations/0198_natural_ordering.py @@ -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), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 00555d49e..ddd4d2426 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -44,12 +44,8 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin): max_length=64, help_text=_( "{module} is accepted as a substitution for the module bay position when attached to a module type." - ) - ) - _name = NaturalOrderingField( - target_field='name', - max_length=100, - blank=True + ), + db_collation="natural_sort" ) label = models.CharField( verbose_name=_('label'), @@ -65,7 +61,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin): class Meta: abstract = True - ordering = ('device_type', '_name') + ordering = ('device_type', 'name') constraints = ( models.UniqueConstraint( fields=('device_type', 'name'), @@ -125,7 +121,7 @@ class ModularComponentTemplateModel(ComponentTemplateModel): class Meta: abstract = True - ordering = ('device_type', 'module_type', '_name') + ordering = ('device_type', 'module_type', 'name') constraints = ( models.UniqueConstraint( fields=('device_type', 'name'), @@ -782,7 +778,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): component_model = InventoryItem class Meta: - ordering = ('device_type__id', 'parent__id', '_name') + ordering = ('device_type__id', 'parent__id', 'name') indexes = ( models.Index(fields=('component_type', 'component_id')), ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 36fd02add..31278a13c 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -50,12 +50,8 @@ class ComponentModel(NetBoxModel): ) name = models.CharField( verbose_name=_('name'), - max_length=64 - ) - _name = NaturalOrderingField( - target_field='name', - max_length=100, - blank=True + max_length=64, + db_collation="natural_sort" ) label = models.CharField( verbose_name=_('label'), @@ -71,7 +67,7 @@ class ComponentModel(NetBoxModel): class Meta: abstract = True - ordering = ('device', '_name') + ordering = ('device', 'name') constraints = ( models.UniqueConstraint( fields=('device', 'name'), @@ -1301,7 +1297,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): clone_fields = ('device', 'parent', 'role', 'manufacturer', 'status', 'part_id') class Meta: - ordering = ('device__id', 'parent__id', '_name') + ordering = ('device__id', 'parent__id', 'name') indexes = ( models.Index(fields=('component_type', 'component_id')), ) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 47f4ee6c9..a836c5d37 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -23,7 +23,7 @@ from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel from netbox.models.mixins import WeightMixin 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 .device_components import * from .mixins import RenderConfigMixin @@ -582,13 +582,8 @@ class Device( verbose_name=_('name'), max_length=64, blank=True, - null=True - ) - _name = NaturalOrderingField( - target_field='name', - max_length=100, - blank=True, - null=True + null=True, + db_collation="natural_sort" ) serial = models.CharField( max_length=50, @@ -775,7 +770,7 @@ class Device( ) class Meta: - ordering = ('_name', 'pk') # Name may be null + ordering = ('name', 'pk') # Name may be null constraints = ( models.UniqueConstraint( Lower('name'), 'site', 'tenant', @@ -1320,7 +1315,8 @@ class VirtualChassis(PrimaryModel): ) name = models.CharField( verbose_name=_('name'), - max_length=64 + max_length=64, + db_collation="natural_sort" ) domain = models.CharField( verbose_name=_('domain'), @@ -1382,7 +1378,8 @@ class VirtualDeviceContext(PrimaryModel): ) name = models.CharField( verbose_name=_('name'), - max_length=64 + max_length=64, + db_collation="natural_sort" ) status = models.CharField( verbose_name=_('status'), diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index d0c6b18b6..284cfe832 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -36,7 +36,8 @@ class PowerPanel(ContactsMixin, ImageAttachmentsMixin, PrimaryModel): ) name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) prerequisite_models = ( @@ -86,7 +87,8 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel): ) name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) status = models.CharField( verbose_name=_('status'), diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 013dfb619..08b7f5a35 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -19,7 +19,7 @@ from netbox.models.mixins import WeightMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from utilities.conversion import to_grams 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 .devices import Device, Module from .power import PowerFeed @@ -255,12 +255,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): ) name = models.CharField( verbose_name=_('name'), - max_length=100 - ) - _name = NaturalOrderingField( - target_field='name', max_length=100, - blank=True + db_collation="natural_sort" ) facility_id = models.CharField( max_length=50, @@ -340,7 +336,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): ) 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 = ( # Name and facility_id must be unique *only* within a Location models.UniqueConstraint( diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index a290f4119..0985a8d7a 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -8,7 +8,6 @@ from dcim.choices import * from dcim.constants import * from netbox.models import NestedGroupModel, PrimaryModel from netbox.models.features import ContactsMixin, ImageAttachmentsMixin -from utilities.fields import NaturalOrderingField __all__ = ( 'Location', @@ -143,12 +142,8 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel): verbose_name=_('name'), max_length=100, unique=True, - help_text=_("Full name of the site") - ) - _name = NaturalOrderingField( - target_field='name', - max_length=100, - blank=True + help_text=_("Full name of the site"), + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), @@ -245,7 +240,7 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel): ) class Meta: - ordering = ('_name',) + ordering = ('name',) verbose_name = _('site') verbose_name_plural = _('sites') diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index fed33401c..b7634626d 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -132,7 +132,6 @@ class PlatformTable(NetBoxTable): class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.TemplateColumn( verbose_name=_('Name'), - order_by=('_name',), template_code=DEVICE_LINK, linkify=True ) @@ -288,7 +287,6 @@ class DeviceComponentTable(NetBoxTable): name = tables.Column( verbose_name=_('Name'), linkify=True, - order_by=('_name',) ) device_status = columns.ChoiceFieldColumn( accessor=tables.A('device__status'), @@ -391,7 +389,6 @@ class DeviceConsolePortTable(ConsolePortTable): name = tables.TemplateColumn( verbose_name=_('Name'), template_code=' {{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -433,7 +430,6 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): verbose_name=_('Name'), template_code=' ' '{{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -482,7 +478,6 @@ class DevicePowerPortTable(PowerPortTable): verbose_name=_('Name'), template_code=' ' '{{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -531,7 +526,6 @@ class DevicePowerOutletTable(PowerOutletTable): name = tables.TemplateColumn( verbose_name=_('Name'), template_code=' {{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -550,6 +544,11 @@ class DevicePowerOutletTable(PowerOutletTable): class BaseInterfaceTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True, + order_by=('_name',) + ) enabled = columns.BooleanColumn( verbose_name=_('Enabled'), ) @@ -597,7 +596,7 @@ class BaseInterfaceTable(NetBoxTable): return ",".join([str(obj) for obj in value.all()]) -class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable): +class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( verbose_name=_('Device'), linkify={ @@ -736,7 +735,6 @@ class DeviceFrontPortTable(FrontPortTable): verbose_name=_('Name'), template_code=' ' '{{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -783,7 +781,6 @@ class DeviceRearPortTable(RearPortTable): verbose_name=_('Name'), template_code=' ' '{{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -846,7 +843,6 @@ class DeviceDeviceBayTable(DeviceBayTable): verbose_name=_('Name'), template_code=' {{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = columns.ActionsColumn( @@ -915,7 +911,6 @@ class DeviceModuleBayTable(ModuleBayTable): name = columns.MPTTColumn( verbose_name=_('Name'), linkify=True, - order_by=Accessor('_name') ) actions = columns.ActionsColumn( extra_buttons=MODULEBAY_BUTTONS @@ -982,7 +977,6 @@ class DeviceInventoryItemTable(InventoryItemTable): verbose_name=_('Name'), template_code='' '{{ value }}', - order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index e8a4e35f1..a7f8f08e8 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -163,9 +163,7 @@ class ComponentTemplateTable(NetBoxTable): id = tables.Column( verbose_name=_('ID') ) - name = tables.Column( - order_by=('_name',) - ) + name = tables.Column() class Meta(NetBoxTable.Meta): exclude = ('id', ) @@ -220,6 +218,10 @@ class PowerOutletTemplateTable(ComponentTemplateTable): class InterfaceTemplateTable(ComponentTemplateTable): + name = tables.Column( + verbose_name=_('Name'), + order_by=('_name',) + ) enabled = columns.BooleanColumn( verbose_name=_('Enabled'), ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index a6b704161..dbd99ca24 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -111,7 +111,6 @@ class RackTypeTable(NetBoxTable): class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.Column( verbose_name=_('Name'), - order_by=('_name',), linkify=True ) location = tables.Column( diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9a821a384..7a5a771a9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -688,8 +688,7 @@ class RackElevationListView(generic.ObjectListView): sort = request.GET.get('sort', 'name') if sort not in ORDERING_CHOICES: sort = 'name' - sort_field = sort.replace("name", "_name") # Use natural ordering - racks = racks.order_by(sort_field) + racks = racks.order_by(sort) # Pagination per_page = get_paginate_count(request) @@ -731,8 +730,8 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView): peer_racks = peer_racks.filter(location=instance.location) else: peer_racks = peer_racks.filter(location__isnull=True) - next_rack = peer_racks.filter(_name__gt=instance._name).first() - prev_rack = peer_racks.filter(_name__lt=instance._name).reverse().first() + next_rack = peer_racks.filter(name__gt=instance.name).first() + prev_rack = peer_racks.filter(name__lt=instance.name).reverse().first() # Determine any additional parameters to pass when embedding the rack elevations svg_extra = '&'.join([ diff --git a/netbox/ipam/migrations/0076_natural_ordering.py b/netbox/ipam/migrations/0076_natural_ordering.py new file mode 100644 index 000000000..8c7bfaea1 --- /dev/null +++ b/netbox/ipam/migrations/0076_natural_ordering.py @@ -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), + ), + ] diff --git a/netbox/ipam/models/asns.py b/netbox/ipam/models/asns.py index eb47426b2..c1d251301 100644 --- a/netbox/ipam/models/asns.py +++ b/netbox/ipam/models/asns.py @@ -16,7 +16,8 @@ class ASNRange(OrganizationalModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 7832cfc67..fa31fd608 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -35,7 +35,8 @@ class VLANGroup(OrganizationalModel): """ name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index 26afb7927..6a8b8d649 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -18,7 +18,8 @@ class VRF(PrimaryModel): """ name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) rd = models.CharField( max_length=VRF_RD_MAX_LENGTH, @@ -74,7 +75,8 @@ class RouteTarget(PrimaryModel): verbose_name=_('name'), max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) unique=True, - 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( to='tenancy.Tenant', diff --git a/netbox/tenancy/migrations/0017_natural_ordering.py b/netbox/tenancy/migrations/0017_natural_ordering.py new file mode 100644 index 000000000..de1fb49aa --- /dev/null +++ b/netbox/tenancy/migrations/0017_natural_ordering.py @@ -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), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 24ffef0cf..3969c8317 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -56,7 +56,8 @@ class Contact(PrimaryModel): ) name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) title = models.CharField( verbose_name=_('title'), diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index 7a2d9c2f8..55f0c5933 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -18,7 +18,8 @@ class TenantGroup(NestedGroupModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), @@ -39,7 +40,8 @@ class Tenant(ContactsMixin, PrimaryModel): """ name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index ee71223cb..1d16a1d3f 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -5,7 +5,6 @@ from django.db import models from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from utilities.ordering import naturalize from .forms.widgets import ColorSelect from .validators import ColorValidator @@ -40,7 +39,7 @@ class NaturalOrderingField(models.CharField): """ 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.naturalize_function = naturalize_function super().__init__(*args, **kwargs) diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 6052c8936..8476eac7e 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -25,7 +25,6 @@ class ComponentType(NetBoxObjectType): """ Base type for device/VM components """ - _name: str virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] @@ -77,7 +76,6 @@ class ClusterTypeType(OrganizationalObjectType): filters=VirtualMachineFilter ) class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType): - _name: str interface_count: BigInt virtual_disk_count: BigInt interface_count: BigInt @@ -102,6 +100,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType): filters=VMInterfaceFilter ) class VMInterfaceType(IPAddressesMixin, ComponentType): + _name: str mac_address: str | None parent: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None diff --git a/netbox/virtualization/migrations/0046_natural_ordering.py b/netbox/virtualization/migrations/0046_natural_ordering.py new file mode 100644 index 000000000..9284b6331 --- /dev/null +++ b/netbox/virtualization/migrations/0046_natural_ordering.py @@ -0,0 +1,43 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0045_clusters_cached_relations'), + ('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', + ), + ] diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index 601ee7f23..9f7b97e29 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -50,7 +50,8 @@ class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel): """ name = models.CharField( verbose_name=_('name'), - max_length=100 + max_length=100, + db_collation="natural_sort" ) type = models.ForeignKey( verbose_name=_('type'), diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 4ee41e403..ebfb2d6c5 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -69,12 +69,8 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co ) name = models.CharField( verbose_name=_('name'), - max_length=64 - ) - _name = NaturalOrderingField( - target_field='name', - max_length=100, - blank=True + max_length=64, + db_collation="natural_sort" ) status = models.CharField( max_length=50, @@ -152,7 +148,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co ) class Meta: - ordering = ('_name', 'pk') # Name may be non-unique + ordering = ('name', 'pk') # Name may be non-unique constraints = ( models.UniqueConstraint( Lower('name'), 'cluster', 'tenant', @@ -273,13 +269,8 @@ class ComponentModel(NetBoxModel): ) name = models.CharField( verbose_name=_('name'), - max_length=64 - ) - _name = NaturalOrderingField( - target_field='name', - naturalize_function=naturalize_interface, - max_length=100, - blank=True + max_length=64, + db_collation="natural_sort" ) description = models.CharField( verbose_name=_('description'), @@ -289,7 +280,6 @@ class ComponentModel(NetBoxModel): class Meta: abstract = True - ordering = ('virtual_machine', CollateAsChar('_name')) constraints = ( models.UniqueConstraint( fields=('virtual_machine', 'name'), @@ -311,10 +301,9 @@ class ComponentModel(NetBoxModel): class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): - virtual_machine = models.ForeignKey( - to='virtualization.VirtualMachine', - on_delete=models.CASCADE, - related_name='interfaces' # Override ComponentModel + name = models.CharField( + verbose_name=_('name'), + max_length=64, ) _name = NaturalOrderingField( target_field='name', @@ -322,6 +311,11 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): max_length=100, blank=True ) + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='interfaces' # Override ComponentModel + ) ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', @@ -358,6 +352,7 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): class Meta(ComponentModel.Meta): verbose_name = _('interface') verbose_name_plural = _('interfaces') + ordering = ('virtual_machine', CollateAsChar('_name')) def clean(self): super().clean() @@ -416,3 +411,4 @@ class VirtualDisk(ComponentModel, TrackingModelMixin): class Meta(ComponentModel.Meta): verbose_name = _('virtual disk') verbose_name_plural = _('virtual disks') + ordering = ('virtual_machine', 'name') diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 4a3138711..26d32f8cf 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -53,7 +53,6 @@ VMINTERFACE_BUTTONS = """ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.Column( verbose_name=_('Name'), - order_by=('_name',), linkify=True ) status = columns.ChoiceFieldColumn( diff --git a/netbox/vpn/migrations/0007_natural_ordering.py b/netbox/vpn/migrations/0007_natural_ordering.py new file mode 100644 index 000000000..01dd4620f --- /dev/null +++ b/netbox/vpn/migrations/0007_natural_ordering.py @@ -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), + ), + ] diff --git a/netbox/vpn/models/crypto.py b/netbox/vpn/models/crypto.py index 2b721ec29..8e991b578 100644 --- a/netbox/vpn/models/crypto.py +++ b/netbox/vpn/models/crypto.py @@ -22,7 +22,8 @@ class IKEProposal(PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) authentication_method = models.CharField( verbose_name=('authentication method'), @@ -67,7 +68,8 @@ class IKEPolicy(PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) version = models.PositiveSmallIntegerField( verbose_name=_('version'), @@ -125,7 +127,8 @@ class IPSecProposal(PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) encryption_algorithm = models.CharField( verbose_name=_('encryption'), @@ -176,7 +179,8 @@ class IPSecPolicy(PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) proposals = models.ManyToManyField( to='vpn.IPSecProposal', @@ -211,7 +215,8 @@ class IPSecProfile(PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) mode = models.CharField( verbose_name=_('mode'), diff --git a/netbox/vpn/models/l2vpn.py b/netbox/vpn/models/l2vpn.py index b799ab32d..3e562531d 100644 --- a/netbox/vpn/models/l2vpn.py +++ b/netbox/vpn/models/l2vpn.py @@ -20,7 +20,8 @@ class L2VPN(ContactsMixin, PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index 3a0f1dc14..714024a81 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -31,7 +31,8 @@ class Tunnel(PrimaryModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) status = models.CharField( verbose_name=_('status'), diff --git a/netbox/wireless/migrations/0012_natural_ordering.py b/netbox/wireless/migrations/0012_natural_ordering.py new file mode 100644 index 000000000..da818bdd9 --- /dev/null +++ b/netbox/wireless/migrations/0012_natural_ordering.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0011_wirelesslan__location_wirelesslan__region_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), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index d78c893a6..61ff72bc1 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -52,7 +52,8 @@ class WirelessLANGroup(NestedGroupModel): name = models.CharField( verbose_name=_('name'), max_length=100, - unique=True + unique=True, + db_collation="natural_sort" ) slug = models.SlugField( verbose_name=_('slug'), From 9fe6685562d0b6918c7acb6a83678bc399a3fd8a Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 15 Nov 2024 11:55:46 -0800 Subject: [PATCH 29/65] 17929 Add Scope Mixins to Prefix (#17930) * 17929 Add Scope Mixins to Prefix * 17929 Add Scope Mixins to Prefix * 17929 fixes for tests * 17929 merge latest scope changes * 12596 review changes * 12596 review changes * 12596 review changes * 12596 review changes * 12596 review changes * 12596 review changes * 17929 fix migrations --- netbox/dcim/api/serializers_/sites.py | 8 +-- netbox/dcim/base_filtersets.py | 67 ++++++++++++++++++ netbox/dcim/filtersets.py | 58 ---------------- netbox/dcim/graphql/types.py | 8 +-- netbox/dcim/models/mixins.py | 4 -- netbox/ipam/api/serializers_/ip.py | 5 +- netbox/ipam/apps.py | 2 +- netbox/ipam/constants.py | 5 -- netbox/ipam/filtersets.py | 56 +-------------- netbox/ipam/forms/bulk_edit.py | 30 +------- netbox/ipam/forms/bulk_import.py | 10 +-- netbox/ipam/forms/model_forms.py | 46 +------------ netbox/ipam/graphql/types.py | 2 +- .../0072_prefix_cached_relations.py | 14 ++-- netbox/ipam/models/ip.py | 69 +------------------ netbox/utilities/api.py | 2 +- netbox/virtualization/filtersets.py | 3 +- ...location_alter_cluster__region_and_more.py | 41 +++++++++++ ...l_ordering.py => 0047_natural_ordering.py} | 2 +- netbox/wireless/filtersets.py | 2 +- ...12_alter_wirelesslan__location_and_more.py | 41 +++++++++++ ...l_ordering.py => 0013_natural_ordering.py} | 2 +- 22 files changed, 187 insertions(+), 290 deletions(-) create mode 100644 netbox/dcim/base_filtersets.py create mode 100644 netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py rename netbox/virtualization/migrations/{0046_natural_ordering.py => 0047_natural_ordering.py} (93%) create mode 100644 netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py rename netbox/wireless/migrations/{0012_natural_ordering.py => 0013_natural_ordering.py} (82%) diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index 7cd89e38c..b818cd954 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -21,7 +21,7 @@ __all__ = ( class RegionSerializer(NestedGroupModelSerializer): parent = NestedRegionSerializer(required=False, allow_null=True, default=None) site_count = serializers.IntegerField(read_only=True, default=0) - prefix_count = RelatedObjectCountField('_prefixes') + prefix_count = RelatedObjectCountField('prefix_set') class Meta: model = Region @@ -35,7 +35,7 @@ class RegionSerializer(NestedGroupModelSerializer): class SiteGroupSerializer(NestedGroupModelSerializer): parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None) site_count = serializers.IntegerField(read_only=True, default=0) - prefix_count = RelatedObjectCountField('_prefixes') + prefix_count = RelatedObjectCountField('prefix_set') class Meta: model = SiteGroup @@ -63,7 +63,7 @@ class SiteSerializer(NetBoxModelSerializer): # Related object counts circuit_count = RelatedObjectCountField('circuit_terminations') device_count = RelatedObjectCountField('devices') - prefix_count = RelatedObjectCountField('_prefixes') + prefix_count = RelatedObjectCountField('prefix_set') rack_count = RelatedObjectCountField('racks') vlan_count = RelatedObjectCountField('vlans') virtualmachine_count = RelatedObjectCountField('virtual_machines') @@ -86,7 +86,7 @@ class LocationSerializer(NestedGroupModelSerializer): tenant = TenantSerializer(nested=True, required=False, allow_null=True) rack_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: model = Location diff --git a/netbox/dcim/base_filtersets.py b/netbox/dcim/base_filtersets.py new file mode 100644 index 000000000..c007c0120 --- /dev/null +++ b/netbox/dcim/base_filtersets.py @@ -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)'), + ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index df66ad77b..0371f882b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -73,7 +73,6 @@ __all__ = ( 'RearPortFilterSet', 'RearPortTemplateFilterSet', 'RegionFilterSet', - 'ScopedFilterSet', 'SiteFilterSet', 'SiteGroupFilterSet', 'VirtualChassisFilterSet', @@ -2345,60 +2344,3 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet): class Meta: model = Interface 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)'), - ) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index fc5f35780..cc1bcac0f 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -461,7 +461,7 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: - return self._clusters.all() + return self.cluster_set.all() @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: @@ -707,7 +707,7 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: - return self._clusters.all() + return self.cluster_set.all() @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: @@ -739,7 +739,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: - return self._clusters.all() + return self.cluster_set.all() @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: @@ -763,7 +763,7 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: - return self._clusters.all() + return self.cluster_set.all() @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 1df3364c4..ac4d7dab9 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -59,28 +59,24 @@ class CachedScopeMixin(models.Model): _location = models.ForeignKey( to='dcim.Location', on_delete=models.CASCADE, - related_name='_%(class)ss', blank=True, null=True ) _site = models.ForeignKey( to='dcim.Site', on_delete=models.CASCADE, - related_name='_%(class)ss', blank=True, null=True ) _region = models.ForeignKey( to='dcim.Region', on_delete=models.CASCADE, - related_name='_%(class)ss', blank=True, null=True ) _site_group = models.ForeignKey( to='dcim.SiteGroup', on_delete=models.CASCADE, - related_name='_%(class)ss', blank=True, null=True ) diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 0c3c141af..bfc7ac546 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -2,8 +2,9 @@ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from dcim.constants import LOCATION_SCOPE_TYPES 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 netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import NetBoxModelSerializer @@ -47,7 +48,7 @@ class PrefixSerializer(NetBoxModelSerializer): vrf = VRFSerializer(nested=True, required=False, allow_null=True) scope_type = ContentTypeField( queryset=ContentType.objects.filter( - model__in=PREFIX_SCOPE_TYPES + model__in=LOCATION_SCOPE_TYPES ), allow_null=True, required=False, diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index e0463dfce..ae88d69a9 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -18,7 +18,7 @@ class IPAMConfig(AppConfig): # Register denormalized fields denormalized.register(Prefix, '_site', { '_region': 'region', - '_sitegroup': 'group', + '_site_group': 'group', }) denormalized.register(Prefix, '_location', { '_site': 'site', diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index c07b8441f..6dffd3287 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -23,11 +23,6 @@ VRF_RD_MAX_LENGTH = 21 PREFIX_LENGTH_MIN = 1 PREFIX_LENGTH_MAX = 127 # IPv6 -# models values for ContentTypes which may be Prefix scope types -PREFIX_SCOPE_TYPES = ( - 'region', 'sitegroup', 'site', 'location', -) - # # IPAddresses diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 88c869a50..c762c15fe 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -1,5 +1,6 @@ import django_filters import netaddr +from dcim.base_filtersets import ScopedFilterSet from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q @@ -9,7 +10,7 @@ from drf_spectacular.utils import extend_schema_field from netaddr.core import AddrFormatError 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 tenancy.filtersets import TenancyFilterSet from utilities.filters import ( @@ -273,7 +274,7 @@ class RoleFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'description', 'weight') -class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): +class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet): family = django_filters.NumberFilter( field_name='prefix', lookup_expr='family' @@ -334,57 +335,6 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='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( queryset=VLAN.objects.all(), label=_('VLAN (ID)'), diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index c323a41c1..7f3216cfd 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ +from dcim.forms.mixins import ScopedBulkEditForm from dcim.models import Region, Site, SiteGroup from ipam.choices import * from ipam.constants import * @@ -205,20 +206,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('description',) -class PrefixBulkEditForm(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 - ) +class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -286,20 +274,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): '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): vrf = DynamicModelChoiceField( diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 3be4ccc59..7e1382be9 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface, Site +from dcim.forms.mixins import ScopedImportForm from ipam.choices import * from ipam.constants import * from ipam.models import * @@ -154,7 +155,7 @@ class RoleImportForm(NetBoxModelImportForm): fields = ('name', 'slug', 'weight', 'description', 'tags') -class PrefixImportForm(NetBoxModelImportForm): +class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm): vrf = CSVModelChoiceField( label=_('VRF'), queryset=VRF.objects.all(), @@ -169,11 +170,6 @@ class PrefixImportForm(NetBoxModelImportForm): to_field_name='name', 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( label=_('VLAN group'), queryset=VLANGroup.objects.all(), @@ -208,7 +204,7 @@ class PrefixImportForm(NetBoxModelImportForm): 'mark_utilized', 'description', 'comments', 'tags', ) labels = { - 'scope_id': 'Scope ID', + 'scope_id': _('Scope ID'), } def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 3d0cd3dd1..56a6dc3d9 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -4,6 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface, Site +from dcim.forms.mixins import ScopedForm from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField @@ -197,25 +198,12 @@ class RoleForm(NetBoxModelForm): ] -class PrefixForm(TenancyForm, NetBoxModelForm): +class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, 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( queryset=VLAN.objects.all(), required=False, @@ -248,36 +236,6 @@ class PrefixForm(TenancyForm, NetBoxModelForm): '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): vrf = DynamicModelChoiceField( diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 2ef63cf0c..5a4813e0c 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -154,7 +154,7 @@ class IPRangeType(NetBoxObjectType): @strawberry_django.type( models.Prefix, - exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_sitegroup'), + exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'), filters=PrefixFilter ) class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType): diff --git a/netbox/ipam/migrations/0072_prefix_cached_relations.py b/netbox/ipam/migrations/0072_prefix_cached_relations.py index 2b457ebda..4b438f7d5 100644 --- a/netbox/ipam/migrations/0072_prefix_cached_relations.py +++ b/netbox/ipam/migrations/0072_prefix_cached_relations.py @@ -11,11 +11,11 @@ def populate_denormalized_fields(apps, schema_editor): prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site') for prefix in prefixes: 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 # 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): @@ -29,22 +29,22 @@ class Migration(migrations.Migration): migrations.AddField( model_name='prefix', 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( model_name='prefix', 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( model_name='prefix', 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( model_name='prefix', - name='_sitegroup', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.sitegroup'), + name='_site_group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup'), ), # Populate denormalized FK values diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index b17e26169..dcecbcdea 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,5 +1,4 @@ import netaddr -from django.apps import apps from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ValidationError 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 core.models import ObjectType +from dcim.models.mixins import CachedScopeMixin from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -198,7 +198,7 @@ class Role(OrganizationalModel): 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 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'), 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( to='ipam.VRF', on_delete=models.PROTECT, @@ -272,36 +256,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): 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 _depth = models.PositiveSmallIntegerField( default=0, @@ -368,25 +322,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): 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 def family(self): return self.prefix.version if self.prefix else None diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 11b914811..6793c0526 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -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(): 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) return annotations diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index ac72bea12..ab25492b5 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -2,7 +2,8 @@ import django_filters from django.db.models import Q 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 extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate diff --git a/netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py b/netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py new file mode 100644 index 000000000..7b1168da0 --- /dev/null +++ b/netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py @@ -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' + ), + ), + ] diff --git a/netbox/virtualization/migrations/0046_natural_ordering.py b/netbox/virtualization/migrations/0047_natural_ordering.py similarity index 93% rename from netbox/virtualization/migrations/0046_natural_ordering.py rename to netbox/virtualization/migrations/0047_natural_ordering.py index 9284b6331..4454cfe2d 100644 --- a/netbox/virtualization/migrations/0046_natural_ordering.py +++ b/netbox/virtualization/migrations/0047_natural_ordering.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('virtualization', '0045_clusters_cached_relations'), + ('virtualization', '0046_alter_cluster__location_alter_cluster__region_and_more'), ('dcim', '0197_natural_sort_collation'), ] diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 5a4195e6c..cc5aefbd8 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from dcim.choices import LinkStatusChoices -from dcim.filtersets import ScopedFilterSet +from dcim.base_filtersets import ScopedFilterSet from dcim.models import Interface from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet diff --git a/netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py b/netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py new file mode 100644 index 000000000..7edaff92b --- /dev/null +++ b/netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py @@ -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' + ), + ), + ] diff --git a/netbox/wireless/migrations/0012_natural_ordering.py b/netbox/wireless/migrations/0013_natural_ordering.py similarity index 82% rename from netbox/wireless/migrations/0012_natural_ordering.py rename to netbox/wireless/migrations/0013_natural_ordering.py index da818bdd9..e33c87c60 100644 --- a/netbox/wireless/migrations/0012_natural_ordering.py +++ b/netbox/wireless/migrations/0013_natural_ordering.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('wireless', '0011_wirelesslan__location_wirelesslan__region_and_more'), + ('wireless', '0012_alter_wirelesslan__location_and_more'), ('dcim', '0197_natural_sort_collation'), ] From b4f15092dbb849ee85fd2dd8caf988ea4bd7eadb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Nov 2024 14:44:57 -0500 Subject: [PATCH 30/65] Closes #5858: Implement a quick-add UI widget for related objects (#18016) * WIP * Misc cleanup * Add warning re: nested quick-adds --- netbox/circuits/forms/model_forms.py | 14 ++++-- netbox/dcim/forms/model_forms.py | 21 +++++--- netbox/ipam/forms/model_forms.py | 14 ++++-- netbox/netbox/views/generic/object_views.py | 41 +++++++++++----- netbox/project-static/dist/netbox.js | 10 ++-- netbox/project-static/dist/netbox.js.map | 6 +-- netbox/project-static/src/buttons/reslug.ts | 48 +++++++++---------- netbox/project-static/src/htmx.ts | 11 +++-- netbox/project-static/src/quickAdd.ts | 39 +++++++++++++++ netbox/templates/htmx/quick_add.html | 28 +++++++++++ netbox/templates/htmx/quick_add_created.html | 22 +++++++++ netbox/tenancy/forms/forms.py | 1 + netbox/utilities/forms/fields/dynamic.py | 12 ++++- .../templates/widgets/apiselect.html | 27 ++++++++--- netbox/virtualization/forms/model_forms.py | 6 ++- netbox/vpn/forms/model_forms.py | 9 ++-- netbox/wireless/forms/model_forms.py | 3 +- 17 files changed, 236 insertions(+), 76 deletions(-) create mode 100644 netbox/project-static/src/quickAdd.ts create mode 100644 netbox/templates/htmx/quick_add.html create mode 100644 netbox/templates/htmx/quick_add_created.html diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 10cd06563..9eeb0f588 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -50,7 +50,9 @@ class ProviderForm(NetBoxModelForm): class ProviderAccountForm(NetBoxModelForm): provider = DynamicModelChoiceField( label=_('Provider'), - queryset=Provider.objects.all() + queryset=Provider.objects.all(), + selector=True, + quick_add=True ) comments = CommentField() @@ -64,7 +66,9 @@ class ProviderAccountForm(NetBoxModelForm): class ProviderNetworkForm(NetBoxModelForm): provider = DynamicModelChoiceField( label=_('Provider'), - queryset=Provider.objects.all() + queryset=Provider.objects.all(), + selector=True, + quick_add=True ) comments = CommentField() @@ -97,7 +101,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm): provider = DynamicModelChoiceField( label=_('Provider'), queryset=Provider.objects.all(), - selector=True + selector=True, + quick_add=True ) provider_account = DynamicModelChoiceField( label=_('Provider account'), @@ -108,7 +113,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm): } ) type = DynamicModelChoiceField( - queryset=CircuitType.objects.all() + queryset=CircuitType.objects.all(), + quick_add=True ) comments = CommentField() diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 2fcdbe5fd..b004798af 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -112,12 +112,14 @@ class SiteForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( label=_('Region'), queryset=Region.objects.all(), - required=False + required=False, + quick_add=True ) group = DynamicModelChoiceField( label=_('Group'), queryset=SiteGroup.objects.all(), - required=False + required=False, + quick_add=True ) asns = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), @@ -206,7 +208,8 @@ class RackRoleForm(NetBoxModelForm): class RackTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), - queryset=Manufacturer.objects.all() + queryset=Manufacturer.objects.all(), + quick_add=True ) comments = CommentField() slug = SlugField( @@ -348,7 +351,8 @@ class ManufacturerForm(NetBoxModelForm): class DeviceTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), - queryset=Manufacturer.objects.all() + queryset=Manufacturer.objects.all(), + quick_add=True ) default_platform = DynamicModelChoiceField( label=_('Default platform'), @@ -436,7 +440,8 @@ class PlatformForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), - required=False + required=False, + quick_add=True ) config_template = DynamicModelChoiceField( label=_('Config template'), @@ -508,7 +513,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm): ) role = DynamicModelChoiceField( label=_('Device role'), - queryset=DeviceRole.objects.all() + queryset=DeviceRole.objects.all(), + quick_add=True ) platform = DynamicModelChoiceField( label=_('Platform'), @@ -750,7 +756,8 @@ class PowerFeedForm(TenancyForm, NetBoxModelForm): power_panel = DynamicModelChoiceField( label=_('Power panel'), queryset=PowerPanel.objects.all(), - selector=True + selector=True, + quick_add=True ) rack = DynamicModelChoiceField( label=_('Rack'), diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 56a6dc3d9..53ffe8f3f 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -109,7 +109,8 @@ class RIRForm(NetBoxModelForm): class AggregateForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), - label=_('RIR') + label=_('RIR'), + quick_add=True ) comments = CommentField() @@ -132,6 +133,7 @@ class ASNRangeForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), label=_('RIR'), + quick_add=True ) slug = SlugField() fieldsets = ( @@ -150,6 +152,7 @@ class ASNForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), label=_('RIR'), + quick_add=True ) sites = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -216,7 +219,8 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm): role = DynamicModelChoiceField( label=_('Role'), queryset=Role.objects.all(), - required=False + required=False, + quick_add=True ) comments = CommentField() @@ -246,7 +250,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): role = DynamicModelChoiceField( label=_('Role'), queryset=Role.objects.all(), - required=False + required=False, + quick_add=True ) comments = CommentField() @@ -639,7 +644,8 @@ class VLANForm(TenancyForm, NetBoxModelForm): role = DynamicModelChoiceField( label=_('Role'), queryset=Role.objects.all(), - required=False + required=False, + quick_add=True ) qinq_svlan = DynamicModelChoiceField( label=_('Q-in-Q SVLAN'), diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 0686e52b7..fb554ca4f 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -233,18 +233,23 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): form = self.form(instance=obj, initial=initial_data) restrict_form_fields(form, request.user) - # If this is an HTMX request, return only the rendered form HTML - if htmx_partial(request): - return render(request, self.htmx_template_name, { - 'model': model, - 'object': obj, - 'form': form, - }) - - return render(request, self.template_name, { + context = { 'model': model, 'object': obj, 'form': form, + } + + # If the form is being displayed within a "quick add" widget, + # use the appropriate template + if request.GET.get('_quickadd'): + return render(request, 'htmx/quick_add.html', context) + + # If this is an HTMX request, return only the rendered form HTML + if htmx_partial(request): + return render(request, self.htmx_template_name, context) + + return render(request, self.template_name, { + **context, 'return_url': self.get_return_url(request, obj), 'prerequisite_model': get_prerequisite_model(self.queryset), **self.get_extra_context(request, obj), @@ -259,6 +264,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ logger = logging.getLogger('netbox.views.ObjectEditView') obj = self.get_object(**kwargs) + model = self.queryset.model # Take a snapshot for change logging (if editing an existing object) if obj.pk and hasattr(obj, 'snapshot'): @@ -292,6 +298,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): msg = f'{msg} {obj}' messages.success(request, msg) + # Object was created via "quick add" modal + if '_quickadd' in request.POST: + return render(request, 'htmx/quick_add_created.html', { + 'object': obj, + }) + # If adding another object, redirect back to the edit form if '_addanother' in request.POST: redirect_url = request.path @@ -324,12 +336,19 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): else: logger.debug("Form validation failed") - return render(request, self.template_name, { + context = { + 'model': model, 'object': obj, 'form': form, 'return_url': self.get_return_url(request, obj), **self.get_extra_context(request, obj), - }) + } + + # Form was submitted via a "quick add" widget + if '_quickadd' in request.POST: + return render(request, 'htmx/quick_add.html', context) + + return render(request, self.template_name, context) class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 969d5c73a..5e24ee625 100644 --- a/netbox/project-static/dist/netbox.js +++ b/netbox/project-static/dist/netbox.js @@ -1,12 +1,12 @@ -"use strict";(()=>{var sh=Object.create;var Ua=Object.defineProperty,oh=Object.defineProperties,ah=Object.getOwnPropertyDescriptor,lh=Object.getOwnPropertyDescriptors,ch=Object.getOwnPropertyNames,du=Object.getOwnPropertySymbols,uh=Object.getPrototypeOf,fu=Object.prototype.hasOwnProperty,dh=Object.prototype.propertyIsEnumerable;var tc=(ii,ti,ei)=>ti in ii?Ua(ii,ti,{enumerable:!0,configurable:!0,writable:!0,value:ei}):ii[ti]=ei,Ui=(ii,ti)=>{for(var ei in ti||(ti={}))fu.call(ti,ei)&&tc(ii,ei,ti[ei]);if(du)for(var ei of du(ti))dh.call(ti,ei)&&tc(ii,ei,ti[ei]);return ii},Hn=(ii,ti)=>oh(ii,lh(ti));var Ya=(ii,ti)=>()=>(ti||ii((ti={exports:{}}).exports,ti),ti.exports),hu=(ii,ti)=>{for(var ei in ti)Ua(ii,ei,{get:ti[ei],enumerable:!0})},fh=(ii,ti,ei,ni)=>{if(ti&&typeof ti=="object"||typeof ti=="function")for(let ri of ch(ti))!fu.call(ii,ri)&&ri!==ei&&Ua(ii,ri,{get:()=>ti[ri],enumerable:!(ni=ah(ti,ri))||ni.enumerable});return ii};var zo=(ii,ti,ei)=>(ei=ii!=null?sh(uh(ii)):{},fh(ti||!ii||!ii.__esModule?Ua(ei,"default",{value:ii,enumerable:!0}):ei,ii));var Rn=(ii,ti,ei)=>tc(ii,typeof ti!="symbol"?ti+"":ti,ei);var ks=(ii,ti,ei)=>new Promise((ni,ri)=>{var si=di=>{try{li(ei.next(di))}catch(mi){ri(mi)}},ai=di=>{try{li(ei.throw(di))}catch(mi){ri(mi)}},li=di=>di.done?ni(di.value):Promise.resolve(di.value).then(si,ai);li((ei=ei.apply(ii,ti)).next())});var Id=Ya((exports,module)=>{(function(ii,ti){typeof define=="function"&&define.amd?define([],ti):typeof module=="object"&&module.exports?module.exports=ti():ii.htmx=ii.htmx||ti()})(typeof self!="undefined"?self:exports,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(ii,ti){var ei=dr(ii,ti||"post");return ei.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,allowScriptTags:!0,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:!1,scrollBehavior:"smooth",defaultFocusScroll:!1,getCacheBusterParam:!1,globalViewTransitions:!1,methodsThatUseUrlParams:["get"],selfRequestsOnly:!1,ignoreTitle:!1,scrollIntoViewOnBoost:!0,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(ii){return new EventSource(ii,{withCredentials:!0})},createWebSocket:function(ii){var ti=new WebSocket(ii,[]);return ti.binaryType=Q.config.wsBinaryType,ti},version:"1.9.12"},r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R},w=["get","post","put","delete","patch"],i=w.map(function(ii){return"[hx-"+ii+"], [data-hx-"+ii+"]"}).join(", "),S=e("head"),q=e("title"),H=e("svg",!0);function e(ii,ti){return new RegExp("<"+ii+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+ii+">",ti?"gim":"im")}function d(ii){if(ii==null)return;let ti=NaN;return ii.slice(-2)=="ms"?ti=parseFloat(ii.slice(0,-2)):ii.slice(-1)=="s"?ti=parseFloat(ii.slice(0,-1))*1e3:ii.slice(-1)=="m"?ti=parseFloat(ii.slice(0,-1))*1e3*60:ti=parseFloat(ii),isNaN(ti)?void 0:ti}function ee(ii,ti){return ii.getAttribute&&ii.getAttribute(ti)}function o(ii,ti){return ii.hasAttribute&&(ii.hasAttribute(ti)||ii.hasAttribute("data-"+ti))}function te(ii,ti){return ee(ii,ti)||ee(ii,"data-"+ti)}function u(ii){return ii.parentElement}function re(){return document}function c(ii,ti){for(;ii&&!ti(ii);)ii=u(ii);return ii||null}function L(ii,ti,ei){var ni=te(ti,ei),ri=te(ti,"hx-disinherit");return ii!==ti&&ri&&(ri==="*"||ri.split(" ").indexOf(ei)>=0)?"unset":ni}function ne(ii,ti){var ei=null;if(c(ii,function(ni){return ei=L(ii,ni,ti)}),ei!=="unset")return ei}function h(ii,ti){var ei=ii.matches||ii.matchesSelector||ii.msMatchesSelector||ii.mozMatchesSelector||ii.webkitMatchesSelector||ii.oMatchesSelector;return ei&&ei.call(ii,ti)}function A(ii){var ti=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i,ei=ti.exec(ii);return ei?ei[1].toLowerCase():""}function s(ii,ti){for(var ei=new DOMParser,ni=ei.parseFromString(ii,"text/html"),ri=ni.body;ti>0;)ti--,ri=ri.firstChild;return ri==null&&(ri=re().createDocumentFragment()),ri}function N(ii){return/",0),si=ri.querySelector("template").content;return Q.config.allowScriptTags?oe(si.querySelectorAll("script"),function(ai){Q.config.inlineScriptNonce&&(ai.nonce=Q.config.inlineScriptNonce),ai.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1}):oe(si.querySelectorAll("script"),function(ai){_(ai)}),si}switch(ei){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s(""+ni+"
",1);case"col":return s(""+ni+"
",2);case"tr":return s(""+ni+"
",2);case"td":case"th":return s(""+ni+"
",3);case"script":case"style":return s("
"+ni+"
",1);default:return s(ni,0)}}function ie(ii){ii&&ii()}function I(ii,ti){return Object.prototype.toString.call(ii)==="[object "+ti+"]"}function k(ii){return I(ii,"Function")}function P(ii){return I(ii,"Object")}function ae(ii){var ti="htmx-internal-data",ei=ii[ti];return ei||(ei=ii[ti]={}),ei}function M(ii){var ti=[];if(ii)for(var ei=0;ei=0}function se(ii){return ii.getRootNode&&ii.getRootNode()instanceof window.ShadowRoot?re().body.contains(ii.getRootNode().host):re().body.contains(ii)}function D(ii){return ii.trim().split(/\s+/)}function le(ii,ti){for(var ei in ti)ti.hasOwnProperty(ei)&&(ii[ei]=ti[ei]);return ii}function E(ii){try{return JSON.parse(ii)}catch(ti){return b(ti),null}}function U(){var ii="htmx:localStorageTest";try{return localStorage.setItem(ii,ii),localStorage.removeItem(ii),!0}catch(ti){return!1}}function B(ii){try{var ti=new URL(ii);return ti&&(ii=ti.pathname+ti.search),/^\/$/.test(ii)||(ii=ii.replace(/\/+$/,"")),ii}catch(ei){return ii}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(ii){var ti=Q.on("htmx:load",function(ei){ii(ei.detail.elt)});return ti}function V(){Q.logger=function(ii,ti,ei){console&&console.log(ti,ii,ei)}}function j(){Q.logger=null}function C(ii,ti){return ti?ii.querySelector(ti):C(re(),ii)}function f(ii,ti){return ti?ii.querySelectorAll(ti):f(re(),ii)}function _(ii,ti){ii=p(ii),ti?setTimeout(function(){_(ii),ii=null},ti):ii.parentElement.removeChild(ii)}function z(ii,ti,ei){ii=p(ii),ei?setTimeout(function(){z(ii,ti),ii=null},ei):ii.classList&&ii.classList.add(ti)}function n(ii,ti,ei){ii=p(ii),ei?setTimeout(function(){n(ii,ti),ii=null},ei):ii.classList&&(ii.classList.remove(ti),ii.classList.length===0&&ii.removeAttribute("class"))}function $(ii,ti){ii=p(ii),ii.classList.toggle(ti)}function W(ii,ti){ii=p(ii),oe(ii.parentElement.children,function(ei){n(ei,ti)}),z(ii,ti)}function v(ii,ti){if(ii=p(ii),ii.closest)return ii.closest(ti);do if(ii==null||h(ii,ti))return ii;while(ii=ii&&u(ii));return null}function g(ii,ti){return ii.substring(0,ti.length)===ti}function G(ii,ti){return ii.substring(ii.length-ti.length)===ti}function J(ii){var ti=ii.trim();return g(ti,"<")&&G(ti,"/>")?ti.substring(1,ti.length-2):ti}function Z(ii,ti){return ti.indexOf("closest ")===0?[v(ii,J(ti.substr(8)))]:ti.indexOf("find ")===0?[C(ii,J(ti.substr(5)))]:ti==="next"?[ii.nextElementSibling]:ti.indexOf("next ")===0?[K(ii,J(ti.substr(5)))]:ti==="previous"?[ii.previousElementSibling]:ti.indexOf("previous ")===0?[Y(ii,J(ti.substr(9)))]:ti==="document"?[document]:ti==="window"?[window]:ti==="body"?[document.body]:re().querySelectorAll(J(ti))}var K=function(ii,ti){for(var ei=re().querySelectorAll(ti),ni=0;ni=0;ni--){var ri=ei[ni];if(ri.compareDocumentPosition(ii)===Node.DOCUMENT_POSITION_FOLLOWING)return ri}};function ue(ii,ti){return ti?Z(ii,ti)[0]:Z(re().body,ii)[0]}function p(ii){return I(ii,"String")?C(ii):ii}function ve(ii,ti,ei){return k(ti)?{target:re().body,event:ii,listener:ti}:{target:p(ii),event:ti,listener:ei}}function de(ii,ti,ei){jr(function(){var ri=ve(ii,ti,ei);ri.target.addEventListener(ri.event,ri.listener)});var ni=k(ti);return ni?ti:ei}function ge(ii,ti,ei){return jr(function(){var ni=ve(ii,ti,ei);ni.target.removeEventListener(ni.event,ni.listener)}),k(ti)?ti:ei}var pe=re().createElement("output");function me(ii,ti){var ei=ne(ii,ti);if(ei){if(ei==="this")return[xe(ii,ti)];var ni=Z(ii,ei);return ni.length===0?(b('The selector "'+ei+'" on '+ti+" returned no matches!"),[pe]):ni}}function xe(ii,ti){return c(ii,function(ei){return te(ei,ti)!=null})}function ye(ii){var ti=ne(ii,"hx-target");if(ti)return ti==="this"?xe(ii,"hx-target"):ue(ii,ti);var ei=ae(ii);return ei.boosted?re().body:ii}function be(ii){for(var ti=Q.config.attributesToSettle,ei=0;ei0?(ri=ii.substr(0,ii.indexOf(":")),ni=ii.substr(ii.indexOf(":")+1,ii.length)):ri=ii);var si=re().querySelectorAll(ni);return si?(oe(si,function(ai){var li,di=ti.cloneNode(!0);li=re().createDocumentFragment(),li.appendChild(di),Se(ri,ai)||(li=di);var mi={shouldSwap:!0,target:ai,fragment:li};ce(ai,"htmx:oobBeforeSwap",mi)&&(ai=mi.target,mi.shouldSwap&&Fe(ri,ai,ai,li,ei),oe(ei.elts,function(hi){ce(hi,"htmx:oobAfterSwap",mi)}))}),ti.parentNode.removeChild(ti)):(ti.parentNode.removeChild(ti),fe(re().body,"htmx:oobErrorNoTarget",{content:ti})),ii}function Ce(ii,ti,ei){var ni=ne(ii,"hx-select-oob");if(ni)for(var ri=ni.split(","),si=0;si0){var si=ri.replace("'","\\'"),ai=ni.tagName.replace(":","\\:"),li=ii.querySelector(ai+"[id='"+si+"']");if(li&&li!==ii){var di=ni.cloneNode();we(ni,li),ei.tasks.push(function(){we(ni,di)})}}})}function Oe(ii){return function(){n(ii,Q.config.addedClass),zt(ii),Nt(ii),qe(ii),ce(ii,"htmx:load")}}function qe(ii){var ti="[autofocus]",ei=h(ii,ti)?ii:ii.querySelector(ti);ei!=null&&ei.focus()}function a(ii,ti,ei,ni){for(Te(ii,ei,ni);ei.childNodes.length>0;){var ri=ei.firstChild;z(ri,Q.config.addedClass),ii.insertBefore(ri,ti),ri.nodeType!==Node.TEXT_NODE&&ri.nodeType!==Node.COMMENT_NODE&&ni.tasks.push(Oe(ri))}}function He(ii,ti){for(var ei=0;ei-1){var ti=ii.replace(H,""),ei=ti.match(q);if(ei)return ei[2]}}function je(ii,ti,ei,ni,ri,si){ri.title=Ve(ni);var ai=l(ni);if(ai)return Ce(ei,ai,ri),ai=Be(ei,ai,si),Re(ai),Fe(ii,ei,ti,ai,ri)}function _e(ii,ti,ei){var ni=ii.getResponseHeader(ti);if(ni.indexOf("{")===0){var ri=E(ni);for(var si in ri)if(ri.hasOwnProperty(si)){var ai=ri[si];P(ai)||(ai={value:ai}),ce(ei,si,ai)}}else for(var li=ni.split(","),di=0;di0;){var ai=ti[0];if(ai==="]"){if(ni--,ni===0){si===null&&(ri=ri+"true"),ti.shift(),ri+=")})";try{var li=Tr(ii,function(){return Function(ri)()},function(){return!0});return li.source=ri,li}catch(di){return fe(re().body,"htmx:syntax:error",{error:di,source:ri}),null}}}else ai==="["&&ni++;Qe(ai,si,ei)?ri+="(("+ei+"."+ai+") ? ("+ei+"."+ai+") : (window."+ai+"))":ri=ri+ai,si=ti.shift()}}}function y(ii,ti){for(var ei="";ii.length>0&&!ti.test(ii[0]);)ei+=ii.shift();return ei}function tt(ii){var ti;return ii.length>0&&Ze.test(ii[0])?(ii.shift(),ti=y(ii,Ke).trim(),ii.shift()):ti=y(ii,x),ti}var rt="input, textarea, select";function nt(ii,ti,ei){var ni=[],ri=Ye(ti);do{y(ri,Je);var si=ri.length,ai=y(ri,/[,\[\s]/);if(ai!=="")if(ai==="every"){var li={trigger:"every"};y(ri,Je),li.pollInterval=d(y(ri,/[,\[\s]/)),y(ri,Je);var di=et(ii,ri,"event");di&&(li.eventFilter=di),ni.push(li)}else if(ai.indexOf("sse:")===0)ni.push({trigger:"sse",sseEvent:ai.substr(4)});else{var mi={trigger:ai},di=et(ii,ri,"event");for(di&&(mi.eventFilter=di);ri.length>0&&ri[0]!==",";){y(ri,Je);var hi=ri.shift();if(hi==="changed")mi.changed=!0;else if(hi==="once")mi.once=!0;else if(hi==="consume")mi.consume=!0;else if(hi==="delay"&&ri[0]===":")ri.shift(),mi.delay=d(y(ri,x));else if(hi==="from"&&ri[0]===":"){if(ri.shift(),Ze.test(ri[0]))var _i=tt(ri);else{var _i=y(ri,x);if(_i==="closest"||_i==="find"||_i==="next"||_i==="previous"){ri.shift();var Ei=tt(ri);Ei.length>0&&(_i+=" "+Ei)}}mi.from=_i}else hi==="target"&&ri[0]===":"?(ri.shift(),mi.target=tt(ri)):hi==="throttle"&&ri[0]===":"?(ri.shift(),mi.throttle=d(y(ri,x))):hi==="queue"&&ri[0]===":"?(ri.shift(),mi.queue=y(ri,x)):hi==="root"&&ri[0]===":"?(ri.shift(),mi[hi]=tt(ri)):hi==="threshold"&&ri[0]===":"?(ri.shift(),mi[hi]=y(ri,x)):fe(ii,"htmx:syntax:error",{token:ri.shift()})}ni.push(mi)}ri.length===si&&fe(ii,"htmx:syntax:error",{token:ri.shift()}),y(ri,Je)}while(ri[0]===","&&ri.shift());return ei&&(ei[ti]=ni),ni}function it(ii){var ti=te(ii,"hx-trigger"),ei=[];if(ti){var ni=Q.config.triggerSpecsCache;ei=ni&&ni[ti]||nt(ii,ti,ni)}return ei.length>0?ei:h(ii,"form")?[{trigger:"submit"}]:h(ii,'input[type="button"], input[type="submit"]')?[{trigger:"click"}]:h(ii,rt)?[{trigger:"change"}]:[{trigger:"click"}]}function at(ii){ae(ii).cancelled=!0}function ot(ii,ti,ei){var ni=ae(ii);ni.timeout=setTimeout(function(){se(ii)&&ni.cancelled!==!0&&(ct(ei,ii,Wt("hx:poll:trigger",{triggerSpec:ei,target:ii}))||ti(ii),ot(ii,ti,ei))},ei.pollInterval)}function st(ii){return location.hostname===ii.hostname&&ee(ii,"href")&&ee(ii,"href").indexOf("#")!==0}function lt(ii,ti,ei){if(ii.tagName==="A"&&st(ii)&&(ii.target===""||ii.target==="_self")||ii.tagName==="FORM"){ti.boosted=!0;var ni,ri;if(ii.tagName==="A")ni="get",ri=ee(ii,"href");else{var si=ee(ii,"method");ni=si?si.toLowerCase():"get",ri=ee(ii,"action")}ei.forEach(function(ai){ht(ii,function(li,di){if(v(li,Q.config.disableSelector)){m(li);return}he(ni,ri,li,di)},ti,ai,!0)})}}function ut(ii,ti){return!!((ii.type==="submit"||ii.type==="click")&&(ti.tagName==="FORM"||h(ti,'input[type="submit"], button')&&v(ti,"form")!==null||ti.tagName==="A"&&ti.href&&(ti.getAttribute("href")==="#"||ti.getAttribute("href").indexOf("#")!==0)))}function ft(ii,ti){return ae(ii).boosted&&ii.tagName==="A"&&ti.type==="click"&&(ti.ctrlKey||ti.metaKey)}function ct(ii,ti,ei){var ni=ii.eventFilter;if(ni)try{return ni.call(ti,ei)!==!0}catch(ri){return fe(re().body,"htmx:eventFilter:error",{error:ri,source:ni.source}),!0}return!1}function ht(ii,ti,ei,ni,ri){var si=ae(ii),ai;ni.from?ai=Z(ii,ni.from):ai=[ii],ni.changed&&ai.forEach(function(li){var di=ae(li);di.lastValue=li.value}),oe(ai,function(li){var di=function(mi){if(!se(ii)){li.removeEventListener(ni.trigger,di);return}if(!ft(ii,mi)&&((ri||ut(mi,ii))&&mi.preventDefault(),!ct(ni,ii,mi))){var hi=ae(mi);if(hi.triggerSpec=ni,hi.handledFor==null&&(hi.handledFor=[]),hi.handledFor.indexOf(ii)<0){if(hi.handledFor.push(ii),ni.consume&&mi.stopPropagation(),ni.target&&mi.target&&!h(mi.target,ni.target))return;if(ni.once){if(si.triggeredOnce)return;si.triggeredOnce=!0}if(ni.changed){var _i=ae(li);if(_i.lastValue===li.value)return;_i.lastValue=li.value}if(si.delayed&&clearTimeout(si.delayed),si.throttle)return;ni.throttle>0?si.throttle||(ti(ii,mi),si.throttle=setTimeout(function(){si.throttle=null},ni.throttle)):ni.delay>0?si.delayed=setTimeout(function(){ti(ii,mi)},ni.delay):(ce(ii,"htmx:trigger"),ti(ii,mi))}}};ei.listenerInfos==null&&(ei.listenerInfos=[]),ei.listenerInfos.push({trigger:ni.trigger,listener:di,on:li}),li.addEventListener(ni.trigger,di)})}var vt=!1,dt=null;function gt(){dt||(dt=function(){vt=!0},window.addEventListener("scroll",dt),setInterval(function(){vt&&(vt=!1,oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(ii){pt(ii)}))},200))}function pt(ii){if(!o(ii,"data-hx-revealed")&&X(ii)){ii.setAttribute("data-hx-revealed","true");var ti=ae(ii);ti.initHash?ce(ii,"revealed"):ii.addEventListener("htmx:afterProcessNode",function(ei){ce(ii,"revealed")},{once:!0})}}function mt(ii,ti,ei){for(var ni=D(ei),ri=0;ri=0){var ai=wt(ei);setTimeout(function(){xt(ii,ti,ei+1)},ai)}},ri.onopen=function(si){ei=0},ae(ii).webSocket=ri,ri.addEventListener("message",function(si){if(!yt(ii)){var ai=si.data;R(ii,function(Ei){ai=Ei.transformResponse(ai,null,ii)});for(var li=T(ii),di=l(ai),mi=M(di.children),hi=0;hi0){ce(ii,"htmx:validation:halted",ai);return}ni.send(JSON.stringify(hi)),ut(ei,ii)&&ei.preventDefault()}):fe(ii,"htmx:noWebSocketSourceError")}function wt(ii){var ti=Q.config.wsReconnectDelay;if(typeof ti=="function")return ti(ii);if(ti==="full-jitter"){var ei=Math.min(ii,6),ni=1e3*Math.pow(2,ei);return ni*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(ii,ti,ei){for(var ni=D(ei),ri=0;ri0?setTimeout(ri,ni):ri()}function Ht(ii,ti,ei){var ni=!1;return oe(w,function(ri){if(o(ii,"hx-"+ri)){var si=te(ii,"hx-"+ri);ni=!0,ti.path=si,ti.verb=ri,ei.forEach(function(ai){Lt(ii,ai,ti,function(li,di){if(v(li,Q.config.disableSelector)){m(li);return}he(ri,si,li,di)})})}}),ni}function Lt(ii,ti,ei,ni){if(ti.sseEvent)Rt(ii,ni,ti.sseEvent);else if(ti.trigger==="revealed")gt(),ht(ii,ni,ei,ti),pt(ii);else if(ti.trigger==="intersect"){var ri={};ti.root&&(ri.root=ue(ii,ti.root)),ti.threshold&&(ri.threshold=parseFloat(ti.threshold));var si=new IntersectionObserver(function(ai){for(var li=0;li0?(ei.polling=!0,ot(ii,ni,ti)):ht(ii,ni,ei,ti)}function At(ii){if(!ii.htmxExecuted&&Q.config.allowScriptTags&&(ii.type==="text/javascript"||ii.type==="module"||ii.type==="")){var ti=re().createElement("script");oe(ii.attributes,function(ni){ti.setAttribute(ni.name,ni.value)}),ti.textContent=ii.textContent,ti.async=!1,Q.config.inlineScriptNonce&&(ti.nonce=Q.config.inlineScriptNonce);var ei=ii.parentElement;try{ei.insertBefore(ti,ii)}catch(ni){b(ni)}finally{ii.parentElement&&ii.parentElement.removeChild(ii)}}}function Nt(ii){h(ii,"script")&&At(ii),oe(f(ii,"script"),function(ti){At(ti)})}function It(ii){var ti=ii.attributes;if(!ti)return!1;for(var ei=0;ei0;){var ai=ni.shift(),li=ai.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);si===0&&li?(ai.split(":"),ri=li[1].slice(0,-1),ei[ri]=li[2]):ei[ri]+=ai,si+=Bt(ai)}for(var di in ei)Ft(ii,di,ei[di])}}function jt(ii){Ae(ii);for(var ti=0;tiQ.config.historyCacheSize;)ri.shift();for(;ri.length>0;)try{localStorage.setItem("htmx-history-cache",JSON.stringify(ri));break}catch(li){fe(re().body,"htmx:historyCacheError",{cause:li,cache:ri}),ri.shift()}}}function Yt(ii){if(!U())return null;ii=B(ii);for(var ti=E(localStorage.getItem("htmx-history-cache"))||[],ei=0;ei=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",ei);var ni=l(this.response);ni=ni.querySelector("[hx-history-elt],[data-hx-history-elt]")||ni;var ri=Zt(),si=T(ri),ai=Ve(this.response);if(ai){var li=C("title");li?li.innerHTML=ai:window.document.title=ai}Ue(ri,ni,si),nr(si.tasks),Jt=ii,ce(re().body,"htmx:historyRestore",{path:ii,cacheMiss:!0,serverResponse:this.response})}else fe(re().body,"htmx:historyCacheMissLoadError",ei)},ti.send()}function ar(ii){er(),ii=ii||location.pathname+location.search;var ti=Yt(ii);if(ti){var ei=l(ti.content),ni=Zt(),ri=T(ni);Ue(ni,ei,ri),nr(ri.tasks),document.title=ti.title,setTimeout(function(){window.scrollTo(0,ti.scroll)},0),Jt=ii,ce(re().body,"htmx:historyRestore",{path:ii,item:ti})}else Q.config.refreshOnHistoryMiss?window.location.reload(!0):ir(ii)}function or(ii){var ti=me(ii,"hx-indicator");return ti==null&&(ti=[ii]),oe(ti,function(ei){var ni=ae(ei);ni.requestCount=(ni.requestCount||0)+1,ei.classList.add.call(ei.classList,Q.config.requestClass)}),ti}function sr(ii){var ti=me(ii,"hx-disabled-elt");return ti==null&&(ti=[]),oe(ti,function(ei){var ni=ae(ei);ni.requestCount=(ni.requestCount||0)+1,ei.setAttribute("disabled","")}),ti}function lr(ii,ti){oe(ii,function(ei){var ni=ae(ei);ni.requestCount=(ni.requestCount||0)-1,ni.requestCount===0&&ei.classList.remove.call(ei.classList,Q.config.requestClass)}),oe(ti,function(ei){var ni=ae(ei);ni.requestCount=(ni.requestCount||0)-1,ni.requestCount===0&&ei.removeAttribute("disabled")})}function ur(ii,ti){for(var ei=0;ei=0}function wr(ii,ti){var ei=ti||ne(ii,"hx-swap"),ni={swapStyle:ae(ii).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(ii).boosted&&!br(ii)&&(ni.show="top"),ei){var ri=D(ei);if(ri.length>0)for(var si=0;si0?di.join(":"):null;ni.scroll=mi,ni.scrollTarget=hi}else if(ai.indexOf("show:")===0){var _i=ai.substr(5),di=_i.split(":"),Ei=di.pop(),hi=di.length>0?di.join(":"):null;ni.show=Ei,ni.showTarget=hi}else if(ai.indexOf("focus-scroll:")===0){var Ai=ai.substr(13);ni.focusScroll=Ai=="true"}else si==0?ni.swapStyle=ai:b("Unknown modifier in hx-swap: "+ai)}}return ni}function Sr(ii){return ne(ii,"hx-encoding")==="multipart/form-data"||h(ii,"form")&&ee(ii,"enctype")==="multipart/form-data"}function Er(ii,ti,ei){var ni=null;return R(ti,function(ri){ni==null&&(ni=ri.encodeParameters(ii,ei,ti))}),ni!=null?ni:Sr(ti)?mr(ei):pr(ei)}function T(ii){return{tasks:[],elts:[ii]}}function Cr(ii,ti){var ei=ii[0],ni=ii[ii.length-1];if(ti.scroll){var ri=null;ti.scrollTarget&&(ri=ue(ei,ti.scrollTarget)),ti.scroll==="top"&&(ei||ri)&&(ri=ri||ei,ri.scrollTop=0),ti.scroll==="bottom"&&(ni||ri)&&(ri=ri||ni,ri.scrollTop=ri.scrollHeight)}if(ti.show){var ri=null;if(ti.showTarget){var si=ti.showTarget;ti.showTarget==="window"&&(si="body"),ri=ue(ei,si)}ti.show==="top"&&(ei||ri)&&(ri=ri||ei,ri.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})),ti.show==="bottom"&&(ni||ri)&&(ri=ri||ni,ri.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior}))}}function Rr(ii,ti,ei,ni){if(ni==null&&(ni={}),ii==null)return ni;var ri=te(ii,ti);if(ri){var si=ri.trim(),ai=ei;if(si==="unset")return null;si.indexOf("javascript:")===0?(si=si.substr(11),ai=!0):si.indexOf("js:")===0&&(si=si.substr(3),ai=!0),si.indexOf("{")!==0&&(si="{"+si+"}");var li;ai?li=Tr(ii,function(){return Function("return ("+si+")")()},{}):li=E(si);for(var di in li)li.hasOwnProperty(di)&&ni[di]==null&&(ni[di]=li[di])}return Rr(u(ii),ti,ei,ni)}function Tr(ii,ti,ei){return Q.config.allowEval?ti():(fe(ii,"htmx:evalDisallowedError"),ei)}function Or(ii,ti){return Rr(ii,"hx-vars",!0,ti)}function qr(ii,ti){return Rr(ii,"hx-vals",!1,ti)}function Hr(ii){return le(Or(ii),qr(ii))}function Lr(ii,ti,ei){if(ei!==null)try{ii.setRequestHeader(ti,ei)}catch(ni){ii.setRequestHeader(ti,encodeURIComponent(ei)),ii.setRequestHeader(ti+"-URI-AutoEncoded","true")}}function Ar(ii){if(ii.responseURL&&typeof URL!="undefined")try{var ti=new URL(ii.responseURL);return ti.pathname+ti.search}catch(ei){fe(re().body,"htmx:badResponseUrl",{url:ii.responseURL})}}function O(ii,ti){return ti.test(ii.getAllResponseHeaders())}function Nr(ii,ti,ei){return ii=ii.toLowerCase(),ei?ei instanceof Element||I(ei,"String")?he(ii,ti,null,null,{targetOverride:p(ei),returnPromise:!0}):he(ii,ti,p(ei.source),ei.event,{handler:ei.handler,headers:ei.headers,values:ei.values,targetOverride:p(ei.target),swapOverride:ei.swap,select:ei.select,returnPromise:!0}):he(ii,ti,null,null,{returnPromise:!0})}function Ir(ii){for(var ti=[];ii;)ti.push(ii),ii=ii.parentElement;return ti}function kr(ii,ti,ei){var ni,ri;if(typeof URL=="function"){ri=new URL(ti,document.location.href);var si=document.location.origin;ni=si===ri.origin}else ri=ti,ni=g(ti,document.location.origin);return Q.config.selfRequestsOnly&&!ni?!1:ce(ii,"htmx:validateUrl",le({url:ri,sameHost:ni},ei))}function he(ii,ti,ei,ni,ri,si){var ai=null,li=null;if(ri=ri!=null?ri:{},ri.returnPromise&&typeof Promise!="undefined")var di=new Promise(function(qn,yn){ai=qn,li=yn});ei==null&&(ei=re().body);var mi=ri.handler||Mr,hi=ri.select||null;if(!se(ei))return ie(ai),di;var _i=ri.targetOverride||ye(ei);if(_i==null||_i==pe)return fe(ei,"htmx:targetError",{target:te(ei,"hx-target")}),ie(li),di;var Ei=ae(ei),Ai=Ei.lastButtonClicked;if(Ai){var ki=ee(Ai,"formaction");ki!=null&&(ti=ki);var Ti=ee(Ai,"formmethod");Ti!=null&&Ti.toLowerCase()!=="dialog"&&(ii=Ti)}var Fi=ne(ei,"hx-confirm");if(si===void 0){var en=function(qn){return he(ii,ti,ei,ni,ri,!!qn)},rn={target:_i,elt:ei,path:ti,verb:ii,triggeringEvent:ni,etc:ri,issueRequest:en,question:Fi};if(ce(ei,"htmx:confirm",rn)===!1)return ie(ai),di}var ln=ei,Yi=ne(ei,"hx-sync"),on=null,an=!1;if(Yi){var bn=Yi.split(":"),hn=bn[0].trim();if(hn==="this"?ln=xe(ei,"hx-sync"):ln=ue(ei,hn),Yi=(bn[1]||"drop").trim(),Ei=ae(ln),Yi==="drop"&&Ei.xhr&&Ei.abortable!==!0)return ie(ai),di;if(Yi==="abort"){if(Ei.xhr)return ie(ai),di;an=!0}else if(Yi==="replace")ce(ln,"htmx:abort");else if(Yi.indexOf("queue")===0){var pn=Yi.split(" ");on=(pn[1]||"last").trim()}}if(Ei.xhr)if(Ei.abortable)ce(ln,"htmx:abort");else{if(on==null){if(ni){var gn=ae(ni);gn&&gn.triggerSpec&&gn.triggerSpec.queue&&(on=gn.triggerSpec.queue)}on==null&&(on="last")}return Ei.queuedRequests==null&&(Ei.queuedRequests=[]),on==="first"&&Ei.queuedRequests.length===0?Ei.queuedRequests.push(function(){he(ii,ti,ei,ni,ri)}):on==="all"?Ei.queuedRequests.push(function(){he(ii,ti,ei,ni,ri)}):on==="last"&&(Ei.queuedRequests=[],Ei.queuedRequests.push(function(){he(ii,ti,ei,ni,ri)})),ie(ai),di}var un=new XMLHttpRequest;Ei.xhr=un,Ei.abortable=an;var vn=function(){if(Ei.xhr=null,Ei.abortable=!1,Ei.queuedRequests!=null&&Ei.queuedRequests.length>0){var qn=Ei.queuedRequests.shift();qn()}},Tn=ne(ei,"hx-prompt");if(Tn){var Ni=prompt(Tn);if(Ni===null||!ce(ei,"htmx:prompt",{prompt:Ni,target:_i}))return ie(ai),vn(),di}if(Fi&&!si&&!confirm(Fi))return ie(ai),vn(),di;var Hi=xr(ei,_i,Ni);ii!=="get"&&!Sr(ei)&&(Hi["Content-Type"]="application/x-www-form-urlencoded"),ri.headers&&(Hi=le(Hi,ri.headers));var Pi=dr(ei,ii),$i=Pi.errors,mn=Pi.values;ri.values&&(mn=le(mn,ri.values));var tn=Hr(ei),zi=le(mn,tn),Li=yr(zi,ei);Q.config.getCacheBusterParam&&ii==="get"&&(Li["org.htmx.cache-buster"]=ee(_i,"id")||"true"),(ti==null||ti==="")&&(ti=re().location.href);var ji=Rr(ei,"hx-request"),Ji=ae(ei).boosted,Vi=Q.config.methodsThatUseUrlParams.indexOf(ii)>=0,Ii={boosted:Ji,useUrlParams:Vi,parameters:Li,unfilteredParameters:zi,headers:Hi,target:_i,verb:ii,errors:$i,withCredentials:ri.credentials||ji.credentials||Q.config.withCredentials,timeout:ri.timeout||ji.timeout||Q.config.timeout,path:ti,triggeringEvent:ni};if(!ce(ei,"htmx:configRequest",Ii))return ie(ai),vn(),di;if(ti=Ii.path,ii=Ii.verb,Hi=Ii.headers,Li=Ii.parameters,$i=Ii.errors,Vi=Ii.useUrlParams,$i&&$i.length>0)return ce(ei,"htmx:validation:halted",Ii),ie(ai),vn(),di;var Sn=ti.split("#"),Yn=Sn[0],Fn=Sn[1],Bn=ti;if(Vi){Bn=Yn;var Gn=Object.keys(Li).length!==0;Gn&&(Bn.indexOf("?")<0?Bn+="?":Bn+="&",Bn+=pr(Li),Fn&&(Bn+="#"+Fn))}if(!kr(ei,Bn,Ii))return fe(ei,"htmx:invalidPath",Ii),ie(li),di;if(un.open(ii.toUpperCase(),Bn,!0),un.overrideMimeType("text/html"),un.withCredentials=Ii.withCredentials,un.timeout=Ii.timeout,!ji.noHeaders){for(var Qn in Hi)if(Hi.hasOwnProperty(Qn)){var Ts=Hi[Qn];Lr(un,Qn,Ts)}}var zn={xhr:un,target:_i,requestConfig:Ii,etc:ri,boosted:Ji,select:hi,pathInfo:{requestPath:ti,finalRequestPath:Bn,anchor:Fn}};if(un.onload=function(){try{var qn=Ir(ei);if(zn.pathInfo.responsePath=Ar(un),mi(ei,zn),lr(hs,ms),ce(ei,"htmx:afterRequest",zn),ce(ei,"htmx:afterOnLoad",zn),!se(ei)){for(var yn=null;qn.length>0&&yn==null;){var Kr=qn.shift();se(Kr)&&(yn=Kr)}yn&&(ce(yn,"htmx:afterRequest",zn),ce(yn,"htmx:afterOnLoad",zn))}ie(ai),vn()}catch(as){throw fe(ei,"htmx:onLoadError",le({error:as},zn)),as}},un.onerror=function(){lr(hs,ms),fe(ei,"htmx:afterRequest",zn),fe(ei,"htmx:sendError",zn),ie(li),vn()},un.onabort=function(){lr(hs,ms),fe(ei,"htmx:afterRequest",zn),fe(ei,"htmx:sendAbort",zn),ie(li),vn()},un.ontimeout=function(){lr(hs,ms),fe(ei,"htmx:afterRequest",zn),fe(ei,"htmx:timeout",zn),ie(li),vn()},!ce(ei,"htmx:beforeRequest",zn))return ie(ai),vn(),di;var hs=or(ei),ms=sr(ei);oe(["loadstart","loadend","progress","abort"],function(qn){oe([un,un.upload],function(yn){yn.addEventListener(qn,function(Kr){ce(ei,"htmx:xhr:"+qn,{lengthComputable:Kr.lengthComputable,loaded:Kr.loaded,total:Kr.total})})})}),ce(ei,"htmx:beforeSend",zn);var qs=Vi?null:Er(un,ei,Li);return un.send(qs),di}function Pr(ii,ti){var ei=ti.xhr,ni=null,ri=null;if(O(ei,/HX-Push:/i)?(ni=ei.getResponseHeader("HX-Push"),ri="push"):O(ei,/HX-Push-Url:/i)?(ni=ei.getResponseHeader("HX-Push-Url"),ri="push"):O(ei,/HX-Replace-Url:/i)&&(ni=ei.getResponseHeader("HX-Replace-Url"),ri="replace"),ni)return ni==="false"?{}:{type:ri,path:ni};var si=ti.pathInfo.finalRequestPath,ai=ti.pathInfo.responsePath,li=ne(ii,"hx-push-url"),di=ne(ii,"hx-replace-url"),mi=ae(ii).boosted,hi=null,_i=null;return li?(hi="push",_i=li):di?(hi="replace",_i=di):mi&&(hi="push",_i=ai||si),_i?_i==="false"?{}:(_i==="true"&&(_i=ai||si),ti.pathInfo.anchor&&_i.indexOf("#")===-1&&(_i=_i+"#"+ti.pathInfo.anchor),{type:hi,path:_i}):{}}function Mr(ii,ti){var ei=ti.xhr,ni=ti.target,ri=ti.etc,si=ti.requestConfig,ai=ti.select;if(ce(ii,"htmx:beforeOnLoad",ti)){if(O(ei,/HX-Trigger:/i)&&_e(ei,"HX-Trigger",ii),O(ei,/HX-Location:/i)){er();var li=ei.getResponseHeader("HX-Location"),di;li.indexOf("{")===0&&(di=E(li),li=di.path,delete di.path),Nr("GET",li,di).then(function(){tr(li)});return}var mi=O(ei,/HX-Refresh:/i)&&ei.getResponseHeader("HX-Refresh")==="true";if(O(ei,/HX-Redirect:/i)){location.href=ei.getResponseHeader("HX-Redirect"),mi&&location.reload();return}if(mi){location.reload();return}O(ei,/HX-Retarget:/i)&&(ei.getResponseHeader("HX-Retarget")==="this"?ti.target=ii:ti.target=ue(ii,ei.getResponseHeader("HX-Retarget")));var hi=Pr(ii,ti),_i=ei.status>=200&&ei.status<400&&ei.status!==204,Ei=ei.response,Ai=ei.status>=400,ki=Q.config.ignoreTitle,Ti=le({shouldSwap:_i,serverResponse:Ei,isError:Ai,ignoreTitle:ki},ti);if(ce(ni,"htmx:beforeSwap",Ti)){if(ni=Ti.target,Ei=Ti.serverResponse,Ai=Ti.isError,ki=Ti.ignoreTitle,ti.target=ni,ti.failed=Ai,ti.successful=!Ai,Ti.shouldSwap){ei.status===286&&at(ii),R(ii,function(hn){Ei=hn.transformResponse(Ei,ei,ii)}),hi.type&&er();var Fi=ri.swapOverride;O(ei,/HX-Reswap:/i)&&(Fi=ei.getResponseHeader("HX-Reswap"));var di=wr(ii,Fi);di.hasOwnProperty("ignoreTitle")&&(ki=di.ignoreTitle),ni.classList.add(Q.config.swappingClass);var en=null,rn=null,ln=function(){try{var hn=document.activeElement,pn={};try{pn={elt:hn,start:hn?hn.selectionStart:null,end:hn?hn.selectionEnd:null}}catch(Pi){}var gn;ai&&(gn=ai),O(ei,/HX-Reselect:/i)&&(gn=ei.getResponseHeader("HX-Reselect")),hi.type&&(ce(re().body,"htmx:beforeHistoryUpdate",le({history:hi},ti)),hi.type==="push"?(tr(hi.path),ce(re().body,"htmx:pushedIntoHistory",{path:hi.path})):(rr(hi.path),ce(re().body,"htmx:replacedInHistory",{path:hi.path})));var un=T(ni);if(je(di.swapStyle,ni,ii,Ei,un,gn),pn.elt&&!se(pn.elt)&&ee(pn.elt,"id")){var vn=document.getElementById(ee(pn.elt,"id")),Tn={preventScroll:di.focusScroll!==void 0?!di.focusScroll:!Q.config.defaultFocusScroll};if(vn){if(pn.start&&vn.setSelectionRange)try{vn.setSelectionRange(pn.start,pn.end)}catch(Pi){}vn.focus(Tn)}}if(ni.classList.remove(Q.config.swappingClass),oe(un.elts,function(Pi){Pi.classList&&Pi.classList.add(Q.config.settlingClass),ce(Pi,"htmx:afterSwap",ti)}),O(ei,/HX-Trigger-After-Swap:/i)){var Ni=ii;se(ii)||(Ni=re().body),_e(ei,"HX-Trigger-After-Swap",Ni)}var Hi=function(){if(oe(un.tasks,function(tn){tn.call()}),oe(un.elts,function(tn){tn.classList&&tn.classList.remove(Q.config.settlingClass),ce(tn,"htmx:afterSettle",ti)}),ti.pathInfo.anchor){var Pi=re().getElementById(ti.pathInfo.anchor);Pi&&Pi.scrollIntoView({block:"start",behavior:"auto"})}if(un.title&&!ki){var $i=C("title");$i?$i.innerHTML=un.title:window.document.title=un.title}if(Cr(un.elts,di),O(ei,/HX-Trigger-After-Settle:/i)){var mn=ii;se(ii)||(mn=re().body),_e(ei,"HX-Trigger-After-Settle",mn)}ie(en)};di.settleDelay>0?setTimeout(Hi,di.settleDelay):Hi()}catch(Pi){throw fe(ii,"htmx:swapError",ti),ie(rn),Pi}},Yi=Q.config.globalViewTransitions;if(di.hasOwnProperty("transition")&&(Yi=di.transition),Yi&&ce(ii,"htmx:beforeTransition",ti)&&typeof Promise!="undefined"&&document.startViewTransition){var on=new Promise(function(hn,pn){en=hn,rn=pn}),an=ln;ln=function(){document.startViewTransition(function(){return an(),on})}}di.swapDelay>0?setTimeout(ln,di.swapDelay):ln()}Ai&&fe(ii,"htmx:responseError",le({error:"Response Status Error Code "+ei.status+" from "+ti.pathInfo.requestPath},ti))}}}var Xr={};function Dr(){return{init:function(ii){return null},onEvent:function(ii,ti){return!0},transformResponse:function(ii,ti,ei){return ii},isInlineSwap:function(ii){return!1},handleSwap:function(ii,ti,ei,ni){return!1},encodeParameters:function(ii,ti,ei){return null}}}function Ur(ii,ti){ti.init&&ti.init(r),Xr[ii]=le(Dr(),ti)}function Br(ii){delete Xr[ii]}function Fr(ii,ti,ei){if(ii==null)return ti;ti==null&&(ti=[]),ei==null&&(ei=[]);var ni=te(ii,"hx-ext");return ni&&oe(ni.split(","),function(ri){if(ri=ri.replace(/ /g,""),ri.slice(0,7)=="ignore:"){ei.push(ri.slice(7));return}if(ei.indexOf(ri)<0){var si=Xr[ri];si&&ti.indexOf(si)<0&&ti.push(si)}}),Fr(u(ii),ti,ei)}var Vr=!1;re().addEventListener("DOMContentLoaded",function(){Vr=!0});function jr(ii){Vr||re().readyState==="complete"?ii():re().addEventListener("DOMContentLoaded",ii)}function _r(){Q.config.includeIndicatorStyles!==!1&&re().head.insertAdjacentHTML("beforeend","")}function zr(){var ii=re().querySelector('meta[name="htmx-config"]');return ii?E(ii.content):null}function $r(){var ii=zr();ii&&(Q.config=le(Q.config,ii))}return jr(function(){$r(),_r();var ii=re().body;zt(ii);var ti=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");ii.addEventListener("htmx:abort",function(ni){var ri=ni.target,si=ae(ri);si&&si.xhr&&si.xhr.abort()});let ei=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(ni){ni.state&&ni.state.htmx?(ar(),oe(ti,function(ri){ce(ri,"htmx:restored",{document:re(),triggerEvent:ce})})):ei&&ei(ni)},setTimeout(function(){ce(ii,"htmx:load",{}),ii=null},0)}),Q}()})});var El=Ya((Bc,zc)=>{(function(ii,ti){typeof Bc=="object"&&typeof zc!="undefined"?zc.exports=ti():typeof define=="function"&&define.amd?define(ti):(ii=typeof globalThis!="undefined"?globalThis:ii||self,ii.TomSelect=ti())})(Bc,function(){"use strict";function ii(fi,oi){fi.split(/\s+/).forEach(ci=>{oi(ci)})}class ti{constructor(){this._events=void 0,this._events={}}on(oi,ci){ii(oi,ui=>{let gi=this._events[ui]||[];gi.push(ci),this._events[ui]=gi})}off(oi,ci){var ui=arguments.length;if(ui===0){this._events={};return}ii(oi,gi=>{if(ui===1){delete this._events[gi];return}let bi=this._events[gi];bi!==void 0&&(bi.splice(bi.indexOf(ci),1),this._events[gi]=bi)})}trigger(oi,...ci){var ui=this;ii(oi,gi=>{let bi=ui._events[gi];bi!==void 0&&bi.forEach(yi=>{yi.apply(ui,ci)})})}}function ei(fi){return fi.plugins={},class extends fi{constructor(...oi){super(...oi),this.plugins={names:[],settings:{},requested:{},loaded:{}}}static define(oi,ci){fi.plugins[oi]={name:oi,fn:ci}}initializePlugins(oi){var ci,ui;let gi=this,bi=[];if(Array.isArray(oi))oi.forEach(yi=>{typeof yi=="string"?bi.push(yi):(gi.plugins.settings[yi.name]=yi.options,bi.push(yi.name))});else if(oi)for(ci in oi)oi.hasOwnProperty(ci)&&(gi.plugins.settings[ci]=oi[ci],bi.push(ci));for(;ui=bi.shift();)gi.require(ui)}loadPlugin(oi){var ci=this,ui=ci.plugins,gi=fi.plugins[oi];if(!fi.plugins.hasOwnProperty(oi))throw new Error('Unable to find "'+oi+'" plugin');ui.requested[oi]=!0,ui.loaded[oi]=gi.fn.apply(ci,[ci.plugins.settings[oi]||{}]),ui.names.push(oi)}require(oi){var ci=this,ui=ci.plugins;if(!ci.plugins.loaded.hasOwnProperty(oi)){if(ui.requested[oi])throw new Error('Plugin has circular dependency ("'+oi+'")');ci.loadPlugin(oi)}return ui.loaded[oi]}}}let ni=fi=>(fi=fi.filter(Boolean),fi.length<2?fi[0]||"":di(fi)==1?"["+fi.join("")+"]":"(?:"+fi.join("|")+")"),ri=fi=>{if(!ai(fi))return fi.join("");let oi="",ci=0,ui=()=>{ci>1&&(oi+="{"+ci+"}")};return fi.forEach((gi,bi)=>{if(gi===fi[bi-1]){ci++;return}ui(),oi+=gi,ci=1}),ui(),oi},si=fi=>{let oi=hi(fi);return ni(oi)},ai=fi=>new Set(fi).size!==fi.length,li=fi=>(fi+"").replace(/([\$\(\)\*\+\.\?\[\]\^\{\|\}\\])/gu,"\\$1"),di=fi=>fi.reduce((oi,ci)=>Math.max(oi,mi(ci)),0),mi=fi=>hi(fi).length,hi=fi=>Array.from(fi);let _i=fi=>{if(fi.length===1)return[[fi]];let oi=[],ci=fi.substring(1);return _i(ci).forEach(function(gi){let bi=gi.slice(0);bi[0]=fi.charAt(0)+bi[0],oi.push(bi),bi=gi.slice(0),bi.unshift(fi.charAt(0)),oi.push(bi)}),oi};let Ei=[[0,65535]],Ai="[\u0300-\u036F\xB7\u02BE\u02BC]",ki,Ti,Fi=3,en={},rn={"/":"\u2044\u2215",0:"\u07C0",a:"\u2C65\u0250\u0251",aa:"\uA733",ae:"\xE6\u01FD\u01E3",ao:"\uA735",au:"\uA737",av:"\uA739\uA73B",ay:"\uA73D",b:"\u0180\u0253\u0183",c:"\uA73F\u0188\u023C\u2184",d:"\u0111\u0257\u0256\u1D05\u018C\uABB7\u0501\u0266",e:"\u025B\u01DD\u1D07\u0247",f:"\uA77C\u0192",g:"\u01E5\u0260\uA7A1\u1D79\uA77F\u0262",h:"\u0127\u2C68\u2C76\u0265",i:"\u0268\u0131",j:"\u0249\u0237",k:"\u0199\u2C6A\uA741\uA743\uA745\uA7A3",l:"\u0142\u019A\u026B\u2C61\uA749\uA747\uA781\u026D",m:"\u0271\u026F\u03FB",n:"\uA7A5\u019E\u0272\uA791\u1D0E\u043B\u0509",o:"\xF8\u01FF\u0254\u0275\uA74B\uA74D\u1D11",oe:"\u0153",oi:"\u01A3",oo:"\uA74F",ou:"\u0223",p:"\u01A5\u1D7D\uA751\uA753\uA755\u03C1",q:"\uA757\uA759\u024B",r:"\u024D\u027D\uA75B\uA7A7\uA783",s:"\xDF\u023F\uA7A9\uA785\u0282",t:"\u0167\u01AD\u0288\u2C66\uA787",th:"\xFE",tz:"\uA729",u:"\u0289",v:"\u028B\uA75F\u028C",vy:"\uA761",w:"\u2C73",y:"\u01B4\u024F\u1EFF",z:"\u01B6\u0225\u0240\u2C6C\uA763",hv:"\u0195"};for(let fi in rn){let oi=rn[fi]||"";for(let ci=0;ci{ki===void 0&&(ki=gn(fi||Ei))},on=(fi,oi="NFKD")=>fi.normalize(oi),an=fi=>hi(fi).reduce((oi,ci)=>oi+bn(ci),""),bn=fi=>(fi=on(fi).toLowerCase().replace(ln,oi=>en[oi]||""),on(fi,"NFC"));function*hn(fi){for(let[oi,ci]of fi)for(let ui=oi;ui<=ci;ui++){let gi=String.fromCharCode(ui),bi=an(gi);bi!=gi.toLowerCase()&&(bi.length>Fi||bi.length!=0&&(yield{folded:bi,composed:gi,code_point:ui}))}}let pn=fi=>{let oi={},ci=(ui,gi)=>{let bi=oi[ui]||new Set,yi=new RegExp("^"+si(bi)+"$","iu");gi.match(yi)||(bi.add(li(gi)),oi[ui]=bi)};for(let ui of hn(fi))ci(ui.folded,ui.folded),ci(ui.folded,ui.composed);return oi},gn=fi=>{let oi=pn(fi),ci={},ui=[];for(let bi in oi){let yi=oi[bi];yi&&(ci[bi]=si(yi)),bi.length>1&&ui.push(li(bi))}ui.sort((bi,yi)=>yi.length-bi.length);let gi=ni(ui);return Ti=new RegExp("^"+gi,"u"),ci},un=(fi,oi=1)=>{let ci=0;return fi=fi.map(ui=>(ki[ui]&&(ci+=ui.length),ki[ui]||ui)),ci>=oi?ri(fi):""},vn=(fi,oi=1)=>(oi=Math.max(oi,fi.length-1),ni(_i(fi).map(ci=>un(ci,oi)))),Tn=(fi,oi=!0)=>{let ci=fi.length>1?1:0;return ni(fi.map(ui=>{let gi=[],bi=oi?ui.length():ui.length()-1;for(let yi=0;yi{for(let ci of oi){if(ci.start!=fi.start||ci.end!=fi.end||ci.substrs.join("")!==fi.substrs.join(""))continue;let ui=fi.parts,gi=yi=>{for(let Ci of ui){if(Ci.start===yi.start&&Ci.substr===yi.substr)return!1;if(!(yi.length==1||Ci.length==1)&&(yi.startCi.start||Ci.startyi.start))return!0}return!1};if(!(ci.parts.filter(gi).length>0))return!0}return!1};class Hi{constructor(){this.parts=[],this.substrs=[],this.start=0,this.end=0}add(oi){oi&&(this.parts.push(oi),this.substrs.push(oi.substr),this.start=Math.min(oi.start,this.start),this.end=Math.max(oi.end,this.end))}last(){return this.parts[this.parts.length-1]}length(){return this.parts.length}clone(oi,ci){let ui=new Hi,gi=JSON.parse(JSON.stringify(this.parts)),bi=gi.pop();for(let Oi of gi)ui.add(Oi);let yi=ci.substr.substring(0,oi-bi.start),Ci=yi.length;return ui.add({start:bi.start,end:bi.start+Ci,length:Ci,substr:yi}),ui}}let Pi=fi=>{Yi(),fi=an(fi);let oi="",ci=[new Hi];for(let ui=0;ui0){Oi=Oi.sort((Wi,Bi)=>Wi.length()-Bi.length());for(let Wi of Oi)Ni(Wi,ci)||ci.push(Wi);continue}if(ui>0&&Di.size==1&&!Di.has("3")){oi+=Tn(ci,!1);let Wi=new Hi,Bi=ci[0];Bi&&Wi.add(Bi.last()),ci=[Wi]}}return oi+=Tn(ci,!0),oi};let $i=(fi,oi)=>{if(fi)return fi[oi]},mn=(fi,oi)=>{if(fi){for(var ci,ui=oi.split(".");(ci=ui.shift())&&(fi=fi[ci]););return fi}},tn=(fi,oi,ci)=>{var ui,gi;return!fi||(fi=fi+"",oi.regex==null)||(gi=fi.search(oi.regex),gi===-1)?0:(ui=oi.string.length/fi.length,gi===0&&(ui+=.5),ui*ci)},zi=(fi,oi)=>{var ci=fi[oi];if(typeof ci=="function")return ci;ci&&!Array.isArray(ci)&&(fi[oi]=[ci])},Li=(fi,oi)=>{if(Array.isArray(fi))fi.forEach(oi);else for(var ci in fi)fi.hasOwnProperty(ci)&&oi(fi[ci],ci)},ji=(fi,oi)=>typeof fi=="number"&&typeof oi=="number"?fi>oi?1:fioi?1:oi>fi?-1:0);class Ji{constructor(oi,ci){this.items=void 0,this.settings=void 0,this.items=oi,this.settings=ci||{diacritics:!0}}tokenize(oi,ci,ui){if(!oi||!oi.length)return[];let gi=[],bi=oi.split(/\s+/);var yi;return ui&&(yi=new RegExp("^("+Object.keys(ui).map(li).join("|")+"):(.*)$")),bi.forEach(Ci=>{let Oi,Di=null,Wi=null;yi&&(Oi=Ci.match(yi))&&(Di=Oi[1],Ci=Oi[2]),Ci.length>0&&(this.settings.diacritics?Wi=Pi(Ci)||null:Wi=li(Ci),Wi&&ci&&(Wi="\\b"+Wi)),gi.push({string:Ci,regex:Wi?new RegExp(Wi,"iu"):null,field:Di})}),gi}getScoreFunction(oi,ci){var ui=this.prepareSearch(oi,ci);return this._getScoreFunction(ui)}_getScoreFunction(oi){let ci=oi.tokens,ui=ci.length;if(!ui)return function(){return 0};let gi=oi.options.fields,bi=oi.weights,yi=gi.length,Ci=oi.getAttrFn;if(!yi)return function(){return 1};let Oi=function(){return yi===1?function(Di,Wi){let Bi=gi[0].field;return tn(Ci(Wi,Bi),Di,bi[Bi]||1)}:function(Di,Wi){var Bi=0;if(Di.field){let Zi=Ci(Wi,Di.field);!Di.regex&&Zi?Bi+=1/yi:Bi+=tn(Zi,Di,1)}else Li(bi,(Zi,In)=>{Bi+=tn(Ci(Wi,In),Di,Zi)});return Bi/yi}}();return ui===1?function(Di){return Oi(ci[0],Di)}:oi.options.conjunction==="and"?function(Di){var Wi,Bi=0;for(let Zi of ci){if(Wi=Oi(Zi,Di),Wi<=0)return 0;Bi+=Wi}return Bi/ui}:function(Di){var Wi=0;return Li(ci,Bi=>{Wi+=Oi(Bi,Di)}),Wi/ui}}getSortFunction(oi,ci){var ui=this.prepareSearch(oi,ci);return this._getSortFunction(ui)}_getSortFunction(oi){var ci,ui=[];let gi=this,bi=oi.options,yi=!oi.query&&bi.sort_empty?bi.sort_empty:bi.sort;if(typeof yi=="function")return yi.bind(this);let Ci=function(Wi,Bi){return Wi==="$score"?Bi.score:oi.getAttrFn(gi.items[Bi.id],Wi)};if(yi)for(let Di of yi)(oi.query||Di.field!=="$score")&&ui.push(Di);if(oi.query){ci=!0;for(let Di of ui)if(Di.field==="$score"){ci=!1;break}ci&&ui.unshift({field:"$score",direction:"desc"})}else ui=ui.filter(Di=>Di.field!=="$score");return ui.length?function(Di,Wi){var Bi,Zi;for(let In of ui)if(Zi=In.field,Bi=(In.direction==="desc"?-1:1)*ji(Ci(Zi,Di),Ci(Zi,Wi)),Bi)return Bi;return 0}:null}prepareSearch(oi,ci){let ui={};var gi=Object.assign({},ci);if(zi(gi,"sort"),zi(gi,"sort_empty"),gi.fields){zi(gi,"fields");let bi=[];gi.fields.forEach(yi=>{typeof yi=="string"&&(yi={field:yi,weight:1}),bi.push(yi),ui[yi.field]="weight"in yi?yi.weight:1}),gi.fields=bi}return{options:gi,query:oi.toLowerCase().trim(),tokens:this.tokenize(oi,gi.respect_word_boundaries,ui),total:0,items:[],weights:ui,getAttrFn:gi.nesting?mn:$i}}search(oi,ci){var ui=this,gi,bi;bi=this.prepareSearch(oi,ci),ci=bi.options,oi=bi.query;let yi=ci.score||ui._getScoreFunction(bi);oi.length?Li(ui.items,(Oi,Di)=>{gi=yi(Oi),(ci.filter===!1||gi>0)&&bi.items.push({score:gi,id:Di})}):Li(ui.items,(Oi,Di)=>{bi.items.push({score:1,id:Di})});let Ci=ui._getSortFunction(bi);return Ci&&bi.items.sort(Ci),bi.total=bi.items.length,typeof ci.limit=="number"&&(bi.items=bi.items.slice(0,ci.limit)),bi}}let Vi=(fi,oi)=>{if(Array.isArray(fi))fi.forEach(oi);else for(var ci in fi)fi.hasOwnProperty(ci)&&oi(fi[ci],ci)},Ii=fi=>{if(fi.jquery)return fi[0];if(fi instanceof HTMLElement)return fi;if(Sn(fi)){var oi=document.createElement("template");return oi.innerHTML=fi.trim(),oi.content.firstChild}return document.querySelector(fi)},Sn=fi=>typeof fi=="string"&&fi.indexOf("<")>-1,Yn=fi=>fi.replace(/['"\\]/g,"\\$&"),Fn=(fi,oi)=>{var ci=document.createEvent("HTMLEvents");ci.initEvent(oi,!0,!1),fi.dispatchEvent(ci)},Bn=(fi,oi)=>{Object.assign(fi.style,oi)},Gn=(fi,...oi)=>{var ci=Ts(oi);fi=zn(fi),fi.map(ui=>{ci.map(gi=>{ui.classList.add(gi)})})},Qn=(fi,...oi)=>{var ci=Ts(oi);fi=zn(fi),fi.map(ui=>{ci.map(gi=>{ui.classList.remove(gi)})})},Ts=fi=>{var oi=[];return Vi(fi,ci=>{typeof ci=="string"&&(ci=ci.trim().split(/[\11\12\14\15\40]/)),Array.isArray(ci)&&(oi=oi.concat(ci))}),oi.filter(Boolean)},zn=fi=>(Array.isArray(fi)||(fi=[fi]),fi),hs=(fi,oi,ci)=>{if(!(ci&&!ci.contains(fi)))for(;fi&&fi.matches;){if(fi.matches(oi))return fi;fi=fi.parentNode}},ms=(fi,oi=0)=>oi>0?fi[fi.length-1]:fi[0],qs=fi=>Object.keys(fi).length===0,qn=(fi,oi)=>{if(!fi)return-1;oi=oi||fi.nodeName;for(var ci=0;fi=fi.previousElementSibling;)fi.matches(oi)&&ci++;return ci},yn=(fi,oi)=>{Vi(oi,(ci,ui)=>{ci==null?fi.removeAttribute(ui):fi.setAttribute(ui,""+ci)})},Kr=(fi,oi)=>{fi.parentNode&&fi.parentNode.replaceChild(oi,fi)},as=(fi,oi)=>{if(oi===null)return;if(typeof oi=="string"){if(!oi.length)return;oi=new RegExp(oi,"i")}let ci=bi=>{var yi=bi.data.match(oi);if(yi&&bi.data.length>0){var Ci=document.createElement("span");Ci.className="highlight";var Oi=bi.splitText(yi.index);Oi.splitText(yi[0].length);var Di=Oi.cloneNode(!0);return Ci.appendChild(Di),Kr(Oi,Ci),1}return 0},ui=bi=>{bi.nodeType===1&&bi.childNodes&&!/(script|style)/i.test(bi.tagName)&&(bi.className!=="highlight"||bi.tagName!=="SPAN")&&Array.from(bi.childNodes).forEach(yi=>{gi(yi)})},gi=bi=>bi.nodeType===3?ci(bi):(ui(bi),0);gi(fi)},Ws=fi=>{var oi=fi.querySelectorAll("span.highlight");Array.prototype.forEach.call(oi,function(ci){var ui=ci.parentNode;ui.replaceChild(ci.firstChild,ci),ui.normalize()})},po=65,Us=13,Ms=27,Ss=37,Ro=38,Ys=39,Po=40,ha=8,Wl=46,pa=9,Ho=(typeof navigator=="undefined"?!1:/Mac/.test(navigator.userAgent))?"metaKey":"ctrlKey";var ja={options:[],optgroups:[],plugins:[],delimiter:",",splitOn:null,persist:!0,diacritics:!0,create:null,createOnBlur:!1,createFilter:null,highlight:!0,openOnFocus:!0,shouldOpen:null,maxOptions:50,maxItems:null,hideSelected:null,duplicates:!1,addPrecedence:!1,selectOnTab:!1,preload:null,allowEmptyOption:!1,refreshThrottle:300,loadThrottle:300,loadingClass:"loading",dataAttr:null,optgroupField:"optgroup",valueField:"value",labelField:"text",disabledField:"disabled",optgroupLabelField:"label",optgroupValueField:"value",lockOptgroupOrder:!1,sortField:"$order",searchField:["text"],searchConjunction:"and",mode:null,wrapperClass:"ts-wrapper",controlClass:"ts-control",dropdownClass:"ts-dropdown",dropdownContentClass:"ts-dropdown-content",itemClass:"item",optionClass:"option",dropdownParent:null,controlInput:'',copyClassesToDropdown:!1,placeholder:null,hidePlaceholder:null,shouldLoad:function(fi){return fi.length>0},render:{}};let wn=fi=>typeof fi=="undefined"||fi===null?null:Gs(fi),Gs=fi=>typeof fi=="boolean"?fi?"1":"0":fi+"",Ks=fi=>(fi+"").replace(/&/g,"&").replace(//g,">").replace(/"/g,"""),Yl=(fi,oi)=>oi>0?setTimeout(fi,oi):(fi.call(null),null),Fo=(fi,oi)=>{var ci;return function(ui,gi){var bi=this;ci&&(bi.loading=Math.max(bi.loading-1,0),clearTimeout(ci)),ci=setTimeout(function(){ci=null,bi.loadedSearches[ui]=!0,fi.call(bi,ui,gi)},oi)}},$o=(fi,oi,ci)=>{var ui,gi=fi.trigger,bi={};fi.trigger=function(){var yi=arguments[0];if(oi.indexOf(yi)!==-1)bi[yi]=arguments;else return gi.apply(fi,arguments)},ci.apply(fi,[]),fi.trigger=gi;for(ui of oi)ui in bi&&gi.apply(fi,bi[ui])},Cs=fi=>({start:fi.selectionStart||0,length:(fi.selectionEnd||0)-(fi.selectionStart||0)}),Dn=(fi,oi=!1)=>{fi&&(fi.preventDefault(),oi&&fi.stopPropagation())},On=(fi,oi,ci,ui)=>{fi.addEventListener(oi,ci,ui)},pi=(fi,oi)=>{if(!oi||!oi[fi])return!1;var ci=(oi.altKey?1:0)+(oi.ctrlKey?1:0)+(oi.shiftKey?1:0)+(oi.metaKey?1:0);return ci===1},vi=(fi,oi)=>{let ci=fi.getAttribute("id");return ci||(fi.setAttribute("id",oi),oi)},wi=fi=>fi.replace(/[\\"']/g,"\\$&"),Si=(fi,oi)=>{oi&&fi.append(oi)};function Ri(fi,oi){var ci=Object.assign({},ja,oi),ui=ci.dataAttr,gi=ci.labelField,bi=ci.valueField,yi=ci.disabledField,Ci=ci.optgroupField,Oi=ci.optgroupLabelField,Di=ci.optgroupValueField,Wi=fi.tagName.toLowerCase(),Bi=fi.getAttribute("placeholder")||fi.getAttribute("data-placeholder");if(!Bi&&!ci.allowEmptyOption){let Cn=fi.querySelector('option[value=""]');Cn&&(Bi=Cn.textContent)}var Zi={placeholder:Bi,options:[],optgroups:[],items:[],maxItems:null},In=()=>{var Cn,Pn=Zi.options,kn={},dn=1;let jn=0;var Os=xn=>{var Ln=Object.assign({},xn.dataset),En=ui&&Ln[ui];return typeof En=="string"&&En.length&&(Ln=Object.assign(Ln,JSON.parse(En))),Ln},Va=(xn,Ln)=>{var En=wn(xn.value);if(En!=null&&!(!En&&!ci.allowEmptyOption)){if(kn.hasOwnProperty(En)){if(Ln){var Jr=kn[En][Ci];Jr?Array.isArray(Jr)?Jr.push(Ln):kn[En][Ci]=[Jr,Ln]:kn[En][Ci]=Ln}}else{var Nn=Os(xn);Nn[gi]=Nn[gi]||xn.textContent,Nn[bi]=Nn[bi]||En,Nn[yi]=Nn[yi]||xn.disabled,Nn[Ci]=Nn[Ci]||Ln,Nn.$option=xn,Nn.$order=Nn.$order||++jn,kn[En]=Nn,Pn.push(Nn)}xn.selected&&Zi.items.push(En)}},Bo=xn=>{var Ln,En;En=Os(xn),En[Oi]=En[Oi]||xn.getAttribute("label")||"",En[Di]=En[Di]||dn++,En[yi]=En[yi]||xn.disabled,En.$order=En.$order||++jn,Zi.optgroups.push(En),Ln=En[Di],Vi(xn.children,Jr=>{Va(Jr,Ln)})};Zi.maxItems=fi.hasAttribute("multiple")?null:1,Vi(fi.children,xn=>{Cn=xn.tagName.toLowerCase(),Cn==="optgroup"?Bo(xn):Cn==="option"&&Va(xn)})},Qi=()=>{let Cn=fi.getAttribute(ui);if(Cn)Zi.options=JSON.parse(Cn),Vi(Zi.options,kn=>{Zi.items.push(kn[bi])});else{var Pn=fi.value.trim()||"";if(!ci.allowEmptyOption&&!Pn.length)return;let kn=Pn.split(ci.delimiter);Vi(kn,dn=>{let jn={};jn[gi]=dn,jn[bi]=dn,Zi.options.push(jn)}),Zi.items=kn}};return Wi==="select"?In():Qi(),Object.assign({},ja,Zi,oi)}var qi=0;class nn extends ei(ti){constructor(oi,ci){super(),this.control_input=void 0,this.wrapper=void 0,this.dropdown=void 0,this.control=void 0,this.dropdown_content=void 0,this.focus_node=void 0,this.order=0,this.settings=void 0,this.input=void 0,this.tabIndex=void 0,this.is_select_tag=void 0,this.rtl=void 0,this.inputId=void 0,this._destroy=void 0,this.sifter=void 0,this.isOpen=!1,this.isDisabled=!1,this.isReadOnly=!1,this.isRequired=void 0,this.isInvalid=!1,this.isValid=!0,this.isLocked=!1,this.isFocused=!1,this.isInputHidden=!1,this.isSetup=!1,this.ignoreFocus=!1,this.ignoreHover=!1,this.hasOptions=!1,this.currentResults=void 0,this.lastValue="",this.caretPos=0,this.loading=0,this.loadedSearches={},this.activeOption=null,this.activeItems=[],this.optgroups={},this.options={},this.userOptions={},this.items=[],this.refreshTimeout=null,qi++;var ui,gi=Ii(oi);if(gi.tomselect)throw new Error("Tom Select already initialized on this element");gi.tomselect=this;var bi=window.getComputedStyle&&window.getComputedStyle(gi,null);ui=bi.getPropertyValue("direction");let yi=Ri(gi,ci);this.settings=yi,this.input=gi,this.tabIndex=gi.tabIndex||0,this.is_select_tag=gi.tagName.toLowerCase()==="select",this.rtl=/rtl/i.test(ui),this.inputId=vi(gi,"tomselect-"+qi),this.isRequired=gi.required,this.sifter=new Ji(this.options,{diacritics:yi.diacritics}),yi.mode=yi.mode||(yi.maxItems===1?"single":"multi"),typeof yi.hideSelected!="boolean"&&(yi.hideSelected=yi.mode==="multi"),typeof yi.hidePlaceholder!="boolean"&&(yi.hidePlaceholder=yi.mode!=="multi");var Ci=yi.createFilter;typeof Ci!="function"&&(typeof Ci=="string"&&(Ci=new RegExp(Ci)),Ci instanceof RegExp?yi.createFilter=Pn=>Ci.test(Pn):yi.createFilter=Pn=>this.settings.duplicates||!this.options[Pn]),this.initializePlugins(yi.plugins),this.setupCallbacks(),this.setupTemplates();let Oi=Ii("
"),Di=Ii("
"),Wi=this._render("dropdown"),Bi=Ii('
'),Zi=this.input.getAttribute("class")||"",In=yi.mode;var Qi;if(Gn(Oi,yi.wrapperClass,Zi,In),Gn(Di,yi.controlClass),Si(Oi,Di),Gn(Wi,yi.dropdownClass,In),yi.copyClassesToDropdown&&Gn(Wi,Zi),Gn(Bi,yi.dropdownContentClass),Si(Wi,Bi),Ii(yi.dropdownParent||Oi).appendChild(Wi),Sn(yi.controlInput)){Qi=Ii(yi.controlInput);var Cn=["autocorrect","autocapitalize","autocomplete","spellcheck"];Li(Cn,Pn=>{gi.getAttribute(Pn)&&yn(Qi,{[Pn]:gi.getAttribute(Pn)})}),Qi.tabIndex=-1,Di.appendChild(Qi),this.focus_node=Qi}else yi.controlInput?(Qi=Ii(yi.controlInput),this.focus_node=Qi):(Qi=Ii(""),this.focus_node=Di);this.wrapper=Oi,this.dropdown=Wi,this.dropdown_content=Bi,this.control=Di,this.control_input=Qi,this.setup()}setup(){let oi=this,ci=oi.settings,ui=oi.control_input,gi=oi.dropdown,bi=oi.dropdown_content,yi=oi.wrapper,Ci=oi.control,Oi=oi.input,Di=oi.focus_node,Wi={passive:!0},Bi=oi.inputId+"-ts-dropdown";yn(bi,{id:Bi}),yn(Di,{role:"combobox","aria-haspopup":"listbox","aria-expanded":"false","aria-controls":Bi});let Zi=vi(Di,oi.inputId+"-ts-control"),In="label[for='"+Yn(oi.inputId)+"']",Qi=document.querySelector(In),Cn=oi.focus.bind(oi);if(Qi){On(Qi,"click",Cn),yn(Qi,{for:Zi});let dn=vi(Qi,oi.inputId+"-ts-label");yn(Di,{"aria-labelledby":dn}),yn(bi,{"aria-labelledby":dn})}if(yi.style.width=Oi.style.width,oi.plugins.names.length){let dn="plugin-"+oi.plugins.names.join(" plugin-");Gn([yi,gi],dn)}(ci.maxItems===null||ci.maxItems>1)&&oi.is_select_tag&&yn(Oi,{multiple:"multiple"}),ci.placeholder&&yn(ui,{placeholder:ci.placeholder}),!ci.splitOn&&ci.delimiter&&(ci.splitOn=new RegExp("\\s*"+li(ci.delimiter)+"+\\s*")),ci.load&&ci.loadThrottle&&(ci.load=Fo(ci.load,ci.loadThrottle)),On(gi,"mousemove",()=>{oi.ignoreHover=!1}),On(gi,"mouseenter",dn=>{var jn=hs(dn.target,"[data-selectable]",gi);jn&&oi.onOptionHover(dn,jn)},{capture:!0}),On(gi,"click",dn=>{let jn=hs(dn.target,"[data-selectable]");jn&&(oi.onOptionSelect(dn,jn),Dn(dn,!0))}),On(Ci,"click",dn=>{var jn=hs(dn.target,"[data-ts-item]",Ci);if(jn&&oi.onItemSelect(dn,jn)){Dn(dn,!0);return}ui.value==""&&(oi.onClick(),Dn(dn,!0))}),On(Di,"keydown",dn=>oi.onKeyDown(dn)),On(ui,"keypress",dn=>oi.onKeyPress(dn)),On(ui,"input",dn=>oi.onInput(dn)),On(Di,"blur",dn=>oi.onBlur(dn)),On(Di,"focus",dn=>oi.onFocus(dn)),On(ui,"paste",dn=>oi.onPaste(dn));let Pn=dn=>{let jn=dn.composedPath()[0];if(!yi.contains(jn)&&!gi.contains(jn)){oi.isFocused&&oi.blur(),oi.inputState();return}jn==ui&&oi.isOpen?dn.stopPropagation():Dn(dn,!0)},kn=()=>{oi.isOpen&&oi.positionDropdown()};On(document,"mousedown",Pn),On(window,"scroll",kn,Wi),On(window,"resize",kn,Wi),this._destroy=()=>{document.removeEventListener("mousedown",Pn),window.removeEventListener("scroll",kn),window.removeEventListener("resize",kn),Qi&&Qi.removeEventListener("click",Cn)},this.revertSettings={innerHTML:Oi.innerHTML,tabIndex:Oi.tabIndex},Oi.tabIndex=-1,Oi.insertAdjacentElement("afterend",oi.wrapper),oi.sync(!1),ci.items=[],delete ci.optgroups,delete ci.options,On(Oi,"invalid",()=>{oi.isValid&&(oi.isValid=!1,oi.isInvalid=!0,oi.refreshState())}),oi.updateOriginalInput(),oi.refreshItems(),oi.close(!1),oi.inputState(),oi.isSetup=!0,Oi.disabled?oi.disable():Oi.readOnly?oi.setReadOnly(!0):oi.enable(),oi.on("change",this.onChange),Gn(Oi,"tomselected","ts-hidden-accessible"),oi.trigger("initialize"),ci.preload===!0&&oi.preload()}setupOptions(oi=[],ci=[]){this.addOptions(oi),Li(ci,ui=>{this.registerOptionGroup(ui)})}setupTemplates(){var oi=this,ci=oi.settings.labelField,ui=oi.settings.optgroupLabelField,gi={optgroup:bi=>{let yi=document.createElement("div");return yi.className="optgroup",yi.appendChild(bi.options),yi},optgroup_header:(bi,yi)=>'
'+yi(bi[ui])+"
",option:(bi,yi)=>"
"+yi(bi[ci])+"
",item:(bi,yi)=>"
"+yi(bi[ci])+"
",option_create:(bi,yi)=>'
Add '+yi(bi.input)+"
",no_results:()=>'
No results found
',loading:()=>'
',not_loading:()=>{},dropdown:()=>"
"};oi.settings.render=Object.assign({},gi,oi.settings.render)}setupCallbacks(){var oi,ci,ui={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",item_select:"onItemSelect",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur"};for(oi in ui)ci=this.settings[ui[oi]],ci&&this.on(oi,ci)}sync(oi=!0){let ci=this,ui=oi?Ri(ci.input,{delimiter:ci.settings.delimiter}):ci.settings;ci.setupOptions(ui.options,ui.optgroups),ci.setValue(ui.items||[],!0),ci.lastQuery=null}onClick(){var oi=this;if(oi.activeItems.length>0){oi.clearActiveItems(),oi.focus();return}oi.isFocused&&oi.isOpen?oi.blur():oi.focus()}onMouseDown(){}onChange(){Fn(this.input,"input"),Fn(this.input,"change")}onPaste(oi){var ci=this;if(ci.isInputHidden||ci.isLocked){Dn(oi);return}ci.settings.splitOn&&setTimeout(()=>{var ui=ci.inputValue();if(ui.match(ci.settings.splitOn)){var gi=ui.trim().split(ci.settings.splitOn);Li(gi,bi=>{wn(bi)&&(this.options[bi]?ci.addItem(bi):ci.createItem(bi))})}},0)}onKeyPress(oi){var ci=this;if(ci.isLocked){Dn(oi);return}var ui=String.fromCharCode(oi.keyCode||oi.which);if(ci.settings.create&&ci.settings.mode==="multi"&&ui===ci.settings.delimiter){ci.createItem(),Dn(oi);return}}onKeyDown(oi){var ci=this;if(ci.ignoreHover=!0,ci.isLocked){oi.keyCode!==pa&&Dn(oi);return}switch(oi.keyCode){case po:if(pi(Ho,oi)&&ci.control_input.value==""){Dn(oi),ci.selectAll();return}break;case Ms:ci.isOpen&&(Dn(oi,!0),ci.close()),ci.clearActiveItems();return;case Po:if(!ci.isOpen&&ci.hasOptions)ci.open();else if(ci.activeOption){let ui=ci.getAdjacent(ci.activeOption,1);ui&&ci.setActiveOption(ui)}Dn(oi);return;case Ro:if(ci.activeOption){let ui=ci.getAdjacent(ci.activeOption,-1);ui&&ci.setActiveOption(ui)}Dn(oi);return;case Us:ci.canSelect(ci.activeOption)?(ci.onOptionSelect(oi,ci.activeOption),Dn(oi)):(ci.settings.create&&ci.createItem()||document.activeElement==ci.control_input&&ci.isOpen)&&Dn(oi);return;case Ss:ci.advanceSelection(-1,oi);return;case Ys:ci.advanceSelection(1,oi);return;case pa:ci.settings.selectOnTab&&(ci.canSelect(ci.activeOption)&&(ci.onOptionSelect(oi,ci.activeOption),Dn(oi)),ci.settings.create&&ci.createItem()&&Dn(oi));return;case ha:case Wl:ci.deleteSelection(oi);return}ci.isInputHidden&&!pi(Ho,oi)&&Dn(oi)}onInput(oi){if(this.isLocked)return;let ci=this.inputValue();if(this.lastValue!==ci){if(this.lastValue=ci,ci==""){this._onInput();return}this.refreshTimeout&&clearTimeout(this.refreshTimeout),this.refreshTimeout=Yl(()=>{this.refreshTimeout=null,this._onInput()},this.settings.refreshThrottle)}}_onInput(){let oi=this.lastValue;this.settings.shouldLoad.call(this,oi)&&this.load(oi),this.refreshOptions(),this.trigger("type",oi)}onOptionHover(oi,ci){this.ignoreHover||this.setActiveOption(ci,!1)}onFocus(oi){var ci=this,ui=ci.isFocused;if(ci.isDisabled||ci.isReadOnly){ci.blur(),Dn(oi);return}ci.ignoreFocus||(ci.isFocused=!0,ci.settings.preload==="focus"&&ci.preload(),ui||ci.trigger("focus"),ci.activeItems.length||(ci.inputState(),ci.refreshOptions(!!ci.settings.openOnFocus)),ci.refreshState())}onBlur(oi){if(document.hasFocus()!==!1){var ci=this;if(ci.isFocused){ci.isFocused=!1,ci.ignoreFocus=!1;var ui=()=>{ci.close(),ci.setActiveItem(),ci.setCaret(ci.items.length),ci.trigger("blur")};ci.settings.create&&ci.settings.createOnBlur?ci.createItem(null,ui):ui()}}}onOptionSelect(oi,ci){var ui,gi=this;ci.parentElement&&ci.parentElement.matches("[data-disabled]")||(ci.classList.contains("create")?gi.createItem(null,()=>{gi.settings.closeAfterSelect&&gi.close()}):(ui=ci.dataset.value,typeof ui!="undefined"&&(gi.lastQuery=null,gi.addItem(ui),gi.settings.closeAfterSelect&&gi.close(),!gi.settings.hideSelected&&oi.type&&/click/.test(oi.type)&&gi.setActiveOption(ci))))}canSelect(oi){return!!(this.isOpen&&oi&&this.dropdown_content.contains(oi))}onItemSelect(oi,ci){var ui=this;return!ui.isLocked&&ui.settings.mode==="multi"?(Dn(oi),ui.setActiveItem(ci,oi),!0):!1}canLoad(oi){return!(!this.settings.load||this.loadedSearches.hasOwnProperty(oi))}load(oi){let ci=this;if(!ci.canLoad(oi))return;Gn(ci.wrapper,ci.settings.loadingClass),ci.loading++;let ui=ci.loadCallback.bind(ci);ci.settings.load.call(ci,oi,ui)}loadCallback(oi,ci){let ui=this;ui.loading=Math.max(ui.loading-1,0),ui.lastQuery=null,ui.clearActiveOption(),ui.setupOptions(oi,ci),ui.refreshOptions(ui.isFocused&&!ui.isInputHidden),ui.loading||Qn(ui.wrapper,ui.settings.loadingClass),ui.trigger("load",oi,ci)}preload(){var oi=this.wrapper.classList;oi.contains("preloaded")||(oi.add("preloaded"),this.load(""))}setTextboxValue(oi=""){var ci=this.control_input,ui=ci.value!==oi;ui&&(ci.value=oi,Fn(ci,"update"),this.lastValue=oi)}getValue(){return this.is_select_tag&&this.input.hasAttribute("multiple")?this.items:this.items.join(this.settings.delimiter)}setValue(oi,ci){var ui=ci?[]:["change"];$o(this,ui,()=>{this.clear(ci),this.addItems(oi,ci)})}setMaxItems(oi){oi===0&&(oi=null),this.settings.maxItems=oi,this.refreshState()}setActiveItem(oi,ci){var ui=this,gi,bi,yi,Ci,Oi,Di;if(ui.settings.mode!=="single"){if(!oi){ui.clearActiveItems(),ui.isFocused&&ui.inputState();return}if(gi=ci&&ci.type.toLowerCase(),gi==="click"&&pi("shiftKey",ci)&&ui.activeItems.length){for(Di=ui.getLastActive(),yi=Array.prototype.indexOf.call(ui.control.children,Di),Ci=Array.prototype.indexOf.call(ui.control.children,oi),yi>Ci&&(Oi=yi,yi=Ci,Ci=Oi),bi=yi;bi<=Ci;bi++)oi=ui.control.children[bi],ui.activeItems.indexOf(oi)===-1&&ui.setActiveItemClass(oi);Dn(ci)}else gi==="click"&&pi(Ho,ci)||gi==="keydown"&&pi("shiftKey",ci)?oi.classList.contains("active")?ui.removeActiveItem(oi):ui.setActiveItemClass(oi):(ui.clearActiveItems(),ui.setActiveItemClass(oi));ui.inputState(),ui.isFocused||ui.focus()}}setActiveItemClass(oi){let ci=this,ui=ci.control.querySelector(".last-active");ui&&Qn(ui,"last-active"),Gn(oi,"active last-active"),ci.trigger("item_select",oi),ci.activeItems.indexOf(oi)==-1&&ci.activeItems.push(oi)}removeActiveItem(oi){var ci=this.activeItems.indexOf(oi);this.activeItems.splice(ci,1),Qn(oi,"active")}clearActiveItems(){Qn(this.activeItems,"active"),this.activeItems=[]}setActiveOption(oi,ci=!0){oi!==this.activeOption&&(this.clearActiveOption(),oi&&(this.activeOption=oi,yn(this.focus_node,{"aria-activedescendant":oi.getAttribute("id")}),yn(oi,{"aria-selected":"true"}),Gn(oi,"active"),ci&&this.scrollToOption(oi)))}scrollToOption(oi,ci){if(!oi)return;let ui=this.dropdown_content,gi=ui.clientHeight,bi=ui.scrollTop||0,yi=oi.offsetHeight,Ci=oi.getBoundingClientRect().top-ui.getBoundingClientRect().top+bi;Ci+yi>gi+bi?this.scroll(Ci-gi+yi,ci):Ci{oi.setActiveItemClass(ui)}))}inputState(){var oi=this;oi.control.contains(oi.control_input)&&(yn(oi.control_input,{placeholder:oi.settings.placeholder}),oi.activeItems.length>0||!oi.isFocused&&oi.settings.hidePlaceholder&&oi.items.length>0?(oi.setTextboxValue(),oi.isInputHidden=!0):(oi.settings.hidePlaceholder&&oi.items.length>0&&yn(oi.control_input,{placeholder:""}),oi.isInputHidden=!1),oi.wrapper.classList.toggle("input-hidden",oi.isInputHidden))}inputValue(){return this.control_input.value.trim()}focus(){var oi=this;oi.isDisabled||oi.isReadOnly||(oi.ignoreFocus=!0,oi.control_input.offsetWidth?oi.control_input.focus():oi.focus_node.focus(),setTimeout(()=>{oi.ignoreFocus=!1,oi.onFocus()},0))}blur(){this.focus_node.blur(),this.onBlur()}getScoreFunction(oi){return this.sifter.getScoreFunction(oi,this.getSearchOptions())}getSearchOptions(){var oi=this.settings,ci=oi.sortField;return typeof oi.sortField=="string"&&(ci=[{field:oi.sortField}]),{fields:oi.searchField,conjunction:oi.searchConjunction,sort:ci,nesting:oi.nesting}}search(oi){var ci,ui,gi=this,bi=this.getSearchOptions();if(gi.settings.score&&(ui=gi.settings.score.call(gi,oi),typeof ui!="function"))throw new Error('Tom Select "score" setting must be a function that returns a function');return oi!==gi.lastQuery?(gi.lastQuery=oi,ci=gi.sifter.search(oi,Object.assign(bi,{score:ui})),gi.currentResults=ci):ci=Object.assign({},gi.currentResults),gi.settings.hideSelected&&(ci.items=ci.items.filter(yi=>{let Ci=wn(yi.id);return!(Ci&&gi.items.indexOf(Ci)!==-1)})),ci}refreshOptions(oi=!0){var ci,ui,gi,bi,yi,Ci,Oi,Di,Wi,Bi;let Zi={},In=[];var Qi=this,Cn=Qi.inputValue();let Pn=Cn===Qi.lastQuery||Cn==""&&Qi.lastQuery==null;var kn=Qi.search(Cn),dn=null,jn=Qi.settings.shouldOpen||!1,Os=Qi.dropdown_content;Pn&&(dn=Qi.activeOption,dn&&(Wi=dn.closest("[data-group]"))),bi=kn.items.length,typeof Qi.settings.maxOptions=="number"&&(bi=Math.min(bi,Qi.settings.maxOptions)),bi>0&&(jn=!0);let Va=(xn,Ln)=>{let En=Zi[xn];if(En!==void 0){let Nn=In[En];if(Nn!==void 0)return[En,Nn.fragment]}let Jr=document.createDocumentFragment();return En=In.length,In.push({fragment:Jr,order:Ln,optgroup:xn}),[En,Jr]};for(ci=0;ci0&&(Nn=Nn.cloneNode(!0),yn(Nn,{id:En.$id+"-clone-"+ui,"aria-selected":null}),Nn.classList.add("ts-cloned"),Qn(Nn,"active"),Qi.activeOption&&Qi.activeOption.dataset.value==Ln&&Wi&&Wi.dataset.group===yi.toString()&&(dn=Nn)),rh.appendChild(Nn),yi!=""&&(Zi[yi]=nh)}}Qi.settings.lockOptgroupOrder&&In.sort((xn,Ln)=>xn.order-Ln.order),Oi=document.createDocumentFragment(),Li(In,xn=>{let Ln=xn.fragment,En=xn.optgroup;if(!Ln||!Ln.children.length)return;let Jr=Qi.optgroups[En];if(Jr!==void 0){let Nn=document.createDocumentFragment(),qa=Qi.render("optgroup_header",Jr);Si(Nn,qa),Si(Nn,Ln);let Wa=Qi.render("optgroup",{group:Jr,options:Nn});Si(Oi,Wa)}else Si(Oi,Ln)}),Os.innerHTML="",Si(Os,Oi),Qi.settings.highlight&&(Ws(Os),kn.query.length&&kn.tokens.length&&Li(kn.tokens,xn=>{as(Os,xn.regex)}));var Bo=xn=>{let Ln=Qi.render(xn,{input:Cn});return Ln&&(jn=!0,Os.insertBefore(Ln,Os.firstChild)),Ln};if(Qi.loading?Bo("loading"):Qi.settings.shouldLoad.call(Qi,Cn)?kn.items.length===0&&Bo("no_results"):Bo("not_loading"),Di=Qi.canCreate(Cn),Di&&(Bi=Bo("option_create")),Qi.hasOptions=kn.items.length>0||Di,jn){if(kn.items.length>0){if(!dn&&Qi.settings.mode==="single"&&Qi.items[0]!=null&&(dn=Qi.getOption(Qi.items[0])),!Os.contains(dn)){let xn=0;Bi&&!Qi.settings.addPrecedence&&(xn=1),dn=Qi.selectable()[xn]}}else Bi&&(dn=Bi);oi&&!Qi.isOpen&&(Qi.open(),Qi.scrollToOption(dn,"auto")),Qi.setActiveOption(dn)}else Qi.clearActiveOption(),oi&&Qi.isOpen&&Qi.close(!1)}selectable(){return this.dropdown_content.querySelectorAll("[data-selectable]")}addOption(oi,ci=!1){let ui=this;if(Array.isArray(oi))return ui.addOptions(oi,ci),!1;let gi=wn(oi[ui.settings.valueField]);return gi===null||ui.options.hasOwnProperty(gi)?!1:(oi.$order=oi.$order||++ui.order,oi.$id=ui.inputId+"-opt-"+oi.$order,ui.options[gi]=oi,ui.lastQuery=null,ci&&(ui.userOptions[gi]=ci,ui.trigger("option_add",gi,oi)),gi)}addOptions(oi,ci=!1){Li(oi,ui=>{this.addOption(ui,ci)})}registerOption(oi){return this.addOption(oi)}registerOptionGroup(oi){var ci=wn(oi[this.settings.optgroupValueField]);return ci===null?!1:(oi.$order=oi.$order||++this.order,this.optgroups[ci]=oi,ci)}addOptionGroup(oi,ci){var ui;ci[this.settings.optgroupValueField]=oi,(ui=this.registerOptionGroup(ci))&&this.trigger("optgroup_add",ui,ci)}removeOptionGroup(oi){this.optgroups.hasOwnProperty(oi)&&(delete this.optgroups[oi],this.clearCache(),this.trigger("optgroup_remove",oi))}clearOptionGroups(){this.optgroups={},this.clearCache(),this.trigger("optgroup_clear")}updateOption(oi,ci){let ui=this;var gi,bi;let yi=wn(oi),Ci=wn(ci[ui.settings.valueField]);if(yi===null)return;let Oi=ui.options[yi];if(Oi==null)return;if(typeof Ci!="string")throw new Error("Value must be set in option data");let Di=ui.getOption(yi),Wi=ui.getItem(yi);if(ci.$order=ci.$order||Oi.$order,delete ui.options[yi],ui.uncacheValue(Ci),ui.options[Ci]=ci,Di){if(ui.dropdown_content.contains(Di)){let Bi=ui._render("option",ci);Kr(Di,Bi),ui.activeOption===Di&&ui.setActiveOption(Bi)}Di.remove()}Wi&&(bi=ui.items.indexOf(yi),bi!==-1&&ui.items.splice(bi,1,Ci),gi=ui._render("item",ci),Wi.classList.contains("active")&&Gn(gi,"active"),Kr(Wi,gi)),ui.lastQuery=null}removeOption(oi,ci){let ui=this;oi=Gs(oi),ui.uncacheValue(oi),delete ui.userOptions[oi],delete ui.options[oi],ui.lastQuery=null,ui.trigger("option_remove",oi),ui.removeItem(oi,ci)}clearOptions(oi){let ci=(oi||this.clearFilter).bind(this);this.loadedSearches={},this.userOptions={},this.clearCache();let ui={};Li(this.options,(gi,bi)=>{ci(gi,bi)&&(ui[bi]=gi)}),this.options=this.sifter.items=ui,this.lastQuery=null,this.trigger("option_clear")}clearFilter(oi,ci){return this.items.indexOf(ci)>=0}getOption(oi,ci=!1){let ui=wn(oi);if(ui===null)return null;let gi=this.options[ui];if(gi!=null){if(gi.$div)return gi.$div;if(ci)return this._render("option",gi)}return null}getAdjacent(oi,ci,ui="option"){var gi=this,bi;if(!oi)return null;ui=="item"?bi=gi.controlChildren():bi=gi.dropdown_content.querySelectorAll("[data-selectable]");for(let yi=0;yi0?bi[yi+1]:bi[yi-1];return null}getItem(oi){if(typeof oi=="object")return oi;var ci=wn(oi);return ci!==null?this.control.querySelector(`[data-value="${wi(ci)}"]`):null}addItems(oi,ci){var ui=this,gi=Array.isArray(oi)?oi:[oi];gi=gi.filter(yi=>ui.items.indexOf(yi)===-1);let bi=gi[gi.length-1];gi.forEach(yi=>{ui.isPending=yi!==bi,ui.addItem(yi,ci)})}addItem(oi,ci){var ui=ci?[]:["change","dropdown_close"];$o(this,ui,()=>{var gi,bi;let yi=this,Ci=yi.settings.mode,Oi=wn(oi);if(!(Oi&&yi.items.indexOf(Oi)!==-1&&(Ci==="single"&&yi.close(),Ci==="single"||!yi.settings.duplicates))&&!(Oi===null||!yi.options.hasOwnProperty(Oi))&&(Ci==="single"&&yi.clear(ci),!(Ci==="multi"&&yi.isFull()))){if(gi=yi._render("item",yi.options[Oi]),yi.control.contains(gi)&&(gi=gi.cloneNode(!0)),bi=yi.isFull(),yi.items.splice(yi.caretPos,0,Oi),yi.insertAtCaret(gi),yi.isSetup){if(!yi.isPending&&yi.settings.hideSelected){let Di=yi.getOption(Oi),Wi=yi.getAdjacent(Di,1);Wi&&yi.setActiveOption(Wi)}!yi.isPending&&!yi.settings.closeAfterSelect&&yi.refreshOptions(yi.isFocused&&Ci!=="single"),yi.settings.closeAfterSelect!=!1&&yi.isFull()?yi.close():yi.isPending||yi.positionDropdown(),yi.trigger("item_add",Oi,gi),yi.isPending||yi.updateOriginalInput({silent:ci})}(!yi.isPending||!bi&&yi.isFull())&&(yi.inputState(),yi.refreshState())}})}removeItem(oi=null,ci){let ui=this;if(oi=ui.getItem(oi),!oi)return;var gi,bi;let yi=oi.dataset.value;gi=qn(oi),oi.remove(),oi.classList.contains("active")&&(bi=ui.activeItems.indexOf(oi),ui.activeItems.splice(bi,1),Qn(oi,"active")),ui.items.splice(gi,1),ui.lastQuery=null,!ui.settings.persist&&ui.userOptions.hasOwnProperty(yi)&&ui.removeOption(yi,ci),gi{}){arguments.length===3&&(ci=arguments[2]),typeof ci!="function"&&(ci=()=>{});var ui=this,gi=ui.caretPos,bi;if(oi=oi||ui.inputValue(),!ui.canCreate(oi))return ci(),!1;ui.lock();var yi=!1,Ci=Oi=>{if(ui.unlock(),!Oi||typeof Oi!="object")return ci();var Di=wn(Oi[ui.settings.valueField]);if(typeof Di!="string")return ci();ui.setTextboxValue(),ui.addOption(Oi,!0),ui.setCaret(gi),ui.addItem(Di),ci(Oi),yi=!0};return typeof ui.settings.create=="function"?bi=ui.settings.create.call(this,oi,Ci):bi={[ui.settings.labelField]:oi,[ui.settings.valueField]:oi},yi||Ci(bi),!0}refreshItems(){var oi=this;oi.lastQuery=null,oi.isSetup&&oi.addItems(oi.items),oi.updateOriginalInput(),oi.refreshState()}refreshState(){let oi=this;oi.refreshValidityState();let ci=oi.isFull(),ui=oi.isLocked;oi.wrapper.classList.toggle("rtl",oi.rtl);let gi=oi.wrapper.classList;gi.toggle("focus",oi.isFocused),gi.toggle("disabled",oi.isDisabled),gi.toggle("readonly",oi.isReadOnly),gi.toggle("required",oi.isRequired),gi.toggle("invalid",!oi.isValid),gi.toggle("locked",ui),gi.toggle("full",ci),gi.toggle("input-active",oi.isFocused&&!oi.isInputHidden),gi.toggle("dropdown-active",oi.isOpen),gi.toggle("has-options",qs(oi.options)),gi.toggle("has-items",oi.items.length>0)}refreshValidityState(){var oi=this;oi.input.validity&&(oi.isValid=oi.input.validity.valid,oi.isInvalid=!oi.isValid)}isFull(){return this.settings.maxItems!==null&&this.items.length>=this.settings.maxItems}updateOriginalInput(oi={}){let ci=this;var ui,gi;let bi=ci.input.querySelector('option[value=""]');if(ci.is_select_tag){let Oi=function(Di,Wi,Bi){return Di||(Di=Ii('")),Di!=bi&&ci.input.append(Di),yi.push(Di),(Di!=bi||Ci>0)&&(Di.selected=!0),Di},yi=[],Ci=ci.input.querySelectorAll("option:checked").length;ci.input.querySelectorAll("option:checked").forEach(Di=>{Di.selected=!1}),ci.items.length==0&&ci.settings.mode=="single"?Oi(bi,"",""):ci.items.forEach(Di=>{if(ui=ci.options[Di],gi=ui[ci.settings.labelField]||"",yi.includes(ui.$option)){let Wi=ci.input.querySelector(`option[value="${wi(Di)}"]:not(:checked)`);Oi(Wi,Di,gi)}else ui.$option=Oi(ui.$option,Di,gi)})}else ci.input.value=ci.getValue();ci.isSetup&&(oi.silent||ci.trigger("change",ci.getValue()))}open(){var oi=this;oi.isLocked||oi.isOpen||oi.settings.mode==="multi"&&oi.isFull()||(oi.isOpen=!0,yn(oi.focus_node,{"aria-expanded":"true"}),oi.refreshState(),Bn(oi.dropdown,{visibility:"hidden",display:"block"}),oi.positionDropdown(),Bn(oi.dropdown,{visibility:"visible",display:"block"}),oi.focus(),oi.trigger("dropdown_open",oi.dropdown))}close(oi=!0){var ci=this,ui=ci.isOpen;oi&&(ci.setTextboxValue(),ci.settings.mode==="single"&&ci.items.length&&ci.inputState()),ci.isOpen=!1,yn(ci.focus_node,{"aria-expanded":"false"}),Bn(ci.dropdown,{display:"none"}),ci.settings.hideSelected&&ci.clearActiveOption(),ci.refreshState(),ui&&ci.trigger("dropdown_close",ci.dropdown)}positionDropdown(){if(this.settings.dropdownParent==="body"){var oi=this.control,ci=oi.getBoundingClientRect(),ui=oi.offsetHeight+ci.top+window.scrollY,gi=ci.left+window.scrollX;Bn(this.dropdown,{width:ci.width+"px",top:ui+"px",left:gi+"px"})}}clear(oi){var ci=this;if(ci.items.length){var ui=ci.controlChildren();Li(ui,gi=>{ci.removeItem(gi,!0)}),ci.inputState(),oi||ci.updateOriginalInput(),ci.trigger("clear")}}insertAtCaret(oi){let ci=this,ui=ci.caretPos,gi=ci.control;gi.insertBefore(oi,gi.children[ui]||null),ci.setCaret(ui+1)}deleteSelection(oi){var ci,ui,gi,bi,yi=this;ci=oi&&oi.keyCode===ha?-1:1,ui=Cs(yi.control_input);let Ci=[];if(yi.activeItems.length)bi=ms(yi.activeItems,ci),gi=qn(bi),ci>0&&gi++,Li(yi.activeItems,Oi=>Ci.push(Oi));else if((yi.isFocused||yi.settings.mode==="single")&&yi.items.length){let Oi=yi.controlChildren(),Di;ci<0&&ui.start===0&&ui.length===0?Di=Oi[yi.caretPos-1]:ci>0&&ui.start===yi.inputValue().length&&(Di=Oi[yi.caretPos]),Di!==void 0&&Ci.push(Di)}if(!yi.shouldDelete(Ci,oi))return!1;for(Dn(oi,!0),typeof gi!="undefined"&&yi.setCaret(gi);Ci.length;)yi.removeItem(Ci.pop());return yi.inputState(),yi.positionDropdown(),yi.refreshOptions(!1),!0}shouldDelete(oi,ci){let ui=oi.map(gi=>gi.dataset.value);return!(!ui.length||typeof this.settings.onDelete=="function"&&this.settings.onDelete(ui,ci)===!1)}advanceSelection(oi,ci){var ui,gi,bi=this;bi.rtl&&(oi*=-1),!bi.inputValue().length&&(pi(Ho,ci)||pi("shiftKey",ci)?(ui=bi.getLastActive(oi),ui?ui.classList.contains("active")?gi=bi.getAdjacent(ui,oi,"item"):gi=ui:oi>0?gi=bi.control_input.nextElementSibling:gi=bi.control_input.previousElementSibling,gi&&(gi.classList.contains("active")&&bi.removeActiveItem(ui),bi.setActiveItemClass(gi))):bi.moveCaret(oi))}moveCaret(oi){}getLastActive(oi){let ci=this.control.querySelector(".last-active");if(ci)return ci;var ui=this.control.querySelectorAll(".active");if(ui)return ms(ui,oi)}setCaret(oi){this.caretPos=this.items.length}controlChildren(){return Array.from(this.control.querySelectorAll("[data-ts-item]"))}lock(){this.setLocked(!0)}unlock(){this.setLocked(!1)}setLocked(oi=this.isReadOnly||this.isDisabled){this.isLocked=oi,this.refreshState()}disable(){this.setDisabled(!0),this.close()}enable(){this.setDisabled(!1)}setDisabled(oi){this.focus_node.tabIndex=oi?-1:this.tabIndex,this.isDisabled=oi,this.input.disabled=oi,this.control_input.disabled=oi,this.setLocked()}setReadOnly(oi){this.isReadOnly=oi,this.input.readOnly=oi,this.control_input.readOnly=oi,this.setLocked()}destroy(){var oi=this,ci=oi.revertSettings;oi.trigger("destroy"),oi.off(),oi.wrapper.remove(),oi.dropdown.remove(),oi.input.innerHTML=ci.innerHTML,oi.input.tabIndex=ci.tabIndex,Qn(oi.input,"tomselected","ts-hidden-accessible"),oi._destroy(),delete oi.input.tomselect}render(oi,ci){var ui,gi;let bi=this;if(typeof this.settings.render[oi]!="function"||(gi=bi.settings.render[oi].call(this,ci,Ks),!gi))return null;if(gi=Ii(gi),oi==="option"||oi==="option_create"?ci[bi.settings.disabledField]?yn(gi,{"aria-disabled":"true"}):yn(gi,{"data-selectable":""}):oi==="optgroup"&&(ui=ci.group[bi.settings.optgroupValueField],yn(gi,{"data-group":ui}),ci.group[bi.settings.disabledField]&&yn(gi,{"data-disabled":""})),oi==="option"||oi==="item"){let yi=Gs(ci[bi.settings.valueField]);yn(gi,{"data-value":yi}),oi==="item"?(Gn(gi,bi.settings.itemClass),yn(gi,{"data-ts-item":""})):(Gn(gi,bi.settings.optionClass),yn(gi,{role:"option",id:ci.$id}),ci.$div=gi,bi.options[yi]=ci)}return gi}_render(oi,ci){let ui=this.render(oi,ci);if(ui==null)throw"HTMLElement expected";return ui}clearCache(){Li(this.options,oi=>{oi.$div&&(oi.$div.remove(),delete oi.$div)})}uncacheValue(oi){let ci=this.getOption(oi);ci&&ci.remove()}canCreate(oi){return this.settings.create&&oi.length>0&&this.settings.createFilter.call(this,oi)}hook(oi,ci,ui){var gi=this,bi=gi[ci];gi[ci]=function(){var yi,Ci;return oi==="after"&&(yi=bi.apply(gi,arguments)),Ci=ui.apply(gi,arguments),oi==="instead"?Ci:(oi==="before"&&(yi=bi.apply(gi,arguments)),yi)}}}function Xi(){On(this.input,"change",()=>{this.sync()})}function _n(fi){var oi=this,ci=oi.onOptionSelect;oi.settings.hideSelected=!1;let ui=Object.assign({className:"tomselect-checkbox",checkedClassNames:void 0,uncheckedClassNames:void 0},fi);var gi=function(Ci,Oi){Oi?(Ci.checked=!0,ui.uncheckedClassNames&&Ci.classList.remove(...ui.uncheckedClassNames),ui.checkedClassNames&&Ci.classList.add(...ui.checkedClassNames)):(Ci.checked=!1,ui.checkedClassNames&&Ci.classList.remove(...ui.checkedClassNames),ui.uncheckedClassNames&&Ci.classList.add(...ui.uncheckedClassNames))},bi=function(Ci){setTimeout(()=>{var Oi=Ci.querySelector("input."+ui.className);Oi instanceof HTMLInputElement&&gi(Oi,Ci.classList.contains("selected"))},1)};oi.hook("after","setupTemplates",()=>{var yi=oi.settings.render.option;oi.settings.render.option=(Ci,Oi)=>{var Di=Ii(yi.call(oi,Ci,Oi)),Wi=document.createElement("input");ui.className&&Wi.classList.add(ui.className),Wi.addEventListener("click",function(Zi){Dn(Zi)}),Wi.type="checkbox";let Bi=wn(Ci[oi.settings.valueField]);return gi(Wi,!!(Bi&&oi.items.indexOf(Bi)>-1)),Di.prepend(Wi),Di}}),oi.on("item_remove",yi=>{var Ci=oi.getOption(yi);Ci&&(Ci.classList.remove("selected"),bi(Ci))}),oi.on("item_add",yi=>{var Ci=oi.getOption(yi);Ci&&bi(Ci)}),oi.hook("instead","onOptionSelect",(yi,Ci)=>{if(Ci.classList.contains("selected")){Ci.classList.remove("selected"),oi.removeItem(Ci.dataset.value),oi.refreshOptions(),Dn(yi,!0);return}ci.call(oi,yi,Ci),bi(Ci)})}function Ki(fi){let oi=this,ci=Object.assign({className:"clear-button",title:"Clear All",html:ui=>`
`},fi);oi.on("initialize",()=>{var ui=Ii(ci.html(ci));ui.addEventListener("click",gi=>{oi.isLocked||(oi.clear(),oi.settings.mode==="single"&&oi.settings.allowEmptyOption&&oi.addItem(""),gi.preventDefault(),gi.stopPropagation())}),oi.control.appendChild(ui)})}let fn=(fi,oi)=>{var ci;(ci=fi.parentNode)==null||ci.insertBefore(oi,fi.nextSibling)},Mn=(fi,oi)=>{var ci;(ci=fi.parentNode)==null||ci.insertBefore(oi,fi)},gs=(fi,oi)=>{do{var ci;if(oi=(ci=oi)==null?void 0:ci.previousElementSibling,fi==oi)return!0}while(oi&&oi.previousElementSibling);return!1};function is(){var fi=this;if(fi.settings.mode!=="multi")return;var oi=fi.lock,ci=fi.unlock;let ui=!0,gi;fi.hook("after","setupTemplates",()=>{var bi=fi.settings.render.item;fi.settings.render.item=(yi,Ci)=>{let Oi=Ii(bi.call(fi,yi,Ci));yn(Oi,{draggable:"true"});let Di=Cn=>{ui||Dn(Cn),Cn.stopPropagation()},Wi=Cn=>{gi=Oi,setTimeout(()=>{Oi.classList.add("ts-dragging")},0)},Bi=Cn=>{Cn.preventDefault(),Oi.classList.add("ts-drag-over"),In(Oi,gi)},Zi=()=>{Oi.classList.remove("ts-drag-over")},In=(Cn,Pn)=>{Pn!==void 0&&(gs(Pn,Oi)?fn(Cn,Pn):Mn(Cn,Pn))},Qi=()=>{var Cn;document.querySelectorAll(".ts-drag-over").forEach(kn=>kn.classList.remove("ts-drag-over")),(Cn=gi)==null||Cn.classList.remove("ts-dragging"),gi=void 0;var Pn=[];fi.control.querySelectorAll("[data-value]").forEach(kn=>{if(kn.dataset.value){let dn=kn.dataset.value;dn&&Pn.push(dn)}}),fi.setValue(Pn)};return On(Oi,"mousedown",Di),On(Oi,"dragstart",Wi),On(Oi,"dragenter",Bi),On(Oi,"dragover",Bi),On(Oi,"dragleave",Zi),On(Oi,"dragend",Qi),Oi}}),fi.hook("instead","lock",()=>(ui=!1,oi.call(fi))),fi.hook("instead","unlock",()=>(ui=!0,ci.call(fi)))}function mo(fi){let oi=this,ci=Object.assign({title:"Untitled",headerClass:"dropdown-header",titleRowClass:"dropdown-header-title",labelClass:"dropdown-header-label",closeClass:"dropdown-header-close",html:ui=>'
'+ui.title+'×
'},fi);oi.on("initialize",()=>{var ui=Ii(ci.html(ci)),gi=ui.querySelector("."+ci.closeClass);gi&&gi.addEventListener("click",bi=>{Dn(bi,!0),oi.close()}),oi.dropdown.insertBefore(ui,oi.dropdown.firstChild)})}function Qs(){var fi=this;fi.hook("instead","setCaret",oi=>{fi.settings.mode==="single"||!fi.control.contains(fi.control_input)?oi=fi.items.length:(oi=Math.max(0,Math.min(fi.items.length,oi)),oi!=fi.caretPos&&!fi.isPending&&fi.controlChildren().forEach((ci,ui)=>{ui{if(!fi.isFocused)return;let ci=fi.getLastActive(oi);if(ci){let ui=qn(ci);fi.setCaret(oi>0?ui+1:ui),fi.setActiveItem(),Qn(ci,"last-active")}else fi.setCaret(fi.caretPos+oi)})}function Gl(){let fi=this;fi.settings.shouldOpen=!0,fi.hook("before","setup",()=>{fi.focus_node=fi.control,Gn(fi.control_input,"dropdown-input");let oi=Ii('