Compare commits

...

4 Commits

Author SHA1 Message Date
Jeremy Stretch
970f2bd4ed Release v4.4.8
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-12-09 11:28:36 -05:00
Etienne.BRUNEL
a4ee323cb6 Add tenant filter on device components.
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-12-09 10:04:41 -05:00
Jason Novinger
17e5184a11 Fixes #20759: Group object types by app in permission form (#20931)
* Fixes #20759: Group object types by app in permission form

Modified the ObjectPermissionForm to use optgroups for organizing
object types by application. This shortens the display names (e.g.,
"permission" instead of "Authentication and Authorization | permission")
while maintaining clear organization through visual grouping.

Changes:
- Updated get_object_types_choices() to return nested optgroup structure
- Enhanced AvailableOptions and SelectedOptions widgets to handle optgroups
- Modified TypeScript moveOptions to preserve optgroup structure
- Added hover text showing full model names
- Styled optgroups with bold, padded labels

* Address PR feedback
2025-12-09 08:43:29 -05:00
github-actions
e1548bb290 Update source translation strings
Some checks are pending
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-12-09 05:02:02 +00:00
48 changed files with 8260 additions and 6919 deletions

View File

@@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.4.7
placeholder: v4.4.8
validations:
required: true
- type: dropdown

View File

@@ -27,7 +27,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.4.7
placeholder: v4.4.8
validations:
required: true
- type: dropdown

View File

@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "NetBox REST API",
"version": "4.4.7",
"version": "4.4.8",
"license": {
"name": "Apache v2 License"
}
@@ -27326,6 +27326,58 @@
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant__n",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id__n",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "type",
@@ -30798,6 +30850,58 @@
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant__n",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id__n",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "type",
@@ -34158,6 +34262,58 @@
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant__n",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id__n",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "updated_by_request",
@@ -46373,6 +46529,58 @@
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant__n",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id__n",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "type",
@@ -52303,6 +52511,58 @@
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant__n",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id__n",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tx_power",
@@ -58814,6 +59074,58 @@
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant__n",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id__n",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "updated_by_request",
@@ -66953,6 +67265,58 @@
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant__n",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id__n",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "updated_by_request",
@@ -78840,6 +79204,58 @@
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant__n",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id__n",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "type",
@@ -83976,6 +84392,58 @@
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant__n",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id__n",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "type",
@@ -96759,6 +97227,58 @@
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant__n",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Tenant (slug)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "tenant_id__n",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Tenant (ID)",
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "type",

View File

@@ -1,5 +1,22 @@
# NetBox v4.4
## v4.4.8 (2025-12-09)
### Enhancements
* [#20068](https://github.com/netbox-community/netbox/issues/20068) - Support the assignment of module type profile attributes via bulk import
* [#20914](https://github.com/netbox-community/netbox/issues/20914) - Enable filtering device components by tenant assigned to device
### Bug Fixes
* [#19918](https://github.com/netbox-community/netbox/issues/19918) - Fix support for `{module}` resolution of components of child modules
* [#20759](https://github.com/netbox-community/netbox/issues/20759) - Improve legibility of object types in permissions form
* [#20860](https://github.com/netbox-community/netbox/issues/20860) - Ensure user-provided changelog message is recorded when creating device components via the UI
* [#20878](https://github.com/netbox-community/netbox/issues/20878) - Use the active database connection when executing custom scripts
* [#20888](https://github.com/netbox-community/netbox/issues/20888) - Resolve warnings about non-decimal values for min/max latitude & longitude fields
---
## v4.4.7 (2025-11-25)
### Enhancements

View File

@@ -1626,6 +1626,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
choices=DeviceStatusChoices,
field_name='device__status',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__tenant',
queryset=Tenant.objects.all(),
label=_('Tenant (ID)'),
)
tenant = django_filters.ModelMultipleChoiceFilter(
field_name='device__tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label=_('Tenant (slug)'),
)
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -10,6 +10,7 @@ from ipam.models import ASN, VRF, VLANTranslationPolicy
from netbox.choices import *
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from tenancy.models import Tenant
from users.models import User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
@@ -120,6 +121,11 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Device role')
)
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
@@ -128,7 +134,8 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
'location_id': '$location_id',
'virtual_chassis_id': '$virtual_chassis_id',
'device_type_id': '$device_type_id',
'role_id': '$role_id'
'role_id': '$role_id',
'tenant_id': '$tenant_id'
},
label=_('Device')
)
@@ -1317,7 +1324,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
)
@@ -1341,7 +1349,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
@@ -1366,7 +1374,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
)
@@ -1385,7 +1394,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
@@ -1418,7 +1427,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'vdc_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'vdc_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
@@ -1539,7 +1549,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'occupied', name=_('Cable')),
)
@@ -1563,7 +1574,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'occupied', name=_('Cable')),
@@ -1587,7 +1598,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
FieldSet('name', 'label', 'position', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
)
@@ -1605,7 +1616,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
FieldSet('name', 'label', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
)
@@ -1622,7 +1633,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
)

View File

@@ -43,6 +43,13 @@ class DeviceComponentFilterSetTests:
params = {'device_status': ['active', 'planned']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DeviceComponentTemplateFilterSetTests:
@@ -3377,9 +3384,17 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -3389,6 +3404,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -3398,6 +3414,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -3617,9 +3634,17 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -3629,6 +3654,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -3638,6 +3664,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -3857,9 +3884,17 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -3869,6 +3904,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -3878,6 +3914,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -4111,9 +4148,17 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -4123,6 +4168,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -4132,6 +4178,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -4390,9 +4437,17 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
virtual_chassis = VirtualChassis(name='Virtual Chassis')
virtual_chassis.save()
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1A',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -4405,6 +4460,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 1B',
tenant=tenants[1],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -4417,6 +4473,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 2',
tenant=tenants[2],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -4426,6 +4483,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -5011,9 +5069,17 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -5023,6 +5089,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -5032,6 +5099,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -5302,9 +5370,17 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -5314,6 +5390,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -5323,6 +5400,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -5579,9 +5657,17 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -5591,6 +5677,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -5600,6 +5687,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -5752,9 +5840,17 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -5764,6 +5860,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -5773,6 +5870,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -30,7 +30,7 @@
"gridstack": "12.3.3",
"htmx.org": "2.0.8",
"query-string": "9.3.1",
"sass": "1.94.2",
"sass": "1.95.0",
"tom-select": "2.4.3",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"

View File

@@ -1,7 +1,7 @@
import { getElements } from '../util';
/**
* Move selected options from one select element to another.
* Move selected options from one select element to another, preserving optgroup structure.
*
* @param source Select Element
* @param target Select Element
@@ -9,14 +9,42 @@ import { getElements } from '../util';
function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
for (const option of Array.from(source.options)) {
if (option.selected) {
target.appendChild(option.cloneNode(true));
// Check if option is inside an optgroup
const parentOptgroup = option.parentElement as HTMLElement;
if (parentOptgroup.tagName === 'OPTGROUP') {
// Find or create matching optgroup in target
const groupLabel = parentOptgroup.getAttribute('label');
let targetOptgroup = Array.from(target.children).find(
child => child.tagName === 'OPTGROUP' && child.getAttribute('label') === groupLabel,
) as HTMLOptGroupElement;
if (!targetOptgroup) {
// Create new optgroup in target
targetOptgroup = document.createElement('optgroup');
targetOptgroup.setAttribute('label', groupLabel!);
target.appendChild(targetOptgroup);
}
// Move option to target optgroup
targetOptgroup.appendChild(option.cloneNode(true));
} else {
// Option is not in an optgroup, append directly
target.appendChild(option.cloneNode(true));
}
option.remove();
// Clean up empty optgroups in source
if (parentOptgroup.tagName === 'OPTGROUP' && parentOptgroup.children.length === 0) {
parentOptgroup.remove();
}
}
}
}
/**
* Move selected options of a select element up in order.
* Move selected options of a select element up in order, respecting optgroup boundaries.
*
* Adapted from:
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
@@ -27,14 +55,21 @@ function moveOptionUp(element: HTMLSelectElement): void {
for (let i = 1; i < options.length; i++) {
const option = options[i];
if (option.selected) {
element.removeChild(option);
element.insertBefore(option, element.options[i - 1]);
const parent = option.parentElement as HTMLElement;
const previousOption = element.options[i - 1];
const previousParent = previousOption.parentElement as HTMLElement;
// Only move if previous option is in the same parent (optgroup or select)
if (parent === previousParent) {
parent.removeChild(option);
parent.insertBefore(option, previousOption);
}
}
}
}
/**
* Move selected options of a select element down in order.
* Move selected options of a select element down in order, respecting optgroup boundaries.
*
* Adapted from:
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
@@ -43,12 +78,18 @@ function moveOptionUp(element: HTMLSelectElement): void {
function moveOptionDown(element: HTMLSelectElement): void {
const options = Array.from(element.options);
for (let i = options.length - 2; i >= 0; i--) {
let option = options[i];
const option = options[i];
if (option.selected) {
let next = element.options[i + 1];
option = element.removeChild(option);
next = element.replaceChild(option, next);
element.insertBefore(next, option);
const parent = option.parentElement as HTMLElement;
const nextOption = element.options[i + 1];
const nextParent = nextOption.parentElement as HTMLElement;
// Only move if next option is in the same parent (optgroup or select)
if (parent === nextParent) {
const optionClone = parent.removeChild(option);
const nextClone = parent.replaceChild(optionClone, nextOption);
parent.insertBefore(nextClone, optionClone);
}
}
}
}

View File

@@ -32,3 +32,17 @@ form.object-edit {
border: 1px solid $red;
}
}
// Make optgroup labels sticky when scrolling through select elements
select[multiple] {
optgroup {
position: sticky;
top: 0;
background-color: var(--bs-body-bg);
font-style: normal;
font-weight: bold;
}
option {
padding-left: 0.5rem;
}
}

View File

@@ -3190,10 +3190,10 @@ safe-regex-test@^1.1.0:
es-errors "^1.3.0"
is-regex "^1.2.1"
sass@1.94.2:
version "1.94.2"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.94.2.tgz#198511fc6fdd2fc0a71b8d1261735c12608d4ef3"
integrity sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==
sass@1.95.0:
version "1.95.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.95.0.tgz#3a3a4d4d954313ab50eaf16f6e2548a2f6ec0811"
integrity sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==
dependencies:
chokidar "^4.0.0"
immutable "^5.0.2"

View File

@@ -1,3 +1,3 @@
version: "4.4.7"
version: "4.4.8"
edition: "Community"
published: "2025-11-25"
published: "2025-12-09"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-06 05:01+0000\n"
"POT-Creation-Date: 2025-12-09 05:01+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -1470,7 +1470,7 @@ msgstr ""
#: netbox/dcim/models/device_components.py:488
#: netbox/dcim/models/device_components.py:1319
#: netbox/dcim/models/devices.py:580 netbox/dcim/models/devices.py:1202
#: netbox/dcim/models/modules.py:210 netbox/dcim/models/power.py:94
#: netbox/dcim/models/modules.py:209 netbox/dcim/models/power.py:94
#: netbox/dcim/models/racks.py:294 netbox/dcim/models/racks.py:677
#: netbox/dcim/models/sites.py:157 netbox/dcim/models/sites.py:281
#: netbox/ipam/models/ip.py:243 netbox/ipam/models/ip.py:529
@@ -1604,7 +1604,7 @@ msgstr ""
#: netbox/dcim/models/device_component_templates.py:44
#: netbox/dcim/models/device_components.py:53 netbox/dcim/models/devices.py:524
#: netbox/dcim/models/devices.py:1128 netbox/dcim/models/devices.py:1197
#: netbox/dcim/models/modules.py:32 netbox/dcim/models/power.py:38
#: netbox/dcim/models/modules.py:31 netbox/dcim/models/power.py:38
#: netbox/dcim/models/power.py:89 netbox/dcim/models/racks.py:263
#: netbox/dcim/models/sites.py:145 netbox/extras/models/configs.py:36
#: netbox/extras/models/configs.py:78 netbox/extras/models/configs.py:272
@@ -6625,7 +6625,7 @@ msgid "module bays"
msgstr ""
#: netbox/dcim/models/device_components.py:1217
#: netbox/dcim/models/modules.py:258
#: netbox/dcim/models/modules.py:257
msgid "A module bay cannot belong to a module installed within it."
msgstr ""
@@ -6661,14 +6661,14 @@ msgid "inventory item roles"
msgstr ""
#: netbox/dcim/models/device_components.py:1346
#: netbox/dcim/models/devices.py:533 netbox/dcim/models/modules.py:218
#: netbox/dcim/models/devices.py:533 netbox/dcim/models/modules.py:217
#: netbox/dcim/models/racks.py:310
#: netbox/virtualization/models/virtualmachines.py:125
msgid "serial number"
msgstr ""
#: netbox/dcim/models/device_components.py:1354
#: netbox/dcim/models/devices.py:541 netbox/dcim/models/modules.py:225
#: netbox/dcim/models/devices.py:541 netbox/dcim/models/modules.py:224
#: netbox/dcim/models/racks.py:317
msgid "asset tag"
msgstr ""
@@ -6717,7 +6717,7 @@ msgstr ""
msgid "manufacturers"
msgstr ""
#: netbox/dcim/models/devices.py:85 netbox/dcim/models/modules.py:74
#: netbox/dcim/models/devices.py:85 netbox/dcim/models/modules.py:73
#: netbox/dcim/models/racks.py:139
msgid "model"
msgstr ""
@@ -6726,11 +6726,11 @@ msgstr ""
msgid "default platform"
msgstr ""
#: netbox/dcim/models/devices.py:101 netbox/dcim/models/modules.py:78
#: netbox/dcim/models/devices.py:101 netbox/dcim/models/modules.py:77
msgid "part number"
msgstr ""
#: netbox/dcim/models/devices.py:104 netbox/dcim/models/modules.py:81
#: netbox/dcim/models/devices.py:104 netbox/dcim/models/modules.py:80
msgid "Discrete part number (optional)"
msgstr ""
@@ -6765,7 +6765,7 @@ msgid ""
msgstr ""
#: netbox/dcim/models/devices.py:132 netbox/dcim/models/devices.py:586
#: netbox/dcim/models/modules.py:84 netbox/dcim/models/racks.py:321
#: netbox/dcim/models/modules.py:83 netbox/dcim/models/racks.py:321
msgid "airflow"
msgstr ""
@@ -6853,7 +6853,7 @@ msgstr ""
msgid "Chassis serial number, assigned by the manufacturer"
msgstr ""
#: netbox/dcim/models/devices.py:542 netbox/dcim/models/modules.py:226
#: netbox/dcim/models/devices.py:542 netbox/dcim/models/modules.py:225
msgid "A unique tag used to identify this device"
msgstr ""
@@ -7106,44 +7106,44 @@ msgstr ""
msgid "Wireless role may be set only on wireless interfaces."
msgstr ""
#: netbox/dcim/models/modules.py:40 netbox/extras/models/configs.py:49
#: netbox/dcim/models/modules.py:39 netbox/extras/models/configs.py:49
msgid "schema"
msgstr ""
#: netbox/dcim/models/modules.py:47
#: netbox/dcim/models/modules.py:46
msgid "module type profile"
msgstr ""
#: netbox/dcim/models/modules.py:48
#: netbox/dcim/models/modules.py:47
msgid "module type profiles"
msgstr ""
#: netbox/dcim/models/modules.py:93
#: netbox/dcim/models/modules.py:92
msgid "attributes"
msgstr ""
#: netbox/dcim/models/modules.py:109
#: netbox/dcim/models/modules.py:108
msgid "module type"
msgstr ""
#: netbox/dcim/models/modules.py:110
#: netbox/dcim/models/modules.py:109
msgid "module types"
msgstr ""
#: netbox/dcim/models/modules.py:140
#: netbox/dcim/models/modules.py:139
#, python-brace-format
msgid "Invalid schema: {error}"
msgstr ""
#: netbox/dcim/models/modules.py:233
#: netbox/dcim/models/modules.py:232
msgid "module"
msgstr ""
#: netbox/dcim/models/modules.py:234
#: netbox/dcim/models/modules.py:233
msgid "modules"
msgstr ""
#: netbox/dcim/models/modules.py:247
#: netbox/dcim/models/modules.py:246
#, python-brace-format
msgid ""
"Module must be installed within a module bay belonging to the assigned "

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
import json
from collections import defaultdict
from django import forms
from django.apps import apps
from django.conf import settings
from django.contrib.auth import password_validation
from django.contrib.postgres.forms import SimpleArrayField
@@ -21,6 +23,7 @@ from utilities.forms.fields import (
DynamicModelMultipleChoiceField,
JSONField,
)
from utilities.string import title
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
from utilities.permissions import qs_filter_from_constraints
@@ -283,10 +286,24 @@ class GroupForm(forms.ModelForm):
def get_object_types_choices():
return [
(ot.pk, str(ot))
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model')
]
"""
Generate choices for object types grouped by app label using optgroups.
Returns nested structure: [(app_label, [(id, model_name), ...]), ...]
"""
app_label_map = {
app_config.label: app_config.verbose_name
for app_config in apps.get_app_configs()
}
choices_by_app = defaultdict(list)
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model'):
app_label = app_label_map.get(ot.app_label, ot.app_label)
model_class = ot.model_class()
model_name = model_class._meta.verbose_name if model_class else ot.model
choices_by_app[app_label].append((ot.pk, title(model_name)))
return list(choices_by_app.items())
class ObjectPermissionForm(forms.ModelForm):

View File

@@ -66,17 +66,45 @@ class SelectWithPK(forms.Select):
option_template_name = 'widgets/select_option_with_pk.html'
class AvailableOptions(forms.SelectMultiple):
class SelectMultipleBase(forms.SelectMultiple):
"""
Base class for select widgets that filter choices based on selected values.
Subclasses should set `include_selected` to control filtering behavior.
"""
include_selected = False
def optgroups(self, name, value, attrs=None):
filtered_choices = []
include_selected = self.include_selected
for choice in self.choices:
if isinstance(choice[1], (list, tuple)): # optgroup
group_label, group_choices = choice
filtered_group = [
c for c in group_choices if (str(c[0]) in value) == include_selected
]
if filtered_group: # Only include optgroup if it has choices left
filtered_choices.append((group_label, filtered_group))
else: # option, e.g. flat choice
if (str(choice[0]) in value) == include_selected:
filtered_choices.append(choice)
self.choices = filtered_choices
value = [] # Clear selected choices
return super().optgroups(name, value, attrs)
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
option = super().create_option(name, value, label, selected, index, subindex, attrs)
option['attrs']['title'] = label # Add title attribute to show full text on hover
return option
class AvailableOptions(SelectMultipleBase):
"""
Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
will be empty.) Employed by SplitMultiSelectWidget.
"""
def optgroups(self, name, value, attrs=None):
self.choices = [
choice for choice in self.choices if str(choice[0]) not in value
]
value = [] # Clear selected choices
return super().optgroups(name, value, attrs)
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
@@ -87,17 +115,12 @@ class AvailableOptions(forms.SelectMultiple):
return context
class SelectedOptions(forms.SelectMultiple):
class SelectedOptions(SelectMultipleBase):
"""
Renders a <select multiple=true> including only choices that have _not_ been selected. (For unbound fields, this
will include _all_ choices.) Employed by SplitMultiSelectWidget.
"""
def optgroups(self, name, value, attrs=None):
self.choices = [
choice for choice in self.choices if str(choice[0]) in value
]
value = [] # Clear selected choices
return super().optgroups(name, value, attrs)
include_selected = True
class SplitMultiSelectWidget(forms.MultiWidget):

View File

@@ -7,6 +7,7 @@ from utilities.forms.bulk_import import BulkImportForm
from utilities.forms.fields.csv import CSVSelectWidget
from utilities.forms.forms import BulkRenameForm
from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
from utilities.forms.widgets.select import AvailableOptions, SelectedOptions
class ExpandIPAddress(TestCase):
@@ -481,3 +482,71 @@ class CSVSelectWidgetTest(TestCase):
widget = CSVSelectWidget()
data = {'test_field': 'valid_value'}
self.assertFalse(widget.value_omitted_from_data(data, {}, 'test_field'))
class SelectMultipleWidgetTest(TestCase):
"""
Validate filtering behavior of AvailableOptions and SelectedOptions widgets.
"""
def test_available_options_flat_choices(self):
"""AvailableOptions should exclude selected values from flat choices"""
widget = AvailableOptions(choices=[
(1, 'Option 1'),
(2, 'Option 2'),
(3, 'Option 3'),
])
widget.optgroups('test', ['2'], None)
self.assertEqual(len(widget.choices), 2)
self.assertEqual(widget.choices[0], (1, 'Option 1'))
self.assertEqual(widget.choices[1], (3, 'Option 3'))
def test_available_options_optgroups(self):
"""AvailableOptions should exclude selected values from optgroups"""
widget = AvailableOptions(choices=[
('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
])
# Select options 2 and 3
widget.optgroups('test', ['2', '3'], None)
# Should have 2 groups with filtered choices
self.assertEqual(len(widget.choices), 2)
self.assertEqual(widget.choices[0][0], 'Group A')
self.assertEqual(widget.choices[0][1], [(1, 'Option 1')])
self.assertEqual(widget.choices[1][0], 'Group B')
self.assertEqual(widget.choices[1][1], [(4, 'Option 4')])
def test_selected_options_flat_choices(self):
"""SelectedOptions should include only selected values from flat choices"""
widget = SelectedOptions(choices=[
(1, 'Option 1'),
(2, 'Option 2'),
(3, 'Option 3'),
])
# Select option 2
widget.optgroups('test', ['2'], None)
# Should only have option 2
self.assertEqual(len(widget.choices), 1)
self.assertEqual(widget.choices[0], (2, 'Option 2'))
def test_selected_options_optgroups(self):
"""SelectedOptions should include only selected values from optgroups"""
widget = SelectedOptions(choices=[
('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
])
# Select options 2 and 3
widget.optgroups('test', ['2', '3'], None)
# Should have 2 groups with only selected choices
self.assertEqual(len(widget.choices), 2)
self.assertEqual(widget.choices[0][0], 'Group A')
self.assertEqual(widget.choices[0][1], [(2, 'Option 2')])
self.assertEqual(widget.choices[1][0], 'Group B')
self.assertEqual(widget.choices[1][1], [(3, 'Option 3')])

View File

@@ -1,10 +1,10 @@
colorama==0.4.6
Django==5.2.8
Django==5.2.9
django-cors-headers==4.9.0
django-debug-toolbar==6.1.0
django-filter==25.2
django-graphiql-debug-toolbar==0.2.0
django-htmx==1.26.0
django-htmx==1.27.0
django-mptt==0.17.0
django-pglocks==1.0.4
django-prometheus==2.4.1
@@ -14,30 +14,30 @@ django-rq==3.2.1
django-storages==1.14.6
django-tables2==2.8.0
django-taggit==6.1.0
django-timezone-field==7.1
django-timezone-field==7.2.1
djangorestframework==3.16.1
drf-spectacular==0.29.0
drf-spectacular-sidecar==2025.10.1
drf-spectacular-sidecar==2025.12.1
feedparser==6.0.12
gunicorn==23.0.0
Jinja2==3.1.6
jsonschema==4.25.1
Markdown==3.10
mkdocs-material==9.7.0
mkdocstrings==0.30.1
mkdocstrings-python==1.19.0
mkdocstrings==1.0.0
mkdocstrings-python==2.0.1
netaddr==1.3.0
nh3==0.3.2
Pillow==12.0.0
psycopg[c,pool]==3.2.13
psycopg[c,pool]==3.3.2
PyYAML==6.0.3
requests==2.32.5
rq==2.6.1
social-auth-app-django==5.6.0
social-auth-core==4.8.1
sorl-thumbnail==12.11.0
strawberry-graphql==0.287.0
strawberry-graphql-django==0.67.2
strawberry-graphql==0.287.2
strawberry-graphql-django==0.70.1
svgwrite==1.4.3
tablib==3.9.0
tzdata==2025.2