From 2a0a7d45aaf9e0fbd2f3c3980c96a2396fbab032 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Oct 2023 11:24:14 -0400 Subject: [PATCH 01/37] Add GitHub issue template for translations --- .github/ISSUE_TEMPLATE/translation.yaml | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/translation.yaml diff --git a/.github/ISSUE_TEMPLATE/translation.yaml b/.github/ISSUE_TEMPLATE/translation.yaml new file mode 100644 index 000000000..001b6ac53 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/translation.yaml @@ -0,0 +1,37 @@ +--- +name: 🌍 Translation +description: Request support for a new language in the user interface +labels: ["type: translation"] +body: + - type: markdown + attributes: + value: > + **NOTE:** This template is used only for proposing the addition of *new* languages. Please do + not use it to request changes to existing translations. + - type: input + attributes: + label: Language + description: What is the name of the language in English? + validations: + required: true + - type: input + attributes: + label: ISO 639-1 code + description: > + What is the two-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) + assigned to the language? + validations: + required: true + - type: dropdown + attributes: + label: Volunteer + description: Are you a native speaker of this language and willing to contribute a translation map? + options: + - Yes + - No + validations: + required: true + - type: textarea + attributes: + label: Comments + description: Any other notes you would like to share From 809b04959085c8d6cdb757c0a0255e17a73e7723 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Oct 2023 11:29:31 -0400 Subject: [PATCH 02/37] YAML fix --- .github/ISSUE_TEMPLATE/translation.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/translation.yaml b/.github/ISSUE_TEMPLATE/translation.yaml index 001b6ac53..d53db04fd 100644 --- a/.github/ISSUE_TEMPLATE/translation.yaml +++ b/.github/ISSUE_TEMPLATE/translation.yaml @@ -27,8 +27,8 @@ body: label: Volunteer description: Are you a native speaker of this language and willing to contribute a translation map? options: - - Yes - - No + - "Yes" + - "No" validations: required: true - type: textarea From 5b2f29480a42e9c815ca1f50403c115cc89b6538 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Oct 2023 11:57:21 -0400 Subject: [PATCH 03/37] Tweak translation issue form --- .github/ISSUE_TEMPLATE/translation.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/translation.yaml b/.github/ISSUE_TEMPLATE/translation.yaml index d53db04fd..d07bc399d 100644 --- a/.github/ISSUE_TEMPLATE/translation.yaml +++ b/.github/ISSUE_TEMPLATE/translation.yaml @@ -25,7 +25,7 @@ body: - type: dropdown attributes: label: Volunteer - description: Are you a native speaker of this language and willing to contribute a translation map? + description: Are you a fluent speaker of this language **and** willing to contribute a translation map? options: - "Yes" - "No" From b3fb39349014998e499c4b5946a69260d46b0713 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 1 Nov 2023 13:30:10 -0700 Subject: [PATCH 04/37] 14033 raise validation error if A and B term go to same object (#14050) * 14033 raise validation error if A and B term go to same object * 14033 move check to cable model clean * 14033 fix tests --- netbox/dcim/models/cables.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 751bca271..f240659dd 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -180,6 +180,17 @@ class Cable(PrimaryModel): if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type): raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}") + if a_type == b_type: + # can't directly use self.a_terminations here as possible they + # don't have pk yet + a_pks = set(obj.pk for obj in self.a_terminations if obj.pk) + b_pks = set(obj.pk for obj in self.b_terminations if obj.pk) + + if (a_pks & b_pks): + raise ValidationError( + _("A and B terminations cannot connect to the same object.") + ) + # Run clean() on any new CableTerminations for termination in self.a_terminations: CableTermination(cable=self, cable_end='A', termination=termination).clean() From 22e474ff96ce9b8659455ad48ee96e766bbf7025 Mon Sep 17 00:00:00 2001 From: Kenny Y <24802984+kenny-y-dev@users.noreply.github.com> Date: Wed, 1 Nov 2023 16:52:19 -0400 Subject: [PATCH 05/37] Update attr in conditions example --- docs/reference/conditions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/conditions.md b/docs/reference/conditions.md index 514006b01..fc571c05e 100644 --- a/docs/reference/conditions.md +++ b/docs/reference/conditions.md @@ -116,7 +116,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This ] }, { - "attr": "tags", + "attr": "tags.slug", "value": "exempt", "op": "contains" } From 66b9cdf1411270f9c9aa8b7d9a8d3cc530231899 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 8 Nov 2023 18:25:32 +0530 Subject: [PATCH 06/37] adds import button on the contact assignment table #13669 --- netbox/tenancy/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 76a86146c..d0a8c2b89 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -386,7 +386,7 @@ class ContactAssignmentListView(generic.ObjectListView): filterset = filtersets.ContactAssignmentFilterSet filterset_form = forms.ContactAssignmentFilterForm table = tables.ContactAssignmentTable - actions = ('export', 'bulk_edit', 'bulk_delete') + actions = ('export', 'bulk_edit', 'bulk_delete', 'import') @register_model_view(ContactAssignment, 'edit') From 60e98324c3fcfcb082489dce24bee4419c474caa Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 8 Nov 2023 21:36:11 +0530 Subject: [PATCH 07/37] adds inventory items to interface #13723 --- netbox/dcim/tables/devices.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 624eb579b..32c0f58c4 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -625,6 +625,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi verbose_name=_('VRF'), linkify=True ) + inventory_items = tables.ManyToManyColumn( + linkify_item=True, + verbose_name=_('Inventory Items'), + ) tags = columns.TagColumn( url_name='dcim:interface_list' ) @@ -636,7 +640,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi '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', - 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', + 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') From b0f2de5bd7154e3ab2e403167e669a5ee84613d5 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 8 Nov 2023 23:18:39 +0530 Subject: [PATCH 08/37] order available columns #14219 --- netbox/netbox/tables/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 52ff69aa9..97ab44362 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -119,7 +119,7 @@ class BaseTable(tables.Table): @property def available_columns(self): - return self._get_columns(visible=False) + return sorted(self._get_columns(visible=False)) @property def selected_columns(self): From 94858ac13f6aa1983d10269516af4cd74f0be11e Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 8 Nov 2023 22:42:58 +0530 Subject: [PATCH 09/37] adds parent to inventory item table #14113 --- netbox/dcim/tables/devices.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 32c0f58c4..bbf1f5d96 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -937,6 +937,9 @@ class InventoryItemTable(DeviceComponentTable): discovered = columns.BooleanColumn( verbose_name=_('Discovered'), ) + parent = tables.LinkColumn( + verbose_name=_('Parent'), + ) tags = columns.TagColumn( url_name='dcim:inventoryitem_list' ) @@ -945,7 +948,7 @@ class InventoryItemTable(DeviceComponentTable): class Meta(NetBoxTable.Meta): model = models.InventoryItem fields = ( - 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', + 'pk', 'id', 'name', 'device', 'parent', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', ) default_columns = ( From 6e8ee9db894e07154b43f7e19eb817fc93765212 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 9 Nov 2023 02:56:36 +0530 Subject: [PATCH 10/37] review changes #14113 --- netbox/dcim/tables/devices.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index bbf1f5d96..bf5f2d050 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -937,7 +937,8 @@ class InventoryItemTable(DeviceComponentTable): discovered = columns.BooleanColumn( verbose_name=_('Discovered'), ) - parent = tables.LinkColumn( + parent = tables.Column( + linkify=True, verbose_name=_('Parent'), ) tags = columns.TagColumn( From ac4b46b5027efe2846872211ebce20fc83ff1d06 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 8 Nov 2023 22:35:00 +0530 Subject: [PATCH 11/37] adds site column to power feeds #13743 --- netbox/dcim/tables/power.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index e4735bd57..04aa9a2d6 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -87,6 +87,10 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable): linkify=True, verbose_name=_('Tenant') ) + site = tables.TemplateColumn( + template_code='''{% if record.rack %}{{ record.rack.site|linkify }}{% else %}—{% endif %}''', + verbose_name=_('Site'), + ) comments = columns.MarkdownColumn( verbose_name=_('Comments'), ) @@ -97,9 +101,9 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable): class Meta(NetBoxTable.Meta): model = PowerFeed fields = ( - 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', 'tenant', - 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'power_panel', 'site', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', + 'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', + 'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', From d2c727c0a2d1e5e56857fb74628e193a23cf3aa2 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 9 Nov 2023 02:54:56 +0530 Subject: [PATCH 12/37] review changes #13743 --- netbox/dcim/tables/power.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index 04aa9a2d6..40a58ad81 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -87,8 +87,9 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable): linkify=True, verbose_name=_('Tenant') ) - site = tables.TemplateColumn( - template_code='''{% if record.rack %}{{ record.rack.site|linkify }}{% else %}—{% endif %}''', + site = tables.Column( + accessor='rack__site', + linkify=True, verbose_name=_('Site'), ) comments = columns.MarkdownColumn( From 1203d761f4c741ea86e1dbe30b48266e49ef9a4d Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 9 Nov 2023 19:16:14 +0530 Subject: [PATCH 13/37] Adds mask length filters on ipaddress (#14218) * adds mask length filters on ipaddress #14101 * Change IPaddress mask_length filter to multi-value; extend tests --------- Co-authored-by: Jeremy Stretch --- netbox/ipam/filtersets.py | 23 ++++++++++++++--------- netbox/ipam/tests/test_filtersets.py | 12 ++++++++++-- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index bc9181286..d1177bdc1 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -266,7 +266,8 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) mask_length = MultiValueNumberFilter( field_name='prefix', - lookup_expr='net_mask_length' + lookup_expr='net_mask_length', + label=_('Mask length') ) mask_length__gte = django_filters.NumberFilter( field_name='prefix', @@ -531,9 +532,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): method='filter_address', label=_('Address'), ) - mask_length = django_filters.NumberFilter( - method='filter_mask_length', - label=_('Mask length'), + mask_length = MultiValueNumberFilter( + field_name='address', + lookup_expr='net_mask_length', + label=_('Mask length') + ) + mask_length__gte = django_filters.NumberFilter( + field_name='address', + lookup_expr='net_mask_length__gte' + ) + mask_length__lte = django_filters.NumberFilter( + field_name='address', + lookup_expr='net_mask_length__lte' ) vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), @@ -677,11 +687,6 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): except ValidationError: return queryset.none() - def filter_mask_length(self, queryset, name, value): - if not value: - return queryset - return queryset.filter(address__net_mask_length=value) - @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 596356906..952376056 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -627,8 +627,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_mask_length(self): - params = {'mask_length': ['24']} + params = {'mask_length': [24]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'mask_length__gte': 32} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + params = {'mask_length__lte': 24} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) def test_vrf(self): vrfs = VRF.objects.all()[:2] @@ -954,8 +958,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_mask_length(self): - params = {'mask_length': '24'} + params = {'mask_length': [24]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + params = {'mask_length__gte': 64} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'mask_length__lte': 25} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_vrf(self): vrfs = VRF.objects.all()[:2] From 0603dd1be485a8202ade05c3faee824bbbeb99e8 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 9 Nov 2023 19:17:24 +0530 Subject: [PATCH 14/37] Adds inventory item children view (#14217) * adds inventory item children view #14112 * Use existing child_items relation --------- Co-authored-by: Jeremy Stretch --- netbox/dcim/views.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7c75dd26e..c67dfaade 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2993,6 +2993,25 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView): template_name = 'dcim/inventoryitem_bulk_delete.html' +@register_model_view(InventoryItem, 'children') +class InventoryItemChildrenView(generic.ObjectChildrenView): + queryset = InventoryItem.objects.all() + child_model = InventoryItem + table = tables.InventoryItemTable + filterset = filtersets.InventoryItemFilterSet + template_name = 'generic/object_children.html' + tab = ViewTab( + label=_('Children'), + badge=lambda obj: obj.child_items.count(), + permission='dcim.view_inventoryitem', + hide_if_empty=True, + weight=5000 + ) + + def get_children(self, request, parent): + return parent.child_items.restrict(request.user, 'view') + + # # Inventory item roles # From dfef89ab88dc57fddeae98fdf427168dbce0a48b Mon Sep 17 00:00:00 2001 From: Chris Mills <1878544+cpmills1975@users.noreply.github.com> Date: Thu, 9 Nov 2023 01:17:09 +0000 Subject: [PATCH 15/37] Fix ordering on JobTable. #14223 --- netbox/core/tables/jobs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index 32ca67f7f..f65964f77 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -19,7 +19,8 @@ class JobTable(NetBoxTable): ) object = tables.Column( verbose_name=_('Object'), - linkify=True + linkify=True, + orderable=False ) status = columns.ChoiceFieldColumn( verbose_name=_('Status'), From 95519b42a056320a5745086b243e8959e3168195 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 9 Nov 2023 19:43:46 +0530 Subject: [PATCH 16/37] Adds device and vm to service filter form (#14215) * adds device and vm to service filter form #13951 * Tweak labels --------- Co-authored-by: Jeremy Stretch --- netbox/ipam/forms/filtersets.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index aae62ca75..a8ca91901 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -523,6 +523,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): class ServiceFilterForm(ServiceTemplateFilterForm): model = Service + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Attributes'), ('protocol', 'port')), + (_('Assignment'), ('device_id', 'virtual_machine_id')), + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device'), + ) + virtual_machine_id = DynamicModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + label=_('Virtual Machine'), + ) tag = TagFilterField(model) From 5000564430cab3da2a752568b737829e2b536cfe Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Nov 2023 09:19:49 -0500 Subject: [PATCH 17/37] Changelog for #13669, #13723, #13743, #13951, #14033, #14101, #14112, #14113, #14220, #14220 --- docs/release-notes/version-3.6.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 8f827b50e..41413016b 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -2,6 +2,22 @@ ## v3.6.5 (FUTURE) +### Enhancements + +* [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view +* [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table +* [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table +* [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form +* [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses +* [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view +* [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table +* [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form + +### Bug Fixes + +* [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object +* [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object + --- ## v3.6.4 (2023-10-17) From 6900097e2df13f676b3c55ec43e63332424fa4d3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Nov 2023 16:24:14 -0400 Subject: [PATCH 18/37] Fixes #14117: Validate the number of front ports to be created --- netbox/dcim/forms/object_create.py | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index abd7bd6f6..ea842508f 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -151,6 +151,23 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp ) self.fields['rear_port'].choices = choices + def clean(self): + + # Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate + # positions + frontport_count = len(self.cleaned_data['name']) + rearport_count = len(self.cleaned_data['rear_port']) + if frontport_count != rearport_count: + raise forms.ValidationError({ + 'rear_port': _( + "The number of front port templates to be created ({frontport_count}) must match the selected " + "number of rear port positions ({rearport_count})." + ).format( + frontport_count=frontport_count, + rearport_count=rearport_count + ) + }) + def get_iterative_data(self, iteration): # Assign rear port and position from selected set @@ -291,6 +308,22 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): ) self.fields['rear_port'].choices = choices + def clean(self): + + # Check that the number of FrontPorts to be created matches the selected number of RearPort positions + frontport_count = len(self.cleaned_data['name']) + rearport_count = len(self.cleaned_data['rear_port']) + if frontport_count != rearport_count: + raise forms.ValidationError({ + 'rear_port': _( + "The number of front ports to be created ({frontport_count}) must match the selected number of " + "rear port positions ({rearport_count})." + ).format( + frontport_count=frontport_count, + rearport_count=rearport_count + ) + }) + def get_iterative_data(self, iteration): # Assign rear port and position from selected set From 092f2b06abc64e1d9d9c89b61a39f7d67e23c2e8 Mon Sep 17 00:00:00 2001 From: Prince Kumar Date: Thu, 9 Nov 2023 20:25:44 +0530 Subject: [PATCH 19/37] Enhance Virtual Machine and Device Platform Filter with Manufacturer Information (#14047) * Add manufacturer for filters in the virtual machine and device #12741 * reverse the filtersets of device and vm * revert the filtersets of vm * add advance selector in platform * remove manufacture from imports --- netbox/dcim/forms/model_forms.py | 3 ++- netbox/virtualization/forms/model_forms.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 93e214598..219e1f6c3 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -442,7 +442,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm): platform = DynamicModelChoiceField( label=_('Platform'), queryset=Platform.objects.all(), - required=False + required=False, + selector=True ) cluster = DynamicModelChoiceField( label=_('Cluster'), diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 21dbc895a..91f5b06ad 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -200,7 +200,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): platform = DynamicModelChoiceField( label=_('Platform'), queryset=Platform.objects.all(), - required=False + required=False, + selector=True ) local_context_data = JSONField( required=False, From 6b89da2233b8f424e2cddc9c8bbe32a53cef4e74 Mon Sep 17 00:00:00 2001 From: Artem Kotik Date: Thu, 9 Nov 2023 22:56:43 +0800 Subject: [PATCH 20/37] Closes #13936: Add primary_ip4 and primary_ip6 filters to VirtualMachine and VirtualDeviceContext filtersets (#14203) * Add primary_ip4 and primary_ip6 filters for VirtualMachine and VirtualDeviceContext filtersets (#13936) * Add PrimaryIPFilterSet to __all__ --------- Co-authored-by: Artem I. Kotik Co-authored-by: Jeremy Stretch --- netbox/dcim/filtersets.py | 21 ++++++++---------- netbox/dcim/tests/test_filtersets.py | 20 +++++++++++++++++ netbox/extras/tests/test_views.py | 2 +- netbox/ipam/filtersets.py | 17 ++++++++++++++ netbox/virtualization/filtersets.py | 4 +++- .../virtualization/tests/test_filtersets.py | 22 +++++++++++++++++-- 6 files changed, 70 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index d600667d7..b5bdaf269 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext as _ from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate +from ipam.filtersets import PrimaryIPFilterSet from ipam.models import ASN, L2VPN, IPAddress, VRF from netbox.filtersets import ( BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, @@ -817,7 +818,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet): +class DeviceFilterSet( + NetBoxModelFilterSet, + TenancyFilterSet, + ContactModelFilterSet, + LocalConfigContextFilterSet, + PrimaryIPFilterSet, +): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='device_type__manufacturer', queryset=Manufacturer.objects.all(), @@ -993,16 +1000,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter method='_device_bays', label=_('Has device bays'), ) - primary_ip4_id = django_filters.ModelMultipleChoiceFilter( - field_name='primary_ip4', - queryset=IPAddress.objects.all(), - label=_('Primary IPv4 (ID)'), - ) - primary_ip6_id = django_filters.ModelMultipleChoiceFilter( - field_name='primary_ip6', - queryset=IPAddress.objects.all(), - label=_('Primary IPv6 (ID)'), - ) oob_ip_id = django_filters.ModelMultipleChoiceFilter( field_name='oob_ip', queryset=IPAddress.objects.all(), @@ -1069,7 +1066,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter return queryset.exclude(devicebays__isnull=value) -class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet): +class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet): device_id = django_filters.ModelMultipleChoiceFilter( field_name='device', queryset=Device.objects.all(), diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 1f3b557b5..8fbef126e 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4712,12 +4712,18 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): addresses = ( IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'), IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'), + IPAddress(assigned_object=None, address='10.1.1.3/24'), + IPAddress(assigned_object=interfaces[0], address='2001:db8::1/64'), + IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'), + IPAddress(assigned_object=None, address='2001:db8::3/64'), ) IPAddress.objects.bulk_create(addresses) vdcs[0].primary_ip4 = addresses[0] + vdcs[0].primary_ip6 = addresses[3] vdcs[0].save() vdcs[1].primary_ip4 = addresses[1] + vdcs[1].primary_ip6 = addresses[4] vdcs[1].save() def test_device(self): @@ -4738,3 +4744,17 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'has_primary_ip': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_primary_ip4(self): + addresses = IPAddress.objects.filter(address__family=4) + params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'primary_ip4_id': [addresses[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + + def test_primary_ip6(self): + addresses = IPAddress.objects.filter(address__family=6) + params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'primary_ip6_id': [addresses[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 296ed9f4d..e034abff5 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -457,7 +457,7 @@ class ConfigContextTestCase( 'platforms': [], 'tenant_groups': [], 'tenants': [], - 'device_types': [devicetype.id,], + 'device_types': [devicetype.id], 'tags': [], 'data': '{"foo": 123}', } diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index d1177bdc1..ba944e3ad 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -29,6 +29,7 @@ __all__ = ( 'L2VPNFilterSet', 'L2VPNTerminationFilterSet', 'PrefixFilterSet', + 'PrimaryIPFilterSet', 'RIRFilterSet', 'RoleFilterSet', 'RouteTargetFilterSet', @@ -1232,3 +1233,19 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): ) ) return qs + + +class PrimaryIPFilterSet(django_filters.FilterSet): + """ + An inheritable FilterSet for models which support primary IP assignment. + """ + primary_ip4_id = django_filters.ModelMultipleChoiceFilter( + field_name='primary_ip4', + queryset=IPAddress.objects.all(), + label=_('Primary IPv4 (ID)'), + ) + primary_ip6_id = django_filters.ModelMultipleChoiceFilter( + field_name='primary_ip6', + queryset=IPAddress.objects.all(), + label=_('Primary IPv6 (ID)'), + ) diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 571dbe64b..b23808b31 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -6,6 +6,7 @@ from dcim.filtersets import CommonInterfaceFilterSet from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate +from ipam.filtersets import PrimaryIPFilterSet from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter @@ -114,7 +115,8 @@ class VirtualMachineFilterSet( NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, - LocalConfigContextFilterSet + LocalConfigContextFilterSet, + PrimaryIPFilterSet, ): status = django_filters.MultipleChoiceFilter( choices=VirtualMachineStatusChoices, diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index d474af21a..e6fe90297 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -291,10 +291,14 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): ipaddresses = ( IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]), IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]), + IPAddress(address='192.0.2.3/24', assigned_object=None), + IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]), + IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]), + IPAddress(address='2001:db8::3/64', assigned_object=None), ) IPAddress.objects.bulk_create(ipaddresses) - VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0]) - VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1]) + VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3]) + VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4]) def test_name(self): params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']} @@ -412,6 +416,20 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_primary_ip4(self): + addresses = IPAddress.objects.filter(address__family=4) + params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'primary_ip4_id': [addresses[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + + def test_primary_ip6(self): + addresses = IPAddress.objects.filter(address__family=6) + params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'primary_ip6_id': [addresses[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VMInterface.objects.all() From e5c38e0829705d44fd47764cf2b2eb92d9540da5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Nov 2023 10:55:55 -0500 Subject: [PATCH 21/37] Closes #13022: Add IP assignment support when bulk importing services (#14230) * issue 13022 resolved, ipaddress added into bulk_import form * validation of ip address for device and virtual machine * error message modified * error message modified * error message modified * Fix form validation * Extend bulk import test --------- Co-authored-by: yash-pal1 Co-authored-by: yash-pal1 --- netbox/ipam/forms/bulk_import.py | 20 +++++++++++++++++++- netbox/ipam/tests/test_views.py | 16 ++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index ac3c99468..ed3ceec2b 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -507,10 +507,28 @@ class ServiceImportForm(NetBoxModelImportForm): choices=ServiceProtocolChoices, help_text=_('IP protocol') ) + ipaddresses = CSVModelMultipleChoiceField( + queryset=IPAddress.objects.all(), + required=False, + to_field_name='address', + help_text=_('IP Address'), + ) class Meta: model = Service - fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags') + fields = ( + 'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', + ) + + def clean_ipaddresses(self): + parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine') + for ip_address in self.cleaned_data['ipaddresses']: + if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent: + raise forms.ValidationError( + _("{ip} is not assigned to this device/VM.").format(ip=ip_address) + ) + + return self.cleaned_data['ipaddresses'] class L2VPNImportForm(NetBoxModelImportForm): diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index afc97cc63..a37584f0f 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -4,6 +4,7 @@ from django.test import override_settings from django.urls import reverse from netaddr import IPNetwork +from dcim.constants import InterfaceTypeChoices from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface from ipam.choices import * from ipam.models import * @@ -911,6 +912,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role) + interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL) services = ( Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), @@ -919,6 +921,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Service.objects.bulk_create(services) + ip_addresses = ( + IPAddress(assigned_object=interface, address='192.0.2.1/24'), + IPAddress(assigned_object=interface, address='192.0.2.2/24'), + ) + IPAddress.objects.bulk_create(ip_addresses) + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { @@ -933,10 +941,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "device,name,protocol,ports,description", - "Device 1,Service 1,tcp,1,First service", - "Device 1,Service 2,tcp,2,Second service", - "Device 1,Service 3,udp,3,Third service", + "device,name,protocol,ports,ipaddresses,description", + "Device 1,Service 1,tcp,1,192.0.2.1/24,First service", + "Device 1,Service 2,tcp,2,192.0.2.2/24,Second service", + "Device 1,Service 3,udp,3,,Third service", ) cls.csv_update_data = ( From 57bf2a2f00f2254750590aa6b752b7b2bd6cc3e6 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 9 Nov 2023 20:59:55 +0530 Subject: [PATCH 22/37] fix asn view under asn range #14195 --- netbox/ipam/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 7cf785521..48ea637d9 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -220,7 +220,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView): tab = ViewTab( label=_('ASNs'), badge=lambda x: x.get_child_asns().count(), - permission='ipam.view_asns', + permission='ipam.view_asn', weight=500 ) From ad95760eadb262f51b2430640ff8f43f4d2a846d Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 9 Nov 2023 21:12:21 +0530 Subject: [PATCH 23/37] adds contact group on contact assignment table #14221 --- netbox/tenancy/tables/contacts.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 2e7525481..a22c04569 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -102,6 +102,11 @@ class ContactAssignmentTable(NetBoxTable): verbose_name=_('Role'), linkify=True ) + contact_group = tables.Column( + accessor=Accessor('contact__group'), + verbose_name=_('Group'), + linkify=True + ) contact_title = tables.Column( accessor=Accessor('contact__title'), verbose_name=_('Contact Title') @@ -137,7 +142,8 @@ class ContactAssignmentTable(NetBoxTable): model = ContactAssignment fields = ( 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone', - 'contact_email', 'contact_address', 'contact_link', 'contact_description', 'tags', 'actions' + 'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_group', 'tags', + 'actions' ) default_columns = ( 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone' From 217a9edb4c4848b818b2ca37de4e1b377aff9475 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 9 Nov 2023 23:43:03 +0530 Subject: [PATCH 24/37] handles the port in the ip #14085 --- netbox/utilities/request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py index 3b8e1edde..0f8ee9cae 100644 --- a/netbox/utilities/request.py +++ b/netbox/utilities/request.py @@ -17,7 +17,7 @@ def get_client_ip(request, additional_headers=()): ) for header in HTTP_HEADERS: if header in request.META: - client_ip = request.META[header].split(',')[0] + client_ip = request.META[header].split(',')[0].partition(':')[0] try: return IPAddress(client_ip) except ValueError: From dd5e20aa1ad1ec46efeeabc5de71d4bba8e07037 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 9 Nov 2023 23:51:43 +0530 Subject: [PATCH 25/37] allow login and logout in maintenance mode #14166 --- netbox/netbox/settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 111781b8a..e3c716a16 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -502,6 +502,9 @@ AUTH_EXEMPT_PATHS = ( MAINTENANCE_EXEMPT_PATHS = ( f'/{BASE_PATH}admin/', f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration + LOGIN_URL, + LOGIN_REDIRECT_URL, + LOGOUT_REDIRECT_URL ) SERIALIZATION_MODULES = { From e1bedb83501bb2c6b8b12968a48c87833f70a8bb Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 9 Nov 2023 21:57:58 +0530 Subject: [PATCH 26/37] restores config revision during cache clear #14182 --- netbox/core/management/commands/clearcache.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/netbox/core/management/commands/clearcache.py b/netbox/core/management/commands/clearcache.py index 22843c490..dd95013af 100644 --- a/netbox/core/management/commands/clearcache.py +++ b/netbox/core/management/commands/clearcache.py @@ -1,11 +1,20 @@ from django.core.cache import cache from django.core.management.base import BaseCommand +from extras.models import ConfigRevision + class Command(BaseCommand): """Command to clear the entire cache.""" help = 'Clears the cache.' def handle(self, *args, **kwargs): + # Fetch the current config revision from the cache + config_version = cache.get('config_version') + # Clear the cache cache.clear() self.stdout.write('Cache has been cleared.', ending="\n") + if config_version: + # Activate the current config revision + ConfigRevision.objects.get(id=config_version).activate() + self.stdout.write(f'Config revision ({config_version}) has been restored.', ending="\n") From 5c27d29b08535364df96d897b6271f49f74a7372 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 10 Nov 2023 01:39:16 +0530 Subject: [PATCH 27/37] Adds unit to the power port draw (#14208) * adds unit to the power port draw #13587 * review changes #13587 * moved units to header #13587 * Abbreviate unit for consistency with e.g. PowerFeedTable available_power column --------- Co-authored-by: Jeremy Stretch --- netbox/dcim/tables/devices.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index bf5f2d050..b72c37daa 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -466,6 +466,12 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): 'args': [Accessor('device_id')], } ) + maximum_draw = tables.Column( + verbose_name=_('Maximum draw (W)') + ) + allocated_draw = tables.Column( + verbose_name=_('Allocated draw (W)') + ) tags = columns.TagColumn( url_name='dcim:powerport_list' ) From 351aaf8397079d234001cd934c5121e4220ce9f0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Nov 2023 15:20:24 -0500 Subject: [PATCH 28/37] Changelog for #12741, #13022, #13587, #13936, #14085, #14117, #14166, #14182, #14195, #14221 --- docs/release-notes/version-3.6.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 41413016b..dfa4abc50 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -4,18 +4,28 @@ ### Enhancements +* [#12741](https://github.com/netbox-community/netbox/issues/12741) - Add selector widget to platform field on device & virtual machine forms +* [#13022](https://github.com/netbox-community/netbox/issues/13022) - Introduce support for assigning IP addresses when bulk importing services +* [#13587](https://github.com/netbox-community/netbox/issues/13587) - Annotate units of measurement on power port table columns * [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view * [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table * [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table +* [#13936](https://github.com/netbox-community/netbox/issues/13936) - Add primary IPv4 and IPv6 filters for virtual machines and VDCs * [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form +* [#14085](https://github.com/netbox-community/netbox/issues/14085) - Strip trailing port number from value returned by `get_client_ip()` * [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses * [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view * [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table * [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form +* [#14221](https://github.com/netbox-community/netbox/issues/14221) - Add contact group column on contact assignments table ### Bug Fixes * [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object +* [#14117](https://github.com/netbox-community/netbox/issues/14117) - Check that enough rear port positions have been selected to accommodate the number of front ports being created +* [#14166](https://github.com/netbox-community/netbox/issues/14166) - Permit user login when maintenance mode is enabled +* [#14182](https://github.com/netbox-community/netbox/issues/14182) - Ensure the active configuration is restored upon clearing cache +* [#14195](https://github.com/netbox-community/netbox/issues/14195) - Correct permissions evaluation for ASN range child ASNs view * [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object --- From 41eae1bc19d1177ad853918119627f14ac4f399e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Nov 2023 15:45:49 -0500 Subject: [PATCH 29/37] Release v3.6.5 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- base_requirements.txt | 3 ++- docs/release-notes/version-3.6.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 8 ++++---- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index f4afe3f98..48c14a2da 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.4 + placeholder: v3.6.5 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 9bf991e6e..0525659ae 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.4 + placeholder: v3.6.5 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index 9863984ca..6e3c5ba19 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -53,7 +53,8 @@ django-tables2 # User-defined tags for objects # https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst -django-taggit +# TODO: Upgrade to v5.0 for NetBox v3.7 beta +django-taggit<5.0 # A Django field for representing time zones # https://github.com/mfogel/django-timezone-field/ diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index dfa4abc50..646c2019e 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -1,6 +1,6 @@ # NetBox v3.6 -## v3.6.5 (FUTURE) +## v3.6.5 (2023-11-09) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e3c716a16..eac1c3c37 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.6.5-dev' +VERSION = '3.6.5' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 9f9176ea2..16bafe62f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ bleach==6.1.0 -Django==4.2.6 +Django==4.2.7 django-cors-headers==4.3.0 django-debug-toolbar==4.2.0 django-filter==23.3 @@ -21,16 +21,16 @@ graphene-django==3.0.0 gunicorn==21.2.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.4.6 +mkdocs-material==9.4.8 mkdocstrings[python-legacy]==0.23.0 netaddr==0.9.0 Pillow==10.1.0 psycopg[binary,pool]==3.1.12 PyYAML==6.0.1 requests==2.31.0 -sentry-sdk==1.32.0 +sentry-sdk==1.34.0 social-auth-app-django==5.4.0 -social-auth-core[openidconnect]==4.4.2 +social-auth-core[openidconnect]==4.5.0 svgwrite==1.4.3 tablib==3.5.0 tzdata==2023.3 From d52a6d3b1090f408882e0875e04fb739fb492b75 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Nov 2023 16:04:38 -0500 Subject: [PATCH 30/37] PRVB --- docs/release-notes/version-3.6.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 646c2019e..b8d316218 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -1,5 +1,9 @@ # NetBox v3.6 +## v3.6.6 (FUTURE) + +--- + ## v3.6.5 (2023-11-09) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index eac1c3c37..5a5a89984 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.6.5' +VERSION = '3.6.6-dev' # Hostname HOSTNAME = platform.node() From 2562c8745c138775eb2f48a5a5af01ee98017895 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 6 Nov 2023 15:36:59 -0500 Subject: [PATCH 31/37] Closes #14156: Add custom field support for contact assignments --- .../tenancy/contactassignment_edit.html | 7 +++++++ netbox/tenancy/api/serializers.py | 2 +- netbox/tenancy/filtersets.py | 5 ++--- netbox/tenancy/forms/model_forms.py | 7 ++----- netbox/tenancy/graphql/types.py | 4 ++-- .../0012_contactassignment_custom_fields.py | 19 +++++++++++++++++++ netbox/tenancy/models/contacts.py | 4 ++-- 7 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 netbox/tenancy/migrations/0012_contactassignment_custom_fields.py diff --git a/netbox/templates/tenancy/contactassignment_edit.html b/netbox/templates/tenancy/contactassignment_edit.html index ef2036976..09a267c04 100644 --- a/netbox/templates/tenancy/contactassignment_edit.html +++ b/netbox/templates/tenancy/contactassignment_edit.html @@ -25,4 +25,11 @@ {% render_field form.priority %} {% render_field form.tags %} + +
+
+
{% trans "Custom Fields" %}
+
+ {% render_custom_fields form %} +
{% endblock %} diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index da0ad04bd..118cafd81 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -105,7 +105,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer): model = ContactAssignment fields = [ 'id', 'url', 'display', 'content_type', 'object_id', 'object', 'contact', 'role', 'priority', 'tags', - 'created', 'last_updated', + 'custom_fields', 'created', 'last_updated', ] @extend_schema_field(OpenApiTypes.OBJECT) diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 0f4900f54..72f03e98a 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -3,11 +3,10 @@ from django.db.models import Q from django.utils.translation import gettext as _ from extras.filters import TagFilter -from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet +from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .models import * - __all__ = ( 'ContactAssignmentFilterSet', 'ContactFilterSet', @@ -81,7 +80,7 @@ class ContactFilterSet(NetBoxModelFilterSet): ) -class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet): +class ContactAssignmentFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index 5b1051c68..9a53eba17 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -1,12 +1,9 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from extras.forms.mixins import TagsMixin -from extras.models import Tag from netbox.forms import NetBoxModelForm from tenancy.models import * -from utilities.forms.mixins import BootstrapMixin -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField +from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField __all__ = ( 'ContactAssignmentForm', @@ -122,7 +119,7 @@ class ContactForm(NetBoxModelForm): } -class ContactAssignmentForm(BootstrapMixin, TagsMixin, forms.ModelForm): +class ContactAssignmentForm(NetBoxModelForm): group = DynamicModelChoiceField( label=_('Group'), queryset=ContactGroup.objects.all(), diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index 727aa2eac..aab02b121 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -1,6 +1,6 @@ import graphene -from extras.graphql.mixins import TagsMixin +from extras.graphql.mixins import CustomFieldsMixin, TagsMixin from tenancy import filtersets, models from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType @@ -69,7 +69,7 @@ class ContactGroupType(OrganizationalObjectType): filterset_class = filtersets.ContactGroupFilterSet -class ContactAssignmentType(TagsMixin, BaseObjectType): +class ContactAssignmentType(CustomFieldsMixin, TagsMixin, BaseObjectType): class Meta: model = models.ContactAssignment diff --git a/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py b/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py new file mode 100644 index 000000000..ee6726822 --- /dev/null +++ b/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.6 on 2023-11-06 20:23 + +from django.db import migrations, models +import utilities.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0011_contactassignment_tags'), + ] + + operations = [ + migrations.AddField( + model_name='contactassignment', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index e8327248d..6cc26fa83 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -5,7 +5,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel -from netbox.models.features import TagsMixin +from netbox.models.features import CustomFieldsMixin, TagsMixin from tenancy.choices import * __all__ = ( @@ -109,7 +109,7 @@ class Contact(PrimaryModel): return reverse('tenancy:contact', args=[self.pk]) -class ContactAssignment(ChangeLoggedModel, TagsMixin): +class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE From 3d20276f558a493433136a7aa496e0f79a5c4db4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Nov 2023 16:21:09 -0500 Subject: [PATCH 32/37] Closes #14134: Display additional object attributes in global search results (#14154) * WIP * Add display_attrs for all indexers * Linkify object attributes * Clean up prefetch logic * Use tooltips for display attributes * Simplify template code * Introduce get_indexer() utility function * Add to examples in docs * Use tooltips to display long strings --- docs/development/search.md | 1 + docs/plugins/development/search.md | 3 ++ netbox/circuits/search.py | 6 ++++ netbox/core/search.py | 1 + netbox/dcim/search.py | 31 +++++++++++++++++++ netbox/extras/models/search.py | 19 ++++++++++++ netbox/ipam/search.py | 16 ++++++++++ netbox/netbox/search/__init__.py | 2 ++ netbox/netbox/search/backends.py | 43 ++++++++++++++++++++++----- netbox/netbox/search/utils.py | 14 +++++++++ netbox/netbox/tables/tables.py | 5 ++++ netbox/netbox/tables/template_code.py | 18 +++++++++++ netbox/tenancy/search.py | 5 ++++ netbox/virtualization/search.py | 5 ++++ netbox/wireless/search.py | 3 ++ 15 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 netbox/netbox/search/utils.py create mode 100644 netbox/netbox/tables/template_code.py diff --git a/docs/development/search.md b/docs/development/search.md index 6ccffa7af..1c4eec169 100644 --- a/docs/development/search.md +++ b/docs/development/search.md @@ -17,6 +17,7 @@ class MyModelIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('site', 'device', 'status', 'description') ``` A SearchIndex subclass defines both its model and a list of two-tuples specifying which model fields to be indexed and the weight (precedence) associated with each. Guidance on weight assignment for fields is provided below. diff --git a/docs/plugins/development/search.md b/docs/plugins/development/search.md index e3b861f00..e54844cf0 100644 --- a/docs/plugins/development/search.md +++ b/docs/plugins/development/search.md @@ -14,8 +14,11 @@ class MyModelIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('site', 'device', 'status', 'description') ``` +Fields listed in `display_attrs` will not be cached for search, but will be displayed alongside the object when it appears in global search results. This is helpful for conveying to the user additional information about an object. + To register one or more indexes with NetBox, define a list named `indexes` at the end of this file: ```python diff --git a/netbox/circuits/search.py b/netbox/circuits/search.py index b80f92d4d..c22b400eb 100644 --- a/netbox/circuits/search.py +++ b/netbox/circuits/search.py @@ -10,6 +10,7 @@ class CircuitIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('provider', 'provider_account', 'type', 'status', 'tenant', 'description') @register_search @@ -22,6 +23,7 @@ class CircuitTerminationIndex(SearchIndex): ('port_speed', 2000), ('upstream_speed', 2000), ) + display_attrs = ('circuit', 'site', 'provider_network', 'description') @register_search @@ -32,6 +34,7 @@ class CircuitTypeIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -42,6 +45,7 @@ class ProviderIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('description',) class ProviderAccountIndex(SearchIndex): @@ -51,6 +55,7 @@ class ProviderAccountIndex(SearchIndex): ('account', 200), ('comments', 5000), ) + display_attrs = ('provider', 'account', 'description') @register_search @@ -62,3 +67,4 @@ class ProviderNetworkIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('provider', 'service_id', 'description') diff --git a/netbox/core/search.py b/netbox/core/search.py index e6d3005e6..5ea9db761 100644 --- a/netbox/core/search.py +++ b/netbox/core/search.py @@ -11,6 +11,7 @@ class DataSourceIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('type', 'status', 'description') @register_search diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index f70c729f4..0784cfaf8 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -10,6 +10,7 @@ class CableIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('type', 'status', 'tenant', 'label', 'description') @register_search @@ -21,6 +22,7 @@ class ConsolePortIndex(SearchIndex): ('description', 500), ('speed', 2000), ) + display_attrs = ('device', 'label', 'description') @register_search @@ -32,6 +34,7 @@ class ConsoleServerPortIndex(SearchIndex): ('description', 500), ('speed', 2000), ) + display_attrs = ('device', 'label', 'description') @register_search @@ -44,6 +47,9 @@ class DeviceIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ( + 'site', 'location', 'rack', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'description', + ) @register_search @@ -54,6 +60,7 @@ class DeviceBayIndex(SearchIndex): ('label', 200), ('description', 500), ) + display_attrs = ('device', 'label', 'description') @register_search @@ -64,6 +71,7 @@ class DeviceRoleIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -75,6 +83,7 @@ class DeviceTypeIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('manufacturer', 'part_number', 'description') @register_search @@ -85,6 +94,7 @@ class FrontPortIndex(SearchIndex): ('label', 200), ('description', 500), ) + display_attrs = ('device', 'label', 'description') @register_search @@ -99,6 +109,7 @@ class InterfaceIndex(SearchIndex): ('mtu', 2000), ('speed', 2000), ) + display_attrs = ('device', 'label', 'description') @register_search @@ -112,6 +123,7 @@ class InventoryItemIndex(SearchIndex): ('description', 500), ('part_id', 2000), ) + display_attrs = ('device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description') @register_search @@ -122,6 +134,7 @@ class LocationIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('site', 'status', 'tenant', 'description') @register_search @@ -132,6 +145,7 @@ class ManufacturerIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -143,6 +157,7 @@ class ModuleIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'description') @register_search @@ -153,6 +168,7 @@ class ModuleBayIndex(SearchIndex): ('label', 200), ('description', 500), ) + display_attrs = ('device', 'label', 'position', 'description') @register_search @@ -164,6 +180,7 @@ class ModuleTypeIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('manufacturer', 'model', 'part_number', 'description') @register_search @@ -174,6 +191,7 @@ class PlatformIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('manufacturer', 'description') @register_search @@ -184,6 +202,7 @@ class PowerFeedIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('power_panel', 'rack', 'status', 'description') @register_search @@ -194,6 +213,7 @@ class PowerOutletIndex(SearchIndex): ('label', 200), ('description', 500), ) + display_attrs = ('device', 'label', 'description') @register_search @@ -204,6 +224,7 @@ class PowerPanelIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('site', 'location', 'description') @register_search @@ -216,6 +237,7 @@ class PowerPortIndex(SearchIndex): ('maximum_draw', 2000), ('allocated_draw', 2000), ) + display_attrs = ('device', 'label', 'description') @register_search @@ -229,6 +251,7 @@ class RackIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('site', 'location', 'facility_id', 'tenant', 'status', 'role', 'description') @register_search @@ -238,6 +261,7 @@ class RackReservationIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('rack', 'tenant', 'user', 'description') @register_search @@ -248,6 +272,7 @@ class RackRoleIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('device', 'label', 'description',) @register_search @@ -258,6 +283,7 @@ class RearPortIndex(SearchIndex): ('label', 200), ('description', 500), ) + display_attrs = ('device', 'label', 'description') @register_search @@ -268,6 +294,7 @@ class RegionIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('parent', 'description') @register_search @@ -282,6 +309,7 @@ class SiteIndex(SearchIndex): ('shipping_address', 2000), ('comments', 5000), ) + display_attrs = ('region', 'group', 'status', 'description') @register_search @@ -292,6 +320,7 @@ class SiteGroupIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('parent', 'description') @register_search @@ -303,6 +332,7 @@ class VirtualChassisIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('master', 'domain', 'description') @register_search @@ -314,3 +344,4 @@ class VirtualDeviceContextIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('device', 'status', 'identifier', 'description') diff --git a/netbox/extras/models/search.py b/netbox/extras/models/search.py index b3327d510..bebcabd31 100644 --- a/netbox/extras/models/search.py +++ b/netbox/extras/models/search.py @@ -4,7 +4,10 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.utils.translation import gettext_lazy as _ +from netbox.search.utils import get_indexer +from netbox.registry import registry from utilities.fields import RestrictedGenericForeignKey +from utilities.utils import content_type_identifier from ..fields import CachedValueField __all__ = ( @@ -58,3 +61,19 @@ class CachedValue(models.Model): def __str__(self): return f'{self.object_type} {self.object_id}: {self.field}={self.value}' + + @property + def display_attrs(self): + """ + Render any display attributes associated with this search result. + """ + indexer = get_indexer(self.object_type) + attrs = {} + for attr in indexer.display_attrs: + name = self.object._meta.get_field(attr).verbose_name + if value := getattr(self.object, attr): + if display_func := getattr(self.object, f'get_{attr}_display', None): + attrs[name] = display_func() + else: + attrs[name] = value + return attrs diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index 4d97bf5f0..c08acce1b 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -11,6 +11,7 @@ class AggregateIndex(SearchIndex): ('date_added', 2000), ('comments', 5000), ) + display_attrs = ('rir', 'tenant', 'description') @register_search @@ -20,6 +21,7 @@ class ASNIndex(SearchIndex): ('asn', 100), ('description', 500), ) + display_attrs = ('rir', 'tenant', 'description') @register_search @@ -28,6 +30,7 @@ class ASNRangeIndex(SearchIndex): fields = ( ('description', 500), ) + display_attrs = ('rir', 'tenant', 'description') @register_search @@ -39,6 +42,7 @@ class FHRPGroupIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('protocol', 'auth_type', 'description') @register_search @@ -50,6 +54,7 @@ class IPAddressIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('vrf', 'tenant', 'status', 'role', 'description') @register_search @@ -61,6 +66,7 @@ class IPRangeIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('vrf', 'tenant', 'status', 'role', 'description') @register_search @@ -72,6 +78,7 @@ class L2VPNIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('type', 'identifier', 'tenant', 'description') @register_search @@ -82,6 +89,7 @@ class PrefixIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description') @register_search @@ -92,6 +100,7 @@ class RIRIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -102,6 +111,7 @@ class RoleIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -112,6 +122,7 @@ class RouteTargetIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('tenant', 'description') @register_search @@ -122,6 +133,7 @@ class ServiceIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('device', 'virtual_machine', 'description') @register_search @@ -132,6 +144,7 @@ class ServiceTemplateIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('description',) @register_search @@ -143,6 +156,7 @@ class VLANIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('site', 'group', 'tenant', 'status', 'role', 'description') @register_search @@ -154,6 +168,7 @@ class VLANGroupIndex(SearchIndex): ('description', 500), ('max_vid', 2000), ) + display_attrs = ('scope_type', 'min_vid', 'max_vid', 'description') @register_search @@ -165,3 +180,4 @@ class VRFIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('rd', 'tenant', 'description') diff --git a/netbox/netbox/search/__init__.py b/netbox/netbox/search/__init__.py index 6d53e9a97..590188f21 100644 --- a/netbox/netbox/search/__init__.py +++ b/netbox/netbox/search/__init__.py @@ -33,10 +33,12 @@ class SearchIndex: category: The label of the group under which this indexer is categorized (for form field display). If none, the name of the model's app will be used. fields: An iterable of two-tuples defining the model fields to be indexed and the weight associated with each. + display_attrs: An iterable of additional object attributes to include when displaying search results. """ model = None category = None fields = () + display_attrs = () @staticmethod def get_field_type(instance, field_name): diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index 4487b6bb8..1fb23a37c 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -3,7 +3,8 @@ from collections import defaultdict from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ImproperlyConfigured -from django.db.models import F, Window, Q +from django.db.models import F, Window, Q, prefetch_related_objects +from django.db.models.fields.related import ForeignKey from django.db.models.functions import window from django.db.models.signals import post_delete, post_save from django.utils.module_loading import import_string @@ -13,7 +14,7 @@ from netaddr.core import AddrFormatError from extras.models import CachedValue, CustomField from netbox.registry import registry from utilities.querysets import RestrictedPrefetch -from utilities.utils import title +from utilities.utils import content_type_identifier, title from . import FieldTypes, LookupTypes, get_indexer DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL @@ -103,17 +104,17 @@ class CachedValueSearchBackend(SearchBackend): def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE): + # Build the filter used to find relevant CachedValue records query_filter = Q(**{f'value__{lookup}': value}) - if object_types: + # Limit results by object type query_filter &= Q(object_type__in=object_types) - if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH): - # Partial string matches are valid only on string values + # "Starts/ends with" matches are valid only on string values query_filter &= Q(type=FieldTypes.STRING) - - if lookup == LookupTypes.PARTIAL: + elif lookup == LookupTypes.PARTIAL: try: + # If the value looks like an IP address, add an extra match for CIDR values address = str(netaddr.IPNetwork(value.strip()).cidr) query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address) except (AddrFormatError, ValueError): @@ -129,6 +130,12 @@ class CachedValueSearchBackend(SearchBackend): ) )[:MAX_RESULTS] + # Gather all ContentTypes present in the search results (used for prefetching related + # objects). This must be done before generating the final results list, which returns + # a RawQuerySet. + content_type_ids = set(queryset.values_list('object_type', flat=True)) + content_types = ContentType.objects.filter(pk__in=content_type_ids) + # Construct a Prefetch to pre-fetch only those related objects for which the # user has permission to view. if user: @@ -144,12 +151,34 @@ class CachedValueSearchBackend(SearchBackend): params ) + # Iterate through each ContentType represented in the search results and prefetch any + # related objects necessary to render the prescribed display attributes (display_attrs). + for ct in content_types: + model = ct.model_class() + indexer = registry['search'].get(content_type_identifier(ct)) + if not (display_attrs := getattr(indexer, 'display_attrs', None)): + continue + + # Add ForeignKey fields to prefetch list + prefetch_fields = [] + for attr in display_attrs: + field = model._meta.get_field(attr) + if type(field) is ForeignKey: + prefetch_fields.append(f'object__{attr}') + + # Compile a list of all CachedValues referencing this object type, and prefetch + # any related objects + if prefetch_fields: + objects = [r for r in results if r.object_type == ct] + prefetch_related_objects(objects, *prefetch_fields) + # Omit any results pertaining to an object the user does not have permission to view ret = [] for r in results: if r.object is not None: r.name = str(r.object) ret.append(r) + return ret def cache(self, instances, indexer=None, remove_existing=True): diff --git a/netbox/netbox/search/utils.py b/netbox/netbox/search/utils.py new file mode 100644 index 000000000..824fbfb3d --- /dev/null +++ b/netbox/netbox/search/utils.py @@ -0,0 +1,14 @@ +from netbox.registry import registry +from utilities.utils import content_type_identifier + +__all__ = ( + 'get_indexer', +) + + +def get_indexer(content_type): + """ + Return the registered search indexer for the given ContentType. + """ + ct_identifier = content_type_identifier(content_type) + return registry['search'].get(ct_identifier) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 97ab44362..cb53310cc 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -15,6 +15,7 @@ from extras.choices import CustomFieldVisibilityChoices from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.utils import get_viewname, highlight_string, title +from .template_code import * __all__ = ( 'BaseTable', @@ -236,6 +237,10 @@ class SearchTable(tables.Table): value = tables.Column( verbose_name=_('Value'), ) + attrs = columns.TemplateColumn( + template_code=SEARCH_RESULT_ATTRS, + verbose_name=_('Attributes') + ) trim_length = 30 diff --git a/netbox/netbox/tables/template_code.py b/netbox/netbox/tables/template_code.py new file mode 100644 index 000000000..24439eeb6 --- /dev/null +++ b/netbox/netbox/tables/template_code.py @@ -0,0 +1,18 @@ +SEARCH_RESULT_ATTRS = """ +{% for name, value in record.display_attrs.items %} + 40 %} data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ value }}"{% endif %} + > + {{ name|bettertitle }}: + {% with url=value.get_absolute_url %} + {% if url %}{% endif %} + {% if value|length > 40 %} + {{ value|truncatechars:"40" }} + {% else %} + {{ value }} + {% endif %} + {% if url %}{% endif %} + {% endwith %} + +{% endfor %} +""" diff --git a/netbox/tenancy/search.py b/netbox/tenancy/search.py index bee497608..56903d6b1 100644 --- a/netbox/tenancy/search.py +++ b/netbox/tenancy/search.py @@ -15,6 +15,7 @@ class ContactIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('group', 'title', 'phone', 'email', 'description') @register_search @@ -25,6 +26,7 @@ class ContactGroupIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -35,6 +37,7 @@ class ContactRoleIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -46,6 +49,7 @@ class TenantIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('group', 'description') @register_search @@ -56,3 +60,4 @@ class TenantGroupIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) diff --git a/netbox/virtualization/search.py b/netbox/virtualization/search.py index 643a9f6de..12174dda4 100644 --- a/netbox/virtualization/search.py +++ b/netbox/virtualization/search.py @@ -10,6 +10,7 @@ class ClusterIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('type', 'group', 'status', 'tenant', 'site', 'description') @register_search @@ -20,6 +21,7 @@ class ClusterGroupIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -30,6 +32,7 @@ class ClusterTypeIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -40,6 +43,7 @@ class VirtualMachineIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'description') @register_search @@ -51,3 +55,4 @@ class VMInterfaceIndex(SearchIndex): ('description', 500), ('mtu', 2000), ) + display_attrs = ('virtual_machine', 'description') diff --git a/netbox/wireless/search.py b/netbox/wireless/search.py index 1f8097cd7..c8ac023cc 100644 --- a/netbox/wireless/search.py +++ b/netbox/wireless/search.py @@ -11,6 +11,7 @@ class WirelessLANIndex(SearchIndex): ('auth_psk', 2000), ('comments', 5000), ) + display_attrs = ('group', 'status', 'vlan', 'tenant', 'description') @register_search @@ -21,6 +22,7 @@ class WirelessLANGroupIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -32,3 +34,4 @@ class WirelessLinkIndex(SearchIndex): ('auth_psk', 2000), ('comments', 5000), ) + display_attrs = ('status', 'tenant', 'description') From 840b7d804c974bc3c1e3c2884610dd1300a37b4a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Nov 2023 16:33:35 -0500 Subject: [PATCH 33/37] Closes #13645: Make Sentry integration optional (#14197) --- base_requirements.txt | 4 ---- docs/administration/error-reporting.md | 18 +++--------------- docs/configuration/error-reporting.md | 3 +++ docs/installation/3-netbox.md | 11 +++++++++++ netbox/netbox/settings.py | 21 +++++++++------------ netbox/netbox/views/errors.py | 5 +++-- requirements.txt | 1 - 7 files changed, 29 insertions(+), 34 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index 6e3c5ba19..b659c9e8d 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -126,10 +126,6 @@ PyYAML # https://github.com/psf/requests/blob/main/HISTORY.md requests -# Sentry SDK -# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md -sentry-sdk - # Social authentication framework # https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md social-auth-core diff --git a/docs/administration/error-reporting.md b/docs/administration/error-reporting.md index 162998774..ccc0a84a5 100644 --- a/docs/administration/error-reporting.md +++ b/docs/administration/error-reporting.md @@ -4,27 +4,15 @@ ### Enabling Error Reporting -NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis. - -```python -SENTRY_ENABLED = True -``` - -### Using a Custom DSN - -If you prefer instead to use your own Sentry ingestor, you'll need to first create a new project under your Sentry account to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below: - -``` -https://examplePublicKey@o0.ingest.sentry.io/0 -``` - -Once you have obtained a DSN, configure Sentry in NetBox's `configuration.py` file with the following parameters: +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 SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" ``` +Setting `SENTRY_ENABLED` to False will disable the Sentry integration. + ### Assigning Tags You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter: diff --git a/docs/configuration/error-reporting.md b/docs/configuration/error-reporting.md index d1c47e2fb..8c3526dec 100644 --- a/docs/configuration/error-reporting.md +++ b/docs/configuration/error-reporting.md @@ -18,6 +18,9 @@ Default: False Set to True to enable automatic error reporting via [Sentry](https://sentry.io/). +!!! note + The `sentry-sdk` Python package is required to enable Sentry integration. + --- ## SENTRY_SAMPLE_RATE diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 0713d12e3..4043416a3 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -227,6 +227,17 @@ sudo sh -c "echo 'boto3' >> /opt/netbox/local_requirements.txt" !!! info These packages were previously required in NetBox v3.5 but now are optional. +### Sentry Integration + +NetBox may be configured to send error reports to [Sentry](../administration/error-reporting.md) for analysis. This integration requires installation of the `sentry-sdk` Python library. + +```no-highlight +sudo sh -c "echo 'sentry-sdk' >> /opt/netbox/local_requirements.txt" +``` + +!!! info + Sentry integration was previously included by default in NetBox v3.6 but is now optional. + ## Run the Upgrade Script Once NetBox has been configured, we're ready to proceed with the actual installation. We'll run the packaged upgrade script (`upgrade.sh`) to perform the following actions: diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 09ee38d6a..465389a11 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -9,12 +9,14 @@ import warnings from urllib.parse import urlencode, urlsplit import django -import sentry_sdk from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator from django.utils.encoding import force_str -from sentry_sdk.integrations.django import DjangoIntegration +try: + import sentry_sdk +except ModuleNotFoundError: + pass from netbox.config import PARAMS from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW @@ -39,8 +41,6 @@ if sys.version_info < (3, 8): f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})" ) -DEFAULT_SENTRY_DSN = 'https://198cf560b29d4054ab8e583a1d10ea58@o1242133.ingest.sentry.io/6396485' - # # Configuration import # @@ -158,7 +158,7 @@ RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend') SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False) -SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN) +SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None) SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0) SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0) @@ -517,12 +517,12 @@ SERIALIZATION_MODULES = { # if SENTRY_ENABLED: + try: + from sentry_sdk.integrations.django import DjangoIntegration + except ModuleNotFoundError: + raise ImproperlyConfigured("SENTRY_ENABLED is True but the sentry-sdk package is not installed.") if not SENTRY_DSN: raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.") - # If using the default DSN, force sampling rates - if SENTRY_DSN == DEFAULT_SENTRY_DSN: - SENTRY_SAMPLE_RATE = 1.0 - SENTRY_TRACES_SAMPLE_RATE = 0 # Initialize the SDK sentry_sdk.init( dsn=SENTRY_DSN, @@ -537,9 +537,6 @@ if SENTRY_ENABLED: # Assign any configured tags for k, v in SENTRY_TAGS.items(): sentry_sdk.set_tag(k, v) - # If using the default DSN, append a unique deployment ID tag for error correlation - if SENTRY_DSN == DEFAULT_SENTRY_DSN: - sentry_sdk.set_tag('netbox.deployment_id', DEPLOYMENT_ID) # diff --git a/netbox/netbox/views/errors.py b/netbox/netbox/views/errors.py index d1a8ccd36..a0f783ed6 100644 --- a/netbox/netbox/views/errors.py +++ b/netbox/netbox/views/errors.py @@ -9,7 +9,6 @@ from django.template.exceptions import TemplateDoesNotExist from django.views.decorators.csrf import requires_csrf_token from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found from django.views.generic import View -from sentry_sdk import capture_message from netbox.plugins.utils import get_installed_plugins @@ -34,7 +33,9 @@ def handler_404(request, exception): """ Wrap Django's default 404 handler to enable Sentry reporting. """ - capture_message("Page not found", level="error") + if settings.SENTRY_ENABLED: + from sentry_sdk import capture_message + capture_message("Page not found", level="error") return page_not_found(request, exception) diff --git a/requirements.txt b/requirements.txt index 16bafe62f..45fb12f80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,6 @@ Pillow==10.1.0 psycopg[binary,pool]==3.1.12 PyYAML==6.0.1 requests==2.31.0 -sentry-sdk==1.34.0 social-auth-app-django==5.4.0 social-auth-core[openidconnect]==4.5.0 svgwrite==1.4.3 From 69a4c310729a799ed3df05fa1c6684cc9a7ca588 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Nov 2023 12:02:32 -0500 Subject: [PATCH 34/37] Closes #13794: Dynamically populate related objects list under tenant view (#14196) * Closes #13794: Dynamically populate related objects list under tenant view * get_related_models() should sort models alphabetically by default * Reference Meta.related_objects instead of calling get_fields() --- netbox/tenancy/views.py | 35 +++-------------------------------- netbox/utilities/utils.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 33 deletions(-) diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 4217f8d15..27d5750ac 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -2,14 +2,9 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ -from circuits.models import Circuit -from dcim.models import Cable, Device, Location, PowerFeed, Rack, RackReservation, Site, VirtualDeviceContext -from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF from netbox.views import generic -from utilities.utils import count_related +from utilities.utils import count_related, get_related_models from utilities.views import register_model_view, ViewTab -from virtualization.models import VirtualMachine, Cluster -from wireless.models import WirelessLAN, WirelessLink from . import filtersets, forms, tables from .models import * @@ -132,32 +127,8 @@ class TenantView(generic.ObjectView): def get_extra_context(self, request, instance): related_models = [ - # DCIM - (Site.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Rack.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Location.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Device.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Cable.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (PowerFeed.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - # IPAM - (VRF.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Prefix.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (IPRange.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (ASN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (VLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - # Circuits - (Circuit.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - # Virtualization - (VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Cluster.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - # Wireless - (WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id') + for model, field in get_related_models(Tenant) ] return { diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index feb28c2d8..d7232d41b 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -8,7 +8,7 @@ from itertools import count, groupby import bleach from django.contrib.contenttypes.models import ContentType from django.core import serializers -from django.db.models import Count, OuterRef, Subquery +from django.db.models import Count, ManyToOneRel, OuterRef, Subquery from django.db.models.functions import Coalesce from django.http import QueryDict from django.utils import timezone @@ -567,3 +567,20 @@ def local_now(): Return the current date & time in the system timezone. """ return localtime(timezone.now()) + + +def get_related_models(model, ordered=True): + """ + Return a list of all models which have a ForeignKey to the given model and the name of the field. For example, + `get_related_models(Tenant)` will return all models which have a ForeignKey relationship to Tenant. + """ + related_models = [ + (field.related_model, field.remote_field.name) + for field in model._meta.related_objects + if type(field) is ManyToOneRel + ] + + if ordered: + return sorted(related_models, key=lambda x: x[0]._meta.verbose_name) + + return related_models From e15647a2ce968866cadc165e68aaad9dbf37ac53 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Nov 2023 12:12:51 -0500 Subject: [PATCH 35/37] Closes #14153: Filter ContentTypes by supported feature (#14191) * WIP * Remove FeatureQuery * Standardize use of proxy ContentType for models * Remove TODO * Correctly filter BookmarksWidget object_types choices * Add feature-specific object type validation --- netbox/core/forms/filtersets.py | 4 +- netbox/core/migrations/0003_job.py | 3 +- netbox/core/models/contenttypes.py | 18 ++++++++ netbox/core/models/data.py | 3 +- netbox/core/models/jobs.py | 16 +++++-- netbox/dcim/models/cables.py | 5 +-- .../dcim/models/device_component_templates.py | 3 +- netbox/dcim/models/device_components.py | 3 +- netbox/extras/api/serializers.py | 15 ++++--- netbox/extras/dashboard/widgets.py | 16 ++++--- netbox/extras/forms/bulk_import.py | 13 ++---- netbox/extras/forms/filtersets.py | 15 ++++--- netbox/extras/forms/model_forms.py | 19 +++------ netbox/extras/migrations/0001_squashed.py | 10 ++--- .../migrations/0094_tag_object_types.py | 3 +- netbox/extras/models/change_logging.py | 18 ++++++-- netbox/extras/models/customfields.py | 9 ++-- netbox/extras/models/models.py | 42 +++++++++++++------ netbox/extras/models/search.py | 3 +- netbox/extras/models/staging.py | 3 +- netbox/extras/models/tags.py | 6 +-- netbox/extras/utils.py | 25 ----------- netbox/ipam/models/fhrp.py | 5 +-- netbox/ipam/models/ip.py | 4 +- netbox/ipam/models/l2vpn.py | 4 +- netbox/ipam/models/vlans.py | 3 +- netbox/netbox/models/features.py | 2 +- netbox/tenancy/forms/filtersets.py | 6 +-- netbox/tenancy/models/contacts.py | 14 ++++++- netbox/users/models.py | 4 +- 30 files changed, 152 insertions(+), 142 deletions(-) diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 4d0acbb77..a567a9fed 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -1,12 +1,10 @@ from django import forms from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from core.choices import * from core.models import * from extras.forms.mixins import SavedFiltersMixin -from extras.utils import FeatureQuery from netbox.forms import NetBoxModelFilterSetForm from netbox.utils import get_data_backend_choices from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm @@ -69,7 +67,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): ) object_type = ContentTypeChoiceField( label=_('Object Type'), - queryset=ContentType.objects.filter(FeatureQuery('jobs').get_query()), + queryset=ContentType.objects.with_feature('jobs'), required=False, ) status = forms.MultipleChoiceField( diff --git a/netbox/core/migrations/0003_job.py b/netbox/core/migrations/0003_job.py index ab6f058ff..f2fe41afb 100644 --- a/netbox/core/migrations/0003_job.py +++ b/netbox/core/migrations/0003_job.py @@ -4,7 +4,6 @@ from django.conf import settings import django.core.validators from django.db import migrations, models import django.db.models.deletion -import extras.utils class Migration(migrations.Migration): @@ -30,7 +29,7 @@ class Migration(migrations.Migration): ('status', models.CharField(default='pending', max_length=30)), ('data', models.JSONField(blank=True, null=True)), ('job_id', models.UUIDField(unique=True)), - ('object_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')), + ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), ], options={ diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index 18c16a1c2..0731871ec 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -21,6 +21,24 @@ class ContentTypeManager(ContentTypeManager_): q |= Q(app_label=app_label, model__in=models) return self.get_queryset().filter(q) + def with_feature(self, feature): + """ + Return the ContentTypes only for models which are registered as supporting the specified feature. For example, + we can find all ContentTypes for models which support webhooks with + + ContentType.objects.with_feature('webhooks') + """ + if feature not in registry['model_features']: + raise KeyError( + f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}" + ) + + q = Q() + for app_label, models in registry['model_features'][feature].items(): + q |= Q(app_label=app_label, model__in=models) + + return self.get_queryset().filter(q) + class ContentType(ContentType_): """ diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 78f05e462..cf40c0bd5 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -6,7 +6,6 @@ from urllib.parse import urlparse from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import RegexValidator from django.db import models @@ -368,7 +367,7 @@ class AutoSyncRecord(models.Model): related_name='+' ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE, related_name='+' ) diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 4e9a93bfb..5b9b41e53 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -3,7 +3,7 @@ import uuid import django_rq from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models from django.urls import reverse @@ -11,8 +11,8 @@ from django.utils import timezone from django.utils.translation import gettext as _ from core.choices import JobStatusChoices +from core.models import ContentType from extras.constants import EVENT_JOB_END, EVENT_JOB_START -from extras.utils import FeatureQuery from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT from utilities.querysets import RestrictedQuerySet @@ -28,9 +28,8 @@ class Job(models.Model): Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script). """ object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', related_name='jobs', - limit_choices_to=FeatureQuery('jobs'), on_delete=models.CASCADE, ) object_id = models.PositiveBigIntegerField( @@ -123,6 +122,15 @@ class Job(models.Model): def get_status_color(self): return JobStatusChoices.colors.get(self.status) + def clean(self): + super().clean() + + # Validate the assigned object type + if self.object_type not in ContentType.objects.with_feature('jobs'): + raise ValidationError( + _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type) + ) + @property def duration(self): if not self.completed: diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 9bcd824e6..e276ae3e5 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -2,7 +2,6 @@ import itertools from collections import defaultdict from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.db.models import Sum @@ -10,12 +9,12 @@ from django.dispatch import Signal from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node from netbox.models import ChangeLoggedModel, PrimaryModel - from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters @@ -258,7 +257,7 @@ class CableTermination(ChangeLoggedModel): verbose_name=_('end') ) termination_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=CABLE_TERMINATION_MODELS, on_delete=models.PROTECT, related_name='+' diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 5110835f4..fb3d6333e 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -1,5 +1,4 @@ from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -709,7 +708,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): db_index=True ) component_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, on_delete=models.PROTECT, related_name='+', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 94568459e..c24ed4d86 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1,7 +1,6 @@ from functools import cached_property from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -1181,7 +1180,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): db_index=True ) component_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=MODULAR_COMPONENT_MODELS, on_delete=models.PROTECT, related_name='+', diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index c1fad99ee..4864253ab 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,10 +1,10 @@ from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from core.api.serializers import JobSerializer from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer +from core.models import ContentType from dcim.api.nested_serializers import ( NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, @@ -14,7 +14,6 @@ from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from netbox.api.exceptions import SerializerNotFound from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer @@ -64,7 +63,7 @@ __all__ = ( class WebhookSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') content_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), + queryset=ContentType.objects.with_feature('webhooks'), many=True ) @@ -85,7 +84,7 @@ class WebhookSerializer(NetBoxModelSerializer): class CustomFieldSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail') content_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), + queryset=ContentType.objects.with_feature('custom_fields'), many=True ) type = ChoiceField(choices=CustomFieldTypeChoices) @@ -151,7 +150,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): class CustomLinkSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') content_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), + queryset=ContentType.objects.with_feature('custom_links'), many=True ) @@ -170,7 +169,7 @@ class CustomLinkSerializer(ValidatedModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') content_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), + queryset=ContentType.objects.with_feature('export_templates'), many=True ) data_source = NestedDataSourceSerializer( @@ -215,7 +214,7 @@ class SavedFilterSerializer(ValidatedModelSerializer): class BookmarkSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') object_type = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()), + queryset=ContentType.objects.with_feature('bookmarks'), ) object = serializers.SerializerMethodField(read_only=True) user = NestedUserSerializer() @@ -239,7 +238,7 @@ class BookmarkSerializer(ValidatedModelSerializer): class TagSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') object_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + queryset=ContentType.objects.with_feature('tags'), many=True, required=False ) diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 7f0229f88..8cfbb4c61 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -32,13 +32,20 @@ __all__ = ( ) -def get_content_type_labels(): +def get_object_type_choices(): return [ (content_type_identifier(ct), content_type_name(ct)) for ct in ContentType.objects.public().order_by('app_label', 'model') ] +def get_bookmarks_object_type_choices(): + return [ + (content_type_identifier(ct), content_type_name(ct)) + for ct in ContentType.objects.with_feature('bookmarks').order_by('app_label', 'model') + ] + + def get_models_from_content_types(content_types): """ Return a list of models corresponding to the given content types, identified by natural key. @@ -158,7 +165,7 @@ class ObjectCountsWidget(DashboardWidget): class ConfigForm(WidgetConfigForm): models = forms.MultipleChoiceField( - choices=get_content_type_labels + choices=get_object_type_choices ) filters = forms.JSONField( required=False, @@ -207,7 +214,7 @@ class ObjectListWidget(DashboardWidget): class ConfigForm(WidgetConfigForm): model = forms.ChoiceField( - choices=get_content_type_labels + choices=get_object_type_choices ) page_size = forms.IntegerField( required=False, @@ -343,8 +350,7 @@ class BookmarksWidget(DashboardWidget): class ConfigForm(WidgetConfigForm): object_types = forms.MultipleChoiceField( - # TODO: Restrict the choices by FeatureQuery('bookmarks') - choices=get_content_type_labels, + choices=get_bookmarks_object_type_choices, required=False ) order_by = forms.ChoiceField( diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 03a6d118b..9b3f59af0 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -6,7 +6,6 @@ from django.utils.translation import gettext_lazy as _ from core.models import ContentType from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from netbox.forms import NetBoxModelImportForm from utilities.forms import CSVModelForm from utilities.forms.fields import ( @@ -29,8 +28,7 @@ __all__ = ( class CustomFieldImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), + queryset=ContentType.objects.with_feature('custom_fields'), help_text=_("One or more assigned object types") ) type = CSVChoiceField( @@ -88,8 +86,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm): class CustomLinkImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links'), + queryset=ContentType.objects.with_feature('custom_links'), help_text=_("One or more assigned object types") ) @@ -104,8 +101,7 @@ class CustomLinkImportForm(CSVModelForm): class ExportTemplateImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('export_templates'), + queryset=ContentType.objects.with_feature('export_templates'), help_text=_("One or more assigned object types") ) @@ -142,8 +138,7 @@ class SavedFilterImportForm(CSVModelForm): class WebhookImportForm(NetBoxModelImportForm): content_types = CSVMultipleContentTypeField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('webhooks'), + queryset=ContentType.objects.with_feature('webhooks'), help_text=_("One or more assigned object types") ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index c0c8835b4..2d438377b 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -6,7 +6,6 @@ from core.models import ContentType, DataFile, DataSource from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from netbox.forms.base import NetBoxModelFilterSetForm from tenancy.models import Tenant, TenantGroup from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice @@ -44,7 +43,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): )), ) content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), + queryset=ContentType.objects.with_feature('custom_fields'), required=False, label=_('Object type') ) @@ -108,7 +107,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): ) content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), + queryset=ContentType.objects.with_feature('custom_links'), required=False ) enabled = forms.NullBooleanField( @@ -151,7 +150,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): } ) content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), + queryset=ContentType.objects.with_feature('export_templates'), required=False, label=_('Content types') ) @@ -179,7 +178,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm): ) content_type_id = ContentTypeChoiceField( label=_('Content type'), - queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()), + queryset=ContentType.objects.with_feature('image_attachments'), required=False ) name = forms.CharField( @@ -228,7 +227,7 @@ class WebhookFilterForm(NetBoxModelFilterSetForm): (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')), ) content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), + queryset=ContentType.objects.with_feature('webhooks'), required=False, label=_('Object type') ) @@ -284,12 +283,12 @@ class WebhookFilterForm(NetBoxModelFilterSetForm): class TagFilterForm(SavedFiltersMixin, FilterForm): model = Tag content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + queryset=ContentType.objects.with_feature('tags'), required=False, label=_('Tagged object type') ) for_object_type_id = ContentTypeChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + queryset=ContentType.objects.with_feature('tags'), required=False, label=_('Allowed object type') ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 7ab568ae0..755f7e836 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -10,7 +10,6 @@ from core.models import ContentType from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from netbox.config import get_config, PARAMS from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup @@ -43,8 +42,7 @@ __all__ = ( class CustomFieldForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), + queryset=ContentType.objects.with_feature('custom_fields') ) object_type = ContentTypeChoiceField( label=_('Object type'), @@ -114,8 +112,7 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links') + queryset=ContentType.objects.with_feature('custom_links') ) fieldsets = ( @@ -142,8 +139,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('export_templates') + queryset=ContentType.objects.with_feature('export_templates') ) template_code = forms.CharField( label=_('Template code'), @@ -210,8 +206,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm): class BookmarkForm(BootstrapMixin, forms.ModelForm): object_type = ContentTypeChoiceField( label=_('Object type'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('bookmarks').get_query() + queryset=ContentType.objects.with_feature('bookmarks') ) class Meta: @@ -222,8 +217,7 @@ class BookmarkForm(BootstrapMixin, forms.ModelForm): class WebhookForm(NetBoxModelForm): content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('webhooks') + queryset=ContentType.objects.with_feature('webhooks') ) fieldsets = ( @@ -257,8 +251,7 @@ class TagForm(BootstrapMixin, forms.ModelForm): slug = SlugField() object_types = ContentTypeMultipleChoiceField( label=_('Object types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('tags'), + queryset=ContentType.objects.with_feature('tags'), required=False ) diff --git a/netbox/extras/migrations/0001_squashed.py b/netbox/extras/migrations/0001_squashed.py index 2fdcc07eb..6f1f77e53 100644 --- a/netbox/extras/migrations/0001_squashed.py +++ b/netbox/extras/migrations/0001_squashed.py @@ -88,7 +88,7 @@ class Migration(migrations.Migration): ('secret', models.CharField(blank=True, max_length=255)), ('ssl_verification', models.BooleanField(default=True)), ('ca_file_path', models.CharField(blank=True, max_length=4096, null=True)), - ('content_types', models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('webhooks'), related_name='webhooks', to='contenttypes.ContentType')), + ('content_types', models.ManyToManyField(related_name='webhooks', to='contenttypes.ContentType')), ], options={ 'ordering': ('name',), @@ -151,7 +151,7 @@ class Migration(migrations.Migration): ('status', models.CharField(default='pending', max_length=30)), ('data', models.JSONField(blank=True, null=True)), ('job_id', models.UUIDField(unique=True)), - ('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')), + ('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), ], options={ @@ -184,7 +184,7 @@ class Migration(migrations.Migration): ('mime_type', models.CharField(blank=True, max_length=50)), ('file_extension', models.CharField(blank=True, max_length=15)), ('as_attachment', models.BooleanField(default=True)), - ('content_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ], options={ 'ordering': ['content_type', 'name'], @@ -201,7 +201,7 @@ class Migration(migrations.Migration): ('group_name', models.CharField(blank=True, max_length=50)), ('button_class', models.CharField(default='default', max_length=30)), ('new_window', models.BooleanField(default=False)), - ('content_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ], options={ 'ordering': ['group_name', 'weight', 'name'], @@ -223,7 +223,7 @@ class Migration(migrations.Migration): ('validation_maximum', models.PositiveIntegerField(blank=True, null=True)), ('validation_regex', models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex])), ('choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None)), - ('content_types', models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType')), + ('content_types', models.ManyToManyField(related_name='custom_fields', to='contenttypes.ContentType')), ], options={ 'ordering': ['weight', 'name'], diff --git a/netbox/extras/migrations/0094_tag_object_types.py b/netbox/extras/migrations/0094_tag_object_types.py index 944ef64b2..8bb760980 100644 --- a/netbox/extras/migrations/0094_tag_object_types.py +++ b/netbox/extras/migrations/0094_tag_object_types.py @@ -1,5 +1,4 @@ from django.db import migrations, models -import extras.utils class Migration(migrations.Migration): @@ -13,7 +12,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='tag', name='object_types', - field=models.ManyToManyField(blank=True, limit_choices_to=extras.utils.FeatureQuery('tags'), related_name='+', to='contenttypes.contenttype'), + field=models.ManyToManyField(blank=True, related_name='+', to='contenttypes.contenttype'), ), migrations.RenameIndex( model_name='taggeditem', diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index ac9c60998..5db0bba57 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -1,10 +1,11 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from extras.choices import * from ..querysets import ObjectChangeQuerySet @@ -48,7 +49,7 @@ class ObjectChange(models.Model): choices=ObjectChangeActionChoices ) changed_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.PROTECT, related_name='+' ) @@ -58,7 +59,7 @@ class ObjectChange(models.Model): fk_field='changed_object_id' ) related_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.PROTECT, related_name='+', blank=True, @@ -104,6 +105,17 @@ class ObjectChange(models.Model): self.user_name ) + def clean(self): + super().clean() + + # Validate the assigned object type + if self.changed_object_type not in ContentType.objects.with_feature('change_logging'): + raise ValidationError( + _("Change logging is not supported for this object type ({type}).").format( + type=self.changed_object_type + ) + ) + def save(self, *args, **kwargs): # Record the user's name and the object's representation as static strings diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 2cb12ed5b..939e8b73b 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -5,18 +5,16 @@ from datetime import datetime, date import django_filters from django import forms from django.conf import settings -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.validators import RegexValidator, ValidationError from django.db import models from django.urls import reverse -from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from extras.choices import * from extras.data import CHOICE_SETS -from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin from netbox.search import FieldTypes @@ -60,9 +58,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='custom_fields', - limit_choices_to=FeatureQuery('custom_fields'), help_text=_('The object(s) to which this field applies.') ) type = models.CharField( @@ -73,7 +70,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): help_text=_('The type of data this custom field holds') ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.PROTECT, blank=True, null=True, diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 90e8027b4..67b455ab4 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -3,7 +3,6 @@ import urllib.parse from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.validators import ValidationError from django.db import models @@ -14,10 +13,11 @@ from django.utils.formats import date_format from django.utils.translation import gettext, gettext_lazy as _ from rest_framework.utils.encoders import JSONEncoder +from core.models import ContentType from extras.choices import * from extras.conditions import ConditionSet from extras.constants import * -from extras.utils import FeatureQuery, image_upload +from extras.utils import image_upload from netbox.config import get_config from netbox.models import ChangeLoggedModel from netbox.models.features import ( @@ -45,10 +45,9 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo Each Webhook can be limited to firing only on certain actions or certain object types. """ content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='webhooks', verbose_name=_('object types'), - limit_choices_to=FeatureQuery('webhooks'), help_text=_("The object(s) to which this Webhook applies.") ) name = models.CharField( @@ -235,7 +234,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): code to be rendered with an object as context. """ content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='custom_links', help_text=_('The object type(s) to which this link applies.') ) @@ -331,7 +330,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='export_templates', help_text=_('The object type(s) to which this template applies.') ) @@ -440,7 +439,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): A set of predefined keyword parameters that can be reused to filter for specific objects. """ content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='saved_filters', help_text=_('The object type(s) to which this filter applies.') ) @@ -520,7 +519,7 @@ class ImageAttachment(ChangeLoggedModel): An uploaded image which is associated with an object. """ content_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) object_id = models.PositiveBigIntegerField() @@ -560,6 +559,15 @@ class ImageAttachment(ChangeLoggedModel): filename = self.image.name.rsplit('/', 1)[-1] return filename.split('_', 2)[2] + def clean(self): + super().clean() + + # Validate the assigned object type + if self.content_type not in ContentType.objects.with_feature('image_attachments'): + raise ValidationError( + _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.content_type) + ) + def delete(self, *args, **kwargs): _name = self.image.name @@ -605,7 +613,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded. """ assigned_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) assigned_object_id = models.PositiveBigIntegerField() @@ -644,9 +652,8 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat def clean(self): super().clean() - # Prevent the creation of journal entries on unsupported models - permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query()) - if self.assigned_object_type not in permitted_types: + # Validate the assigned object type + if self.assigned_object_type not in ContentType.objects.with_feature('journaling'): raise ValidationError( _("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type) ) @@ -664,7 +671,7 @@ class Bookmark(models.Model): auto_now_add=True ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.PROTECT ) object_id = models.PositiveBigIntegerField() @@ -695,6 +702,15 @@ class Bookmark(models.Model): return str(self.object) return super().__str__() + def clean(self): + super().clean() + + # Validate the assigned object type + if self.object_type not in ContentType.objects.with_feature('bookmarks'): + raise ValidationError( + _("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type) + ) + class ConfigRevision(models.Model): """ diff --git a/netbox/extras/models/search.py b/netbox/extras/models/search.py index bebcabd31..9ba779642 100644 --- a/netbox/extras/models/search.py +++ b/netbox/extras/models/search.py @@ -1,6 +1,5 @@ import uuid -from django.contrib.contenttypes.models import ContentType from django.db import models from django.utils.translation import gettext_lazy as _ @@ -27,7 +26,7 @@ class CachedValue(models.Model): editable=False ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE, related_name='+' ) diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py index b0df9e26e..2e848a817 100644 --- a/netbox/extras/models/staging.py +++ b/netbox/extras/models/staging.py @@ -2,7 +2,6 @@ import logging from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.db import models, transaction from django.utils.translation import gettext_lazy as _ @@ -71,7 +70,7 @@ class StagedChange(ChangeLoggedModel): choices=ChangeActionChoices ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE, related_name='+' ) diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index de4f15509..3aba6df60 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -1,13 +1,10 @@ from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from taggit.models import TagBase, GenericTaggedItemBase -from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin from utilities.choices import ColorChoices @@ -37,9 +34,8 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): blank=True, ) object_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='+', - limit_choices_to=FeatureQuery('tags'), blank=True, help_text=_("The object type(s) to which this this tag can be applied.") ) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 7b9356efb..c6b2de188 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,5 +1,3 @@ -from django.db.models import Q -from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager from netbox.registry import registry @@ -31,29 +29,6 @@ def image_upload(instance, filename): return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) -@deconstructible -class FeatureQuery: - """ - Helper class that delays evaluation of the registry contents for the functionality store - until it has been populated. - """ - def __init__(self, feature): - self.feature = feature - - def __call__(self): - return self.get_query() - - def get_query(self): - """ - Given an extras feature, return a Q object for content type lookup - """ - query = Q() - for app_label, models in registry['model_features'][self.feature].items(): - query |= Q(app_label=app_label, model__in=models) - - return query - - def register_features(model, features): """ Register model features in the application registry. diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 5d355102f..1e4e7dac3 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -1,13 +1,12 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from netbox.models import ChangeLoggedModel, PrimaryModel from ipam.choices import * from ipam.constants import * +from netbox.models import ChangeLoggedModel, PrimaryModel __all__ = ( 'FHRPGroup', @@ -78,7 +77,7 @@ class FHRPGroup(PrimaryModel): class FHRPGroupAssignment(ChangeLoggedModel): interface_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) interface_id = models.PositiveBigIntegerField() diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 934cb98c7..7dc0ac445 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,6 +1,5 @@ import netaddr from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.db.models import F @@ -9,6 +8,7 @@ from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -740,7 +740,7 @@ class IPAddress(PrimaryModel): help_text=_('The functional role of this IP') ) assigned_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+', diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index 3072fc6c3..a2742a8f3 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -1,11 +1,11 @@ from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from ipam.choices import L2VPNTypeChoices from ipam.constants import L2VPN_ASSIGNMENT_MODELS from netbox.models import NetBoxModel, PrimaryModel @@ -86,7 +86,7 @@ class L2VPNTermination(NetBoxModel): related_name='terminations' ) assigned_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=L2VPN_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+' diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 675d03ee5..b6aed5398 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -1,5 +1,4 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -32,7 +31,7 @@ class VLANGroup(OrganizationalModel): max_length=100 ) scope_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE, limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES), blank=True, diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index cce265efc..11307b4f8 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -3,7 +3,6 @@ from collections import defaultdict from functools import cached_property from django.contrib.contenttypes.fields import GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models from django.db.models.signals import class_prepared @@ -13,6 +12,7 @@ from django.utils.translation import gettext_lazy as _ from taggit.managers import TaggableManager from core.choices import JobStatusChoices +from core.models import ContentType from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.utils import is_taggable, register_features from netbox.registry import registry diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 692b8963f..77e945542 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -1,8 +1,7 @@ from django import forms -from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ -from extras.utils import FeatureQuery +from core.models import ContentType from netbox.forms import NetBoxModelFilterSetForm from tenancy.choices import * from tenancy.models import * @@ -87,8 +86,7 @@ class ContactAssignmentFilterForm(NetBoxModelFilterSetForm): (_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')), ) content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('contacts'), + queryset=ContentType.objects.with_feature('contacts'), required=False, label=_('Object type') ) diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 6cc26fa83..e7f319051 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -1,9 +1,10 @@ from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models.features import CustomFieldsMixin, TagsMixin from tenancy.choices import * @@ -111,7 +112,7 @@ class Contact(PrimaryModel): class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel): content_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) object_id = models.PositiveBigIntegerField() @@ -157,6 +158,15 @@ class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel): def get_absolute_url(self): return reverse('tenancy:contact', args=[self.contact.pk]) + def clean(self): + super().clean() + + # Validate the assigned object type + if self.content_type not in ContentType.objects.with_feature('contacts'): + raise ValidationError( + _("Contacts cannot be assigned to this object type ({type}).").format(type=self.content_type) + ) + def to_objectchange(self, action): objectchange = super().to_objectchange(action) objectchange.related_object = self.object diff --git a/netbox/users/models.py b/netbox/users/models.py index 2a345653d..d77d4932c 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -3,7 +3,6 @@ import os from django.conf import settings from django.contrib.auth.models import Group, GroupManager, User, UserManager -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator @@ -15,6 +14,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from netaddr import IPNetwork +from core.models import ContentType from ipam.fields import IPNetworkField from netbox.config import get_config from utilities.querysets import RestrictedQuerySet @@ -353,7 +353,7 @@ class ObjectPermission(models.Model): default=True ) object_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, related_name='object_permissions' ) From e767fec5cc33494cd1ca90bde801ed70e92b859d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Nov 2023 12:16:35 -0500 Subject: [PATCH 36/37] Closes #14173: Enable plugins to register columns on core tables (#14265) * Closes #14173: Enable plugins to register columns on core tables * Support translation for column name * Document new registry store --- docs/development/application-registry.md | 4 ++++ docs/plugins/development/tables.md | 25 ++++++++++++++++++++++ netbox/netbox/registry.py | 1 + netbox/netbox/tables/tables.py | 10 ++++++++- netbox/netbox/tests/dummy_plugin/tables.py | 11 ++++++++++ netbox/netbox/tests/dummy_plugin/views.py | 2 ++ netbox/netbox/tests/test_plugins.py | 10 +++++++++ netbox/utilities/tables.py | 19 ++++++++++++++++ 8 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 netbox/netbox/tests/dummy_plugin/tables.py diff --git a/docs/development/application-registry.md b/docs/development/application-registry.md index c7ac6ca46..c845cd5a7 100644 --- a/docs/development/application-registry.md +++ b/docs/development/application-registry.md @@ -53,6 +53,10 @@ This store maintains all registered items for plugins, such as navigation menus, A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it. +### `tables` + +A dictionary mapping table classes to lists of extra columns that have been registered by plugins using the `register_table_column()` utility function. Each column is defined as a tuple of name and column instance. + ### `views` A hierarchical mapping of registered views for each model. Mappings are added using the `register_model_view()` decorator, and URLs paths can be generated from these using `get_model_urls()`. diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md index f846139f0..9d57a9603 100644 --- a/docs/plugins/development/tables.md +++ b/docs/plugins/development/tables.md @@ -87,3 +87,28 @@ The table column classes listed below are supported for use in plugins. These cl options: members: - __init__ + +## Extending Core Tables + +!!! info "This feature was introduced in NetBox v3.7." + +Plugins can register their own custom columns on core tables using the `register_table_column()` utility function. This allows a plugin to attach additional information, such as relationships to its own models, to built-in object lists. + +```python +import django_tables2 +from django.utils.translation import gettext_lazy as _ + +from dcim.tables import SiteTable +from utilities.tables import register_table_column + +mycol = django_tables2.Column( + verbose_name=_('My Column'), + accessor=django_tables2.A('description') +) + +register_table_column(mycol, 'foo', SiteTable) +``` + +You'll typically want to define an accessor identifying the desired model field or relationship when defining a custom column. See the [django-tables2 documentation](https://django-tables2.readthedocs.io/) for more information on creating custom columns. + +::: utilities.tables.register_table_column diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index 151eb2f6b..ad8c18dcf 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -28,6 +28,7 @@ registry = Registry({ 'models': collections.defaultdict(set), 'plugins': dict(), 'search': dict(), + 'tables': collections.defaultdict(dict), 'views': collections.defaultdict(dict), 'widgets': dict(), }) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index cb53310cc..83dc3ae3c 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -1,3 +1,5 @@ +from copy import deepcopy + import django_tables2 as tables from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.fields import GenericForeignKey @@ -12,6 +14,7 @@ from django_tables2.data import TableQuerysetData from extras.models import CustomField, CustomLink from extras.choices import CustomFieldVisibilityChoices +from netbox.registry import registry from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.utils import get_viewname, highlight_string, title @@ -191,12 +194,17 @@ class NetBoxTable(BaseTable): if extra_columns is None: extra_columns = [] + if registered_columns := registry['tables'].get(self.__class__): + extra_columns.extend([ + # Create a copy to avoid modifying the original Column + (name, deepcopy(column)) for name, column in registered_columns.items() + ]) + # Add custom field & custom link columns content_type = ContentType.objects.get_for_model(self._meta.model) custom_fields = CustomField.objects.filter( content_types=content_type ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN) - extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) diff --git a/netbox/netbox/tests/dummy_plugin/tables.py b/netbox/netbox/tests/dummy_plugin/tables.py new file mode 100644 index 000000000..0f1e823d7 --- /dev/null +++ b/netbox/netbox/tests/dummy_plugin/tables.py @@ -0,0 +1,11 @@ +import django_tables2 as tables + +from dcim.tables import SiteTable +from utilities.tables import register_table_column + +mycol = tables.Column( + verbose_name='My column', + accessor=tables.A('description') +) + +register_table_column(mycol, 'foo', SiteTable) diff --git a/netbox/netbox/tests/dummy_plugin/views.py b/netbox/netbox/tests/dummy_plugin/views.py index 8713102c5..03a83b585 100644 --- a/netbox/netbox/tests/dummy_plugin/views.py +++ b/netbox/netbox/tests/dummy_plugin/views.py @@ -4,6 +4,8 @@ from django.views.generic import View from dcim.models import Site from utilities.views import register_model_view from .models import DummyModel +# Trigger registration of custom column +from .tables import mycol class DummyModelsView(View): diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index 046436a86..40bf8b0ea 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -97,6 +97,16 @@ class PluginTest(TestCase): self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site']) + def test_registered_columns(self): + """ + Check that a plugin can register a custom column on a core model table. + """ + from dcim.models import Site + from dcim.tables import SiteTable + + table = SiteTable(Site.objects.all()) + self.assertIn('foo', table.columns.names()) + def test_user_preferences(self): """ Check that plugin UserPreferences are registered. diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 489b90f10..654eb02be 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -1,6 +1,9 @@ +from netbox.registry import registry + __all__ = ( 'get_table_ordering', 'linkify_phone', + 'register_table_column' ) @@ -26,3 +29,19 @@ def linkify_phone(value): if value is None: return None return f"tel:{value}" + + +def register_table_column(column, name, *tables): + """ + Register a custom column for use on one or more tables. + + Args: + column: The column instance to register + name: The name of the table column + tables: One or more table classes + """ + for table in tables: + reg = registry['tables'][table] + if name in reg: + raise ValueError(f"A column named {name} is already defined for table {table.__name__}") + reg[name] = column From e13bf48a35a2f979ef4c8460cd6f447977029e89 Mon Sep 17 00:00:00 2001 From: Pavel Korovin Date: Fri, 17 Nov 2023 16:32:58 +0300 Subject: [PATCH 37/37] Add /api/virtualization/virtual-machines/{id}/render-config/ endpoint (#14287) * Add /api/virtualization/virtual-machines/{id}/render-config/ endpoint * Update Docstring "Device" -> "Virtual Machine" Docstring should mention "..this Virtual Machine" instead of "...this Device", thanks @LuPo! * Move config rendering logic to new RenderConfigMixin * Add tests for render-config API endpoint --------- Co-authored-by: Jeremy Stretch --- netbox/dcim/api/views.py | 24 ++--------------- netbox/dcim/tests/test_api.py | 17 ++++++++++++ netbox/extras/api/mixins.py | 35 ++++++++++++++++++++++++- netbox/virtualization/api/views.py | 6 ++--- netbox/virtualization/tests/test_api.py | 17 ++++++++++++ 5 files changed, 73 insertions(+), 26 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index a3e532f0b..cd5a297c9 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -3,10 +3,8 @@ from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema, OpenApiParameter from rest_framework.decorators import action -from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.routers import APIRootView -from rest_framework.status import HTTP_400_BAD_REQUEST from rest_framework.viewsets import ViewSet from circuits.models import Circuit @@ -14,12 +12,11 @@ from dcim import filtersets from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.models import * from dcim.svg import CableTraceSVG -from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin +from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.metadata import ContentTypeMetadata from netbox.api.pagination import StripCountAnnotationsPaginator -from netbox.api.renderers import TextRenderer from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from netbox.constants import NESTED_SERIALIZER_PREFIX @@ -390,7 +387,7 @@ class PlatformViewSet(NetBoxModelViewSet): class DeviceViewSet( SequentialBulkCreatesMixin, ConfigContextQuerySetMixin, - ConfigTemplateRenderMixin, + RenderConfigMixin, NetBoxModelViewSet ): queryset = Device.objects.prefetch_related( @@ -420,23 +417,6 @@ class DeviceViewSet( return serializers.DeviceWithConfigContextSerializer - @action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer]) - def render_config(self, request, pk): - """ - Resolve and render the preferred ConfigTemplate for this Device. - """ - device = self.get_object() - configtemplate = device.get_config_template() - if not configtemplate: - return Response({'error': 'No config template found for this device.'}, status=HTTP_400_BAD_REQUEST) - - # Compile context data - context_data = device.get_config_context() - context_data.update(request.data) - context_data.update({'device': device}) - - return self.render_configtemplate(request, configtemplate, context_data) - class VirtualDeviceContextViewSet(NetBoxModelViewSet): queryset = VirtualDeviceContext.objects.prefetch_related( diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index d3211a75f..f36b11033 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -6,6 +6,7 @@ from rest_framework import status from dcim.choices import * from dcim.constants import * from dcim.models import * +from extras.models import ConfigTemplate from ipam.models import ASN, RIR, VLAN, VRF from netbox.api.serializers import GenericObjectSerializer from utilities.testing import APITestCase, APIViewTestCases, create_test_device @@ -1265,6 +1266,22 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + def test_render_config(self): + configtemplate = ConfigTemplate.objects.create( + name='Config Template 1', + template_code='Config for device {{ device.name }}' + ) + + device = Device.objects.first() + device.config_template = configtemplate + device.save() + + self.add_permissions('dcim.add_device') + url = reverse('dcim-api:device-detail', kwargs={'pk': device.pk}) + 'render-config/' + response = self.client.post(url, {}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['content'], f'Config for device {device.name}') + class ModuleTest(APIViewTestCases.APIViewTestCase): model = Module diff --git a/netbox/extras/api/mixins.py b/netbox/extras/api/mixins.py index b6be47bbb..1737ff9f8 100644 --- a/netbox/extras/api/mixins.py +++ b/netbox/extras/api/mixins.py @@ -1,10 +1,16 @@ from jinja2.exceptions import TemplateError +from rest_framework.decorators import action +from rest_framework.renderers import JSONRenderer from rest_framework.response import Response +from rest_framework.status import HTTP_400_BAD_REQUEST +from netbox.api.renderers import TextRenderer from .nested_serializers import NestedConfigTemplateSerializer __all__ = ( 'ConfigContextQuerySetMixin', + 'ConfigTemplateRenderMixin', + 'RenderConfigMixin', ) @@ -31,7 +37,9 @@ class ConfigContextQuerySetMixin: class ConfigTemplateRenderMixin: - + """ + Provides a method to return a rendered ConfigTemplate as REST API data. + """ def render_configtemplate(self, request, configtemplate, context): try: output = configtemplate.render(context=context) @@ -50,3 +58,28 @@ class ConfigTemplateRenderMixin: 'configtemplate': template_serializer.data, 'content': output }) + + +class RenderConfigMixin(ConfigTemplateRenderMixin): + """ + Provides a /render-config/ endpoint for REST API views whose model may have a ConfigTemplate assigned. + """ + @action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer]) + def render_config(self, request, pk): + """ + Resolve and render the preferred ConfigTemplate for this Device. + """ + instance = self.get_object() + object_type = instance._meta.model_name + configtemplate = instance.get_config_template() + if not configtemplate: + return Response({ + 'error': f'No config template found for this {object_type}.' + }, status=HTTP_400_BAD_REQUEST) + + # Compile context data + context_data = instance.get_config_context() + context_data.update(request.data) + context_data.update({object_type: instance}) + + return self.render_configtemplate(request, configtemplate, context_data) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 04e8f2167..2b28505ab 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,7 +1,7 @@ from rest_framework.routers import APIRootView from dcim.models import Device -from extras.api.mixins import ConfigContextQuerySetMixin +from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin from netbox.api.viewsets import NetBoxModelViewSet from utilities.query_functions import CollateAsChar from utilities.utils import count_related @@ -53,9 +53,9 @@ class ClusterViewSet(NetBoxModelViewSet): # Virtual machines # -class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): +class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet): queryset = VirtualMachine.objects.prefetch_related( - 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'config_template', 'tags' ) filterset_class = filtersets.VirtualMachineFilterSet diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 3fb46fbb9..b33f3afe9 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -3,6 +3,7 @@ from rest_framework import status from dcim.choices import InterfaceModeChoices from dcim.models import Site +from extras.models import ConfigTemplate from ipam.models import VLAN, VRF from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.choices import * @@ -228,6 +229,22 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + def test_render_config(self): + configtemplate = ConfigTemplate.objects.create( + name='Config Template 1', + template_code='Config for virtual machine {{ virtualmachine.name }}' + ) + + vm = VirtualMachine.objects.first() + vm.config_template = configtemplate + vm.save() + + self.add_permissions('virtualization.add_virtualmachine') + url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/' + response = self.client.post(url, {}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['content'], f'Config for virtual machine {vm.name}') + class VMInterfaceTest(APIViewTestCases.APIViewTestCase): model = VMInterface