From 26fb733b3a2fbadb2234e5eba3693f136e128583 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 27 Aug 2025 13:13:53 -0700 Subject: [PATCH] Merge main into feature --- .../ISSUE_TEMPLATE/01-feature_request.yaml | 2 +- .github/ISSUE_TEMPLATE/02-bug_report.yaml | 2 +- docs/administration/error-reporting.md | 2 +- docs/reference/conditions.md | 2 +- docs/release-notes/version-4.3.md | 28 ++ netbox/core/api/serializers_/tasks.py | 14 +- netbox/dcim/api/serializers_/roles.py | 8 +- netbox/dcim/api/views.py | 14 +- netbox/dcim/filtersets.py | 17 +- netbox/dcim/forms/bulk_create.py | 7 +- netbox/dcim/forms/connections.py | 7 +- netbox/dcim/forms/model_forms.py | 2 + netbox/dcim/tests/test_filtersets.py | 16 + netbox/dcim/views.py | 15 +- netbox/extras/lookups.py | 28 +- netbox/extras/models/customfields.py | 10 +- netbox/extras/models/notifications.py | 13 +- netbox/extras/tests/test_customfields.py | 12 +- netbox/ipam/views.py | 20 +- netbox/netbox/filtersets.py | 9 +- netbox/netbox/views/misc.py | 4 +- netbox/project-static/dist/netbox.css | Bin 555502 -> 557097 bytes netbox/project-static/dist/netbox.js | Bin 383021 -> 382999 bytes netbox/project-static/dist/netbox.js.map | Bin 1773830 -> 1773948 bytes netbox/project-static/dist/rack_elevation.css | Bin 1514 -> 1512 bytes netbox/project-static/package.json | 4 +- .../project-static/styles/custom/_code.scss | 12 +- .../styles/overrides/_tomselect.scss | 6 + .../styles/svg/rack_elevation.scss | 6 +- netbox/project-static/yarn.lock | 13 +- .../templates/inc/missing_prerequisites.html | 2 +- .../templates/inc/panels/related_objects.html | 29 +- netbox/translations/cs/LC_MESSAGES/django.mo | Bin 248109 -> 248396 bytes netbox/translations/cs/LC_MESSAGES/django.po | 262 +++++++------- netbox/translations/da/LC_MESSAGES/django.mo | Bin 240479 -> 240771 bytes netbox/translations/da/LC_MESSAGES/django.po | 258 +++++++------- netbox/translations/de/LC_MESSAGES/django.mo | Bin 253277 -> 253578 bytes netbox/translations/de/LC_MESSAGES/django.po | 263 +++++++------- netbox/translations/en/LC_MESSAGES/django.po | 258 +++++++------- netbox/translations/es/LC_MESSAGES/django.mo | Bin 255016 -> 255342 bytes netbox/translations/es/LC_MESSAGES/django.po | 262 +++++++------- netbox/translations/fr/LC_MESSAGES/django.mo | Bin 257054 -> 257334 bytes netbox/translations/fr/LC_MESSAGES/django.po | 269 +++++++------- netbox/translations/it/LC_MESSAGES/django.mo | Bin 253098 -> 253430 bytes netbox/translations/it/LC_MESSAGES/django.po | 262 +++++++------- netbox/translations/ja/LC_MESSAGES/django.mo | Bin 271467 -> 271826 bytes netbox/translations/ja/LC_MESSAGES/django.po | 258 +++++++------- netbox/translations/nl/LC_MESSAGES/django.mo | Bin 248657 -> 248963 bytes netbox/translations/nl/LC_MESSAGES/django.po | 260 +++++++------- netbox/translations/pl/LC_MESSAGES/django.mo | Bin 250856 -> 251174 bytes netbox/translations/pl/LC_MESSAGES/django.po | 261 +++++++------- netbox/translations/pt/LC_MESSAGES/django.mo | Bin 251039 -> 251355 bytes netbox/translations/pt/LC_MESSAGES/django.po | 261 +++++++------- netbox/translations/ru/LC_MESSAGES/django.mo | Bin 322291 -> 322767 bytes netbox/translations/ru/LC_MESSAGES/django.po | 329 +++++++++--------- netbox/translations/tr/LC_MESSAGES/django.mo | Bin 244484 -> 244764 bytes netbox/translations/tr/LC_MESSAGES/django.po | 258 +++++++------- netbox/translations/uk/LC_MESSAGES/django.mo | Bin 322037 -> 322443 bytes netbox/translations/uk/LC_MESSAGES/django.po | 259 +++++++------- netbox/translations/zh/LC_MESSAGES/django.mo | Bin 226269 -> 226529 bytes netbox/translations/zh/LC_MESSAGES/django.po | 258 +++++++------- netbox/utilities/forms/fields/csv.py | 2 + netbox/utilities/templates/buttons/add.html | 2 +- netbox/utilities/templatetags/buttons.py | 3 +- netbox/utilities/views.py | 41 ++- requirements.txt | 10 +- 66 files changed, 2351 insertions(+), 1989 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/01-feature_request.yaml b/.github/ISSUE_TEMPLATE/01-feature_request.yaml index 5252adb06..24497f825 100644 --- a/.github/ISSUE_TEMPLATE/01-feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/01-feature_request.yaml @@ -15,7 +15,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v4.3.6 + placeholder: v4.3.7 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml index 20836a5e3..153109e31 100644 --- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml @@ -27,7 +27,7 @@ body: attributes: label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v4.3.6 + placeholder: v4.3.7 validations: required: true - type: dropdown diff --git a/docs/administration/error-reporting.md b/docs/administration/error-reporting.md index ccc0a84a5..8b4312273 100644 --- a/docs/administration/error-reporting.md +++ b/docs/administration/error-reporting.md @@ -4,7 +4,7 @@ ### Enabling Error Reporting -NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to True and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`. +NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to `True` and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`. ```python SENTRY_ENABLED = True diff --git a/docs/reference/conditions.md b/docs/reference/conditions.md index fc571c05e..94e5e30b4 100644 --- a/docs/reference/conditions.md +++ b/docs/reference/conditions.md @@ -89,7 +89,7 @@ The following condition will evaluate as true: ``` !!! note "Evaluating static choice fields" - Pay close attention when evaluating static choice fields, such as the `status` field above. These fields typically render as a dictionary specifying both the field's raw value (`value`) and its human-friendly label (`label`). be sure to specify on which of these you want to match. + Pay close attention when evaluating static choice fields, such as the `status` field above. These fields typically render as a dictionary specifying both the field's raw value (`value`) and its human-friendly label (`label`). Be sure to specify on which of these you want to match. ## Condition Sets diff --git a/docs/release-notes/version-4.3.md b/docs/release-notes/version-4.3.md index d3ade67b8..42adcd404 100644 --- a/docs/release-notes/version-4.3.md +++ b/docs/release-notes/version-4.3.md @@ -1,5 +1,29 @@ # NetBox v4.3 +## v4.3.7 (2025-08-26) + +### Enhancements + +* [#18147](https://github.com/netbox-community/netbox/issues/18147) - Add device & VM interface counts under related objects for VRFs +* [#19990](https://github.com/netbox-community/netbox/issues/19990) - Button to add a missing prerequisite now includes a return URL +* [#20122](https://github.com/netbox-community/netbox/issues/20122) - Improve color contrast of highlighted data under changelog diff view +* [#20131](https://github.com/netbox-community/netbox/issues/20131) - Add object selector for interface to the MAC address edit form + +### Bug Fixes + +* [#18916](https://github.com/netbox-community/netbox/issues/18916) - Fix dynamic dropdown selection styling for required fields when no selection is made +* [#19645](https://github.com/netbox-community/netbox/issues/19645) - Fix interface selection when adding a cable for a virtual chassis master +* [#19669](https://github.com/netbox-community/netbox/issues/19669) - Restore token authentication support for fetching media assets +* [#19970](https://github.com/netbox-community/netbox/issues/19970) - Device role child device counts should be cumulative +* [#20012](https://github.com/netbox-community/netbox/issues/20012) - Fix support for `empty` filter lookup on custom fields +* [#20043](https://github.com/netbox-community/netbox/issues/20043) - Fix page styling when rack elevations are embedded +* [#20098](https://github.com/netbox-community/netbox/issues/20098) - Fix `AttributeError` exception when assigning tags during bulk import +* [#20120](https://github.com/netbox-community/netbox/issues/20120) - Fix REST API serialization of jobs under `/api/core/background-tasks/` +* [#20157](https://github.com/netbox-community/netbox/issues/20157) - Fix `IntegrityError` exception when a duplicate notification is triggered +* [#20164](https://github.com/netbox-community/netbox/issues/20164) - Fix `ValueError` exception when attempting to add power outlets to devices in bulk + +--- + ## v4.3.6 (2025-08-12) ### Enhancements @@ -29,6 +53,8 @@ * [#20033](https://github.com/netbox-community/netbox/issues/20033) - Fix `TypeError` exception when bulk deleting bookmarks * [#20056](https://github.com/netbox-community/netbox/issues/20056) - Fixed missing RF role options in device type schema validation +--- + ## v4.3.5 (2025-07-29) ### Enhancements @@ -48,6 +74,8 @@ !!! note "Plugin Developer Advisory" The fix for bug [#18900](https://github.com/netbox-community/netbox/issues/18900) now raises explicit exceptions when API endpoints attempt to paginate unordered querysets. Plugin maintainers should review their API viewsets to ensure proper queryset ordering is applied before pagination, either by using `.order_by()` on querysets or by setting `ordering` in model Meta classes. Previously silent pagination issues in plugin code will now raise `QuerySetNotOrdered` exceptions and may require updates to maintain compatibility. +--- + ## v4.3.4 (2025-07-15) ### Enhancements diff --git a/netbox/core/api/serializers_/tasks.py b/netbox/core/api/serializers_/tasks.py index 53f2b5126..85fb682c9 100644 --- a/netbox/core/api/serializers_/tasks.py +++ b/netbox/core/api/serializers_/tasks.py @@ -18,8 +18,8 @@ class BackgroundTaskSerializer(serializers.Serializer): description = serializers.CharField() origin = serializers.CharField() func_name = serializers.CharField() - args = serializers.ListField(child=serializers.CharField()) - kwargs = serializers.DictField() + args = serializers.SerializerMethodField() + kwargs = serializers.SerializerMethodField() result = serializers.CharField() timeout = serializers.IntegerField() result_ttl = serializers.IntegerField() @@ -42,6 +42,16 @@ class BackgroundTaskSerializer(serializers.Serializer): is_scheduled = serializers.BooleanField() is_stopped = serializers.BooleanField() + def get_args(self, obj) -> list: + return [ + str(arg) for arg in obj.args + ] + + def get_kwargs(self, obj) -> dict: + return { + key: str(value) for key, value in obj.kwargs.items() + } + def get_position(self, obj) -> int: return obj.get_position() diff --git a/netbox/dcim/api/serializers_/roles.py b/netbox/dcim/api/serializers_/roles.py index 17eeaa949..0f83655a6 100644 --- a/netbox/dcim/api/serializers_/roles.py +++ b/netbox/dcim/api/serializers_/roles.py @@ -1,3 +1,5 @@ +from rest_framework import serializers + from dcim.models import DeviceRole, InventoryItemRole from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from netbox.api.fields import RelatedObjectCountField @@ -13,10 +15,8 @@ __all__ = ( class DeviceRoleSerializer(NestedGroupModelSerializer): parent = NestedDeviceRoleSerializer(required=False, allow_null=True, default=None) config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) - - # Related object counts - device_count = RelatedObjectCountField('devices') - virtualmachine_count = RelatedObjectCountField('virtual_machines') + device_count = serializers.IntegerField(read_only=True, default=0) + virtualmachine_count = serializers.IntegerField(read_only=True, default=0) class Meta: model = DeviceRole diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index a64a157e0..ffc0ca4d6 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -352,7 +352,19 @@ class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet): # class DeviceRoleViewSet(NetBoxModelViewSet): - queryset = DeviceRole.objects.all() + queryset = DeviceRole.objects.add_related_count( + DeviceRole.objects.add_related_count( + DeviceRole.objects.all(), + VirtualMachine, + 'role', + 'virtualmachine_count', + cumulative=True + ), + Device, + 'role', + 'device_count', + cumulative=True + ) serializer_class = serializers.DeviceRoleSerializer filterset_class = filtersets.DeviceRoleFilterSet diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 7856467d1..37a0d99a2 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1918,6 +1918,16 @@ class InterfaceFilterSet( PathEndpointFilterSet, CommonInterfaceFilterSet ): + virtual_chassis_member_or_master = MultiValueCharFilter( + method='filter_virtual_chassis_member_or_master', + field_name='name', + label=_('Virtual Chassis Interfaces for Device when device is master') + ) + virtual_chassis_member_or_master_id = MultiValueNumberFilter( + method='filter_virtual_chassis_member_or_master', + field_name='pk', + label=_('Virtual Chassis Interfaces for Device when device is master (ID)') + ) virtual_chassis_member = MultiValueCharFilter( method='filter_virtual_chassis_member', field_name='name', @@ -2028,11 +2038,14 @@ class InterfaceFilterSet( 'cable_id', 'cable_end', ) - def filter_virtual_chassis_member(self, queryset, name, value): + def filter_virtual_chassis_member_or_master(self, queryset, name, value): + return self.filter_virtual_chassis_member(queryset, name, value, if_master=True) + + def filter_virtual_chassis_member(self, queryset, name, value, if_master=False): try: vc_interface_ids = [] for device in Device.objects.filter(**{f'{name}__in': value}): - vc_interface_ids.extend(device.vc_interfaces(if_master=False).values_list('id', flat=True)) + vc_interface_ids.extend(device.vc_interfaces(if_master=if_master).values_list('id', flat=True)) return queryset.filter(pk__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index efcaf4903..337bb402f 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -69,11 +69,14 @@ class PowerPortBulkCreateForm( class PowerOutletBulkCreateForm( - form_from_model(PowerOutlet, ['type', 'color', 'feed_leg', 'mark_connected']), + form_from_model(PowerOutlet, ['type', 'status', 'color', 'feed_leg', 'mark_connected']), DeviceBulkAddComponentForm ): model = PowerOutlet - field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags') + field_order = ( + 'name', 'label', 'type', 'status', 'color', 'feed_leg', 'mark_connected', + 'description', 'tags', + ) class InterfaceBulkCreateForm( diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 5e5d83b0b..d3588da39 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -19,6 +19,11 @@ def get_cable_form(a_type, b_type): # Device component if hasattr(term_cls, 'device'): + # Dynamically change the param field for interfaces to use virtual_chassis filter + query_param_device_field = 'device_id' + if term_cls == Interface: + query_param_device_field = 'virtual_chassis_member_or_master_id' + attrs[f'termination_{cable_end}_device'] = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), label=_('Device'), @@ -36,7 +41,7 @@ def get_cable_form(a_type, b_type): 'parent': 'device', }, query_params={ - 'device_id': f'$termination_{cable_end}_device', + query_param_device_field: f'$termination_{cable_end}_device', 'kind': 'physical', # Exclude virtual interfaces } ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 02338a3b9..32ea2d263 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1899,6 +1899,7 @@ class MACAddressForm(NetBoxModelForm): label=_('Interface'), queryset=Interface.objects.all(), required=False, + selector=True, context={ 'parent': 'device', }, @@ -1907,6 +1908,7 @@ class MACAddressForm(NetBoxModelForm): label=_('VM Interface'), queryset=VMInterface.objects.all(), required=False, + selector=True, context={ 'parent': 'virtual_machine', }, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 6e67ba83e..c05d07ab0 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4444,6 +4444,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil ) Device.objects.bulk_create(devices) + virtual_chassis.master = devices[0] + virtual_chassis.save() + module_bays = ( ModuleBay(device=devices[0], name='Module Bay 1'), ModuleBay(device=devices[1], name='Module Bay 2'), @@ -4830,6 +4833,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil params = {'device': [devices[0].name, devices[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_virtual_chassis_member_or_master(self): + vc = VirtualChassis.objects.first() + master = vc.master + member = vc.members.exclude(pk=master.pk).first() + params = {'virtual_chassis_member_or_master_id': [master.pk,]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'virtual_chassis_member_or_master_id': [member.pk,]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'virtual_chassis_member_or_master': [master.name,]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'virtual_chassis_member_or_master': [member.name,]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_virtual_chassis_member(self): # Device 1A & 3 have 1 management interface, Device 1B has 1 interfaces devices = Device.objects.filter(name__in=['Device 1A', 'Device 3']) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4956cc5e3..97ca99874 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2040,9 +2040,18 @@ class InventoryItemTemplateBulkDeleteView(generic.BulkDeleteView): @register_model_view(DeviceRole, 'list', path='', detail=False) class DeviceRoleListView(generic.ObjectListView): - queryset = DeviceRole.objects.annotate( - device_count=count_related(Device, 'role'), - vm_count=count_related(VirtualMachine, 'role') + queryset = DeviceRole.objects.add_related_count( + DeviceRole.objects.add_related_count( + DeviceRole.objects.all(), + VirtualMachine, + 'role', + 'vm_count', + cumulative=True + ), + Device, + 'role', + 'device_count', + cumulative=True ) filterset = filtersets.DeviceRoleFilterSet filterset_form = forms.DeviceRoleFilterForm diff --git a/netbox/extras/lookups.py b/netbox/extras/lookups.py index 9e1fe4a0b..33296340e 100644 --- a/netbox/extras/lookups.py +++ b/netbox/extras/lookups.py @@ -1,4 +1,5 @@ -from django.db.models import CharField, Lookup +from django.db.models import CharField, JSONField, Lookup +from django.db.models.fields.json import KeyTextTransform from .fields import CachedValueField @@ -18,6 +19,30 @@ class Empty(Lookup): return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params +class JSONEmpty(Lookup): + """ + Support "empty" lookups for JSONField keys. + + A key is considered empty if it is "", null, or does not exist. + """ + lookup_name = "empty" + + def as_sql(self, compiler, connection): + # self.lhs.lhs is the parent expression (could be a JSONField or another KeyTransform) + # Rebuild the expression using KeyTextTransform to guarantee ->> (text) + text_expr = KeyTextTransform(self.lhs.key_name, self.lhs.lhs) + lhs_sql, lhs_params = compiler.compile(text_expr) + + value = self.rhs + if value not in (True, False): + raise ValueError("The 'empty' lookup only accepts True or False.") + + condition = '' if value else 'NOT ' + sql = f"(NULLIF({lhs_sql}, '') IS {condition}NULL)" + + return sql, lhs_params + + class NetHost(Lookup): """ Similar to ipam.lookups.NetHost, but casts the field to INET. @@ -45,5 +70,6 @@ class NetContainsOrEquals(Lookup): CharField.register_lookup(Empty) +JSONField.register_lookup(JSONEmpty) CachedValueField.register_lookup(NetHost) CachedValueField.register_lookup(NetContainsOrEquals) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 88e6abc27..caf113f97 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -600,11 +600,19 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): kwargs = { 'field_name': f'custom_field_data__{self.name}' } + # Native numeric filters will use `isnull` by default for empty lookups, but + # JSON fields require `empty` (see bug #20012). + if lookup_expr == 'isnull': + lookup_expr = 'empty' if lookup_expr is not None: kwargs['lookup_expr'] = lookup_expr + # 'Empty' lookup is always a boolean + if lookup_expr == 'empty': + filter_class = django_filters.BooleanFilter + # Text/URL - if self.type in ( + elif self.type in ( CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT, CustomFieldTypeChoices.TYPE_URL, diff --git a/netbox/extras/models/notifications.py b/netbox/extras/models/notifications.py index c8e8c4fd8..f813a0d29 100644 --- a/netbox/extras/models/notifications.py +++ b/netbox/extras/models/notifications.py @@ -173,14 +173,17 @@ class NotificationGroup(ChangeLoggedModel): User.objects.filter(groups__in=self.groups.all()) ).order_by('username') - def notify(self, **kwargs): + def notify(self, object_type, object_id, **kwargs): """ Bulk-create Notifications for all members of this group. """ - Notification.objects.bulk_create([ - Notification(user=member, **kwargs) - for member in self.members - ]) + for user in self.members: + Notification.objects.update_or_create( + object_type=object_type, + object_id=object_id, + user=user, + defaults=kwargs + ) notify.alters_data = True diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 8bae8dfc9..c3074aa41 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1615,6 +1615,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf11': manufacturers[2].pk, 'cf12': [manufacturers[2].pk, manufacturers[3].pk], }), + Site(name='Site 4', slug='site-4'), ]) def test_filter_integer(self): @@ -1624,6 +1625,7 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf1__empty': True}, self.queryset).qs.count(), 1) def test_filter_decimal(self): self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2) @@ -1632,6 +1634,7 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf2__empty': True}, self.queryset).qs.count(), 1) def test_filter_boolean(self): self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2) @@ -1648,6 +1651,7 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__empty': True}, self.queryset).qs.count(), 1) def test_filter_text_loose(self): self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2) @@ -1659,6 +1663,7 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__empty': True}, self.queryset).qs.count(), 1) def test_filter_url_strict(self): self.assertEqual( @@ -1674,17 +1679,20 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0) self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7__empty': True}, self.queryset).qs.count(), 1) def test_filter_url_loose(self): self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3) def test_filter_select(self): self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf9__empty': True}, self.queryset).qs.count(), 1) def test_filter_multiselect(self): self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1) # Contains a literal null + self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2) def test_filter_object(self): manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) @@ -1692,6 +1700,7 @@ class CustomFieldModelFilterTest(TestCase): self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2 ) + self.assertEqual(self.filterset({'cf_cf11__empty': True}, self.queryset).qs.count(), 1) def test_filter_multiobject(self): manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) @@ -1703,3 +1712,4 @@ class CustomFieldModelFilterTest(TestCase): self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3 ) + self.assertEqual(self.filterset({'cf_cf12__empty': True}, self.queryset).qs.count(), 1) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 61a5ce563..53e42c4fa 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -54,8 +54,26 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView): ) export_targets_table.configure(request) + related_models = self.get_related_models( + request, + instance, + omit=(Interface, VMInterface), + extra=( + ( + Interface.objects.restrict(request.user, 'view').filter(vrf=instance), + 'vrf_id', + _('Device Interfaces') + ), + ( + VMInterface.objects.restrict(request.user, 'view').filter(vrf=instance), + 'vrf_id', + _('VM Interfaces') + ), + ), + ) + return { - 'related_models': self.get_related_models(request, instance, omit=[Interface, VMInterface]), + 'related_models': related_models, 'import_targets_table': import_targets_table, 'export_targets_table': export_targets_table, } diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index f24b4e11c..ea24efe48 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -29,6 +29,13 @@ __all__ = ( 'OrganizationalModelFilterSet', ) +STANDARD_LOOKUPS = ( + 'exact', + 'iexact', + 'in', + 'contains', +) + # # FilterSets @@ -159,7 +166,7 @@ class BaseFilterSet(django_filters.FilterSet): return {} # Skip nonstandard lookup expressions - if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'iexact', 'in']: + if existing_filter.method is not None or existing_filter.lookup_expr not in STANDARD_LOOKUPS: return {} # Choose the lookup expression map based on the filter type diff --git a/netbox/netbox/views/misc.py b/netbox/netbox/views/misc.py index f28b0f7b1..ab995db66 100644 --- a/netbox/netbox/views/misc.py +++ b/netbox/netbox/views/misc.py @@ -20,7 +20,7 @@ from netbox.search.backends import search_backend from netbox.tables import SearchTable from utilities.htmx import htmx_partial from utilities.paginator import EnhancedPaginator, get_paginate_count -from utilities.views import ConditionalLoginRequiredMixin +from utilities.views import ConditionalLoginRequiredMixin, TokenConditionalLoginRequiredMixin __all__ = ( 'HomeView', @@ -119,7 +119,7 @@ class SearchView(ConditionalLoginRequiredMixin, View): }) -class MediaView(ConditionalLoginRequiredMixin, View): +class MediaView(TokenConditionalLoginRequiredMixin, View): """ Wrap Django's serve() view to enforce LOGIN_REQUIRED for static media. """ diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 4777dffa26b35622975a5b2f3ff36c92fc9a654b..2768f77149d6b153152785489a7dd3a9b084302a 100644 GIT binary patch delta 1612 zcmaF&S#f29(uTPj)0fX+(@{z;Eh^40vMR{W%qvMPvQEp)0a8}!MTwQg$%#3s8ityi z=V+vF(J4;NNlh-%E6LA=uu@a>GK+OH^U4x)GE=luGRyQzignA25(^4aiza`#qpwSv z4!*mw`b6kR%P-2+P0r6NDay~8-e=9BIeEfU(arPj$}?G!s2kb20hd(3=6txX&ZtMM zIgnt0IYN7K!o#^1gaZle0hlsGfIWEVXGxmH$nIbFP-?T@BLyZ3jDQBgm2eW={WD zz#%fZK$m;6!9(83_Rl#c>plyxqs-*V<0H-HQqU=D#NPAgOk zlqY|%gIkH$;?Z~?gQQO%#w`EJk93UFSfsa!Fbht^2cQ&(@)tk zaZi^~Wa60apv=VDeoui3h?#+y1&Bd>HXvr-eouj8uP_m@j3}t4*IRK&Oy6X}QK|^@ zHqP*$E^h-=>ut&*FumW3gJZj$DTg!@Nw#edH0RJ~Cc-9uM-JKP7aTYQw@W0|l1J~Fm+dX2R{Pt1*dMgh8>E~@YIJOsBaY!>wzi-3Aza7YzV4fb}0FmO{ zZs)*pLU_8ICkM~;7&oBO;1G`2iql1Au?tRbsNi6qo?FHt2j;RXr_WE*;RU80c_?~?L diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 1bb33d754c7df2752ae42aa4d0642b20aa3381bd..1f5e199d8e78425e2b7233f80d6fad1a4a9bd3e4 100644 GIT binary patch delta 58 zcmZ3xL45iK@eLv~SS^%vvQnpS6lav5oU@!~v&#%4hvuFe?L9XbftU%1nYZ`cVA=DR M(PF#jHP$DL0L2j*IsgCw delta 81 zcmbQfL4557@eLv~Sk0AmvQnqd7H5>7uIIwYwb^NgiG!$4Nk(R|UVLg!YHn&?iC$WM ja%r(fbLoxt(i@CG%ml>D+e>e-?ETAVzTNm5>k~!*!O0)u diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 121a9cd1b106887b87bed9768ae8eff30dfb2398..2d0c72c2dd6f92d0ad3a0e365857260d1e50dce0 100644 GIT binary patch delta 974 zcmZvaPe@cz6vlaPbabAL|IW3sF|ZI?ATMgoSc`O*aZ$#RRL`85AF1;1UINsYL3dT|;WaN67_ z4p58loq?+vLN0FT_^shjUc*HN8&n`&%$1@3Jr z%U5A+OZj0Hev094dlj4d0j-|Mh80$}1}zxB(c@ShSc4mWk$AwuxT2JoxX4$x5a_k9 zfUkQ|rXUM8N z?ik>=&k?W8`>5^kL5#GQil0h2|KJ-Y?W$;m9EqW8{e@8;MB+APhxq^AvhKg-ps*$Y;~( zRGzmuWXcndepd%iLA*AdJu+I$-JzzGVKCH*2sXQ zqSkAC@dMcs;f%p&KaysljXJnLkzq-kI|mc%B;m9YK`w2Q4xv7scWsiKr3T;FBu}j@ zzqdsm?OM||ncTH++vKMR=7W6wJLyzn*p%x#NZg~b(pH9>GFd(f;Ylgcha0?=`bUY3RX4OqTShg^1ycMit+)Htn%EBEP)Sfp{DpO~N{ zmci=@8nJ!_e*mhU(YmtTA)03BSL@L<_e11b$9AZLT&8zqSU&7Q! V6AKinLiQomNDZd9VACnjHGEZ!{0e4PmZc2NmC diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index 43b38e2a1..e2cb21a45 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -24,13 +24,13 @@ "dependencies": { "@mdi/font": "7.4.47", "@tabler/core": "1.4.0", - "bootstrap": "5.3.7", + "bootstrap": "5.3.8", "clipboard": "2.0.11", "flatpickr": "4.6.13", "gridstack": "12.3.3", "htmx.org": "2.0.6", "query-string": "9.2.2", - "sass": "1.90.0", + "sass": "1.91.0", "tom-select": "2.4.3", "typeface-inter": "3.18.1", "typeface-roboto-mono": "1.1.13" diff --git a/netbox/project-static/styles/custom/_code.scss b/netbox/project-static/styles/custom/_code.scss index 0e6869efa..79bcb14c8 100644 --- a/netbox/project-static/styles/custom/_code.scss +++ b/netbox/project-static/styles/custom/_code.scss @@ -12,11 +12,13 @@ pre.change-data { min-width: fit-content; &.added { - background-color: $green; + color: var(--tblr-dark); + background-color: $green-300; } &.removed { - background-color: $red; + color: var(--tblr-dark); + background-color: $red-300; } } } @@ -26,11 +28,13 @@ pre.change-diff { border-color: transparent; &.change-added { - background-color: $green; + color: var(--tblr-dark); + background-color: $green-300; } &.change-removed { - background-color: $red; + color: var(--tblr-dark); + background-color: $red-300; } } diff --git a/netbox/project-static/styles/overrides/_tomselect.scss b/netbox/project-static/styles/overrides/_tomselect.scss index 29aa9d361..4d2070fa0 100644 --- a/netbox/project-static/styles/overrides/_tomselect.scss +++ b/netbox/project-static/styles/overrides/_tomselect.scss @@ -6,3 +6,9 @@ } } } + +// Replicate styling of tom-selected