From f60312febf06b39a5ee56f55491ee1ee145e4657 Mon Sep 17 00:00:00 2001 From: Patrick Rauscher Date: Thu, 20 Oct 2022 08:51:04 +0200 Subject: [PATCH 01/20] Set *_COOKIE_PATH according to BASE_PATH As discussed in #10639, all three `COOKIE_PATH`s should be set accordingly with the netbox-`BASE_PATH` to improve coexistance with other Django-projects probably hosted on the same Host --- netbox/netbox/settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0c3b1660b..14b66b2dd 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -85,6 +85,7 @@ CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') +CSRF_COOKIE_PATH = BASE_PATH or '/' CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') @@ -129,6 +130,8 @@ SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {}) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') +SESSION_COOKIE_PATH = BASE_PATH or '/' +LANGUAGE_COOKIE_PATH = BASE_PATH or '/' SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') From 53c9c3cf8dbe96f6e8b6f40a14c71a52cb2247f6 Mon Sep 17 00:00:00 2001 From: Craig Pund Date: Thu, 20 Oct 2022 16:26:26 -0400 Subject: [PATCH 02/20] Fixes #10580 (#10687) * change IP address accessor to parent object * set IP assigned check to link to interface * Fix Assigned not being orderable Co-authored-by: Jeremy Stretch Co-authored-by: Craig Pund Co-authored-by: Jeremy Stretch --- netbox/ipam/tables/ip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index a820385ed..44f40b8a1 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -375,7 +375,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable): ) assigned = columns.BooleanColumn( accessor='assigned_object_id', - linkify=True, + linkify=lambda record: record.assigned_object.get_absolute_url(), verbose_name='Assigned' ) tags = columns.TagColumn( From e7659a5f99801966124df765c799c8f26957f9d6 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 20 Oct 2022 13:27:51 -0700 Subject: [PATCH 03/20] 9584 add device type (slug) to filter list (#10630) * 9584 add device type (slug) to filter list * 9584 add test --- netbox/dcim/filtersets.py | 6 ++++++ netbox/dcim/tests/test_filtersets.py | 2 ++ 2 files changed, 8 insertions(+) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0a4439173..a999383c7 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -800,6 +800,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter to_field_name='slug', label='Manufacturer (slug)', ) + device_type = django_filters.ModelMultipleChoiceFilter( + field_name='device_type__slug', + queryset=DeviceType.objects.all(), + to_field_name='slug', + label='Device type (slug)', + ) device_type_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), label='Device type (ID)', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index feef4e90c..05bb64796 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1643,6 +1643,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): device_types = DeviceType.objects.all()[:2] params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device_type': [device_types[0].slug, device_types[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_devicerole(self): device_roles = DeviceRole.objects.all()[:2] From 96c469641796b2b12ce643363d41d823670b68db Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 20 Oct 2022 16:31:52 -0400 Subject: [PATCH 04/20] Changelog for #9584, #10580, #10639 --- docs/release-notes/version-3.3.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index de0f1a40a..5f3d50098 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -4,7 +4,10 @@ ### Enhancements +* [#9584](https://github.com/netbox-community/netbox/issues/9584) - Enable filtering devices by device type slug * [#9722](https://github.com/netbox-community/netbox/issues/9722) - Add LDAP configuration parameters to specify certificates +* [#10580](https://github.com/netbox-community/netbox/issues/10580) - Link "assigned" checkbox in IP address table to assigned interface +* [#10639](https://github.com/netbox-community/netbox/issues/10639) - Set cookie paths according to configured `BASE_PATH` * [#10685](https://github.com/netbox-community/netbox/issues/10685) - Position A/Z termination cards above the fold under circuit view ### Bug Fixes From 3d687a6c2d71807cc25ad5d78f29b99d42243107 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 21 Oct 2022 12:39:03 -0400 Subject: [PATCH 05/20] Closes #10718: Optimize object-based permissions enforcement --- netbox/netbox/api/viewsets/mixins.py | 5 ++--- netbox/netbox/views/generic/object_views.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index 7dc1111f3..b47c88a4e 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -108,6 +108,5 @@ class ObjectValidationMixin: conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() if conforming_count != len(instance): raise ObjectDoesNotExist - else: - # Check that the instance is matched by the view's queryset - self.queryset.get(pk=instance.pk) + elif not self.queryset.filter(pk=instance.pk).exists(): + raise ObjectDoesNotExist diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index a56a832b6..3b0c77251 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -173,7 +173,7 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView): obj = model_form.save() # Enforce object-level permissions - if not self.queryset.filter(pk=obj.pk).first(): + if not self.queryset.filter(pk=obj.pk).exists(): raise PermissionsViolation() # Iterate through the related object forms (if any), validating and saving each instance. @@ -390,7 +390,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): obj = form.save() # Check that the new object conforms with any assigned object-level permissions - if not self.queryset.filter(pk=obj.pk).first(): + if not self.queryset.filter(pk=obj.pk).exists(): raise PermissionsViolation() msg = '{} {}'.format( From 4c504870e029058d8e1cfeaaea84ac31b93a55e9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 21 Oct 2022 12:47:19 -0400 Subject: [PATCH 06/20] Tweak PR template language --- .github/PULL_REQUEST_TEMPLATE.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 33134cb45..0bbbe90c7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,13 +1,14 @@ ### Fixes: #1234 From b2e2e3be35f3922ecee945b97279c50725c0b7fa Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 25 Oct 2022 11:19:15 -0400 Subject: [PATCH 07/20] Closes #10739: Introduce get_queryset() method on generic views --- docs/plugins/development/views.md | 14 ++++++++---- docs/release-notes/version-3.4.md | 1 + netbox/netbox/views/generic/base.py | 33 +++++++++++++++++++++++------ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index e12f32bad..45d7064cf 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -82,23 +82,25 @@ class ThingEditView(ObjectEditView): Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly. ::: netbox.views.generic.base.BaseObjectView + options: + members: + - get_queryset + - get_object + - get_extra_context ::: netbox.views.generic.ObjectView options: members: - - get_object - get_template_name ::: netbox.views.generic.ObjectEditView options: members: - - get_object - alter_object ::: netbox.views.generic.ObjectDeleteView options: - members: - - get_object + members: false ::: netbox.views.generic.ObjectChildrenView options: @@ -111,6 +113,10 @@ Below are the class definitions for NetBox's object views. These views handle CR Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly. ::: netbox.views.generic.base.BaseMultiObjectView + options: + members: + - get_queryset + - get_extra_context ::: netbox.views.generic.ObjectListView options: diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 80b94d6c2..ba5947364 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -38,6 +38,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models * [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin +* [#10739](https://github.com/netbox-community/netbox/issues/10739) - Introduce `get_queryset()` method on generic views ### Other Changes diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py index 3ad3bcf67..8e49ea62f 100644 --- a/netbox/netbox/views/generic/base.py +++ b/netbox/netbox/views/generic/base.py @@ -1,18 +1,40 @@ +from django.core.exceptions import ImproperlyConfigured from django.shortcuts import get_object_or_404 from django.views.generic import View from utilities.views import ObjectPermissionRequiredMixin -class BaseObjectView(ObjectPermissionRequiredMixin, View): +class BaseView(ObjectPermissionRequiredMixin, View): + queryset = None + + def dispatch(self, request, *args, **kwargs): + self.queryset = self.get_queryset(request) + return super().dispatch(request, *args, **kwargs) + + def get_queryset(self, request): + """ + Return the base queryset for the view. By default, this returns self.queryset.all(). + + Args: + request: The current request + """ + if self.queryset is None: + raise ImproperlyConfigured( + f"{self.__class__.__name__} does not define a queryset. Set queryset on the class or " + f"override its get_queryset() method." + ) + return self.queryset.all() + + +class BaseObjectView(BaseView): """ - Base view class for reusable generic views. + Base class for generic views which display or manipulate a single object. Attributes: queryset: Django QuerySet from which the object(s) will be fetched template_name: The name of the HTML template file to render """ - queryset = None template_name = None def get_object(self, **kwargs): @@ -35,16 +57,15 @@ class BaseObjectView(ObjectPermissionRequiredMixin, View): return {} -class BaseMultiObjectView(ObjectPermissionRequiredMixin, View): +class BaseMultiObjectView(BaseView): """ - Base view class for reusable generic views. + Base class for generic views which display or manipulate multiple objects. Attributes: queryset: Django QuerySet from which the object(s) will be fetched table: The django-tables2 Table class used to render the objects list template_name: The name of the HTML template file to render """ - queryset = None table = None template_name = None From 91c6bbcd787700b9c3fcafefe35af1ba1ae1ac0b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 25 Oct 2022 16:26:05 -0400 Subject: [PATCH 08/20] Closes #10695: Rename forms/models.py to forms/model_forms.py --- netbox/circuits/forms/__init__.py | 2 +- netbox/circuits/forms/{models.py => model_forms.py} | 0 netbox/dcim/forms/__init__.py | 2 +- netbox/dcim/forms/connections.py | 2 +- netbox/dcim/forms/{models.py => model_forms.py} | 0 netbox/dcim/forms/object_create.py | 2 +- netbox/extras/forms/__init__.py | 2 +- netbox/extras/forms/{models.py => model_forms.py} | 0 netbox/ipam/forms/__init__.py | 2 +- netbox/ipam/forms/{models.py => model_forms.py} | 2 -- netbox/tenancy/forms/__init__.py | 2 +- netbox/tenancy/forms/{models.py => model_forms.py} | 0 netbox/virtualization/forms/__init__.py | 2 +- netbox/virtualization/forms/{models.py => model_forms.py} | 2 +- netbox/virtualization/forms/object_create.py | 2 +- netbox/wireless/forms/__init__.py | 2 +- netbox/wireless/forms/{models.py => model_forms.py} | 0 17 files changed, 11 insertions(+), 13 deletions(-) rename netbox/circuits/forms/{models.py => model_forms.py} (100%) rename netbox/dcim/forms/{models.py => model_forms.py} (100%) rename netbox/extras/forms/{models.py => model_forms.py} (100%) rename netbox/ipam/forms/{models.py => model_forms.py} (99%) rename netbox/tenancy/forms/{models.py => model_forms.py} (100%) rename netbox/virtualization/forms/{models.py => model_forms.py} (99%) rename netbox/wireless/forms/{models.py => model_forms.py} (100%) diff --git a/netbox/circuits/forms/__init__.py b/netbox/circuits/forms/__init__.py index 5c23f833a..1499f98b2 100644 --- a/netbox/circuits/forms/__init__.py +++ b/netbox/circuits/forms/__init__.py @@ -1,4 +1,4 @@ from .bulk_edit import * from .bulk_import import * from .filtersets import * -from .models import * +from .model_forms import * diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/model_forms.py similarity index 100% rename from netbox/circuits/forms/models.py rename to netbox/circuits/forms/model_forms.py diff --git a/netbox/dcim/forms/__init__.py b/netbox/dcim/forms/__init__.py index 22f0b1204..7510a979f 100644 --- a/netbox/dcim/forms/__init__.py +++ b/netbox/dcim/forms/__init__.py @@ -1,4 +1,4 @@ -from .models import * +from .model_forms import * from .filtersets import * from .object_create import * from .object_import import * diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 5e3948baa..537a89bad 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -3,7 +3,7 @@ from django import forms from circuits.models import Circuit, CircuitTermination, Provider from dcim.models import * from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField -from .models import CableForm +from .model_forms import CableForm def get_cable_form(a_type, b_type): diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/model_forms.py similarity index 100% rename from netbox/dcim/forms/models.py rename to netbox/dcim/forms/model_forms.py diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index a03597db1..afdaa4fcc 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -3,7 +3,7 @@ from django import forms from dcim.models import * from netbox.forms import NetBoxModelForm from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField -from . import models as model_forms +from . import model_forms __all__ = ( 'ComponentCreateForm', diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index b470650da..d2f2fb015 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -1,4 +1,4 @@ -from .models import * +from .model_forms import * from .filtersets import * from .bulk_edit import * from .bulk_import import * diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/model_forms.py similarity index 100% rename from netbox/extras/forms/models.py rename to netbox/extras/forms/model_forms.py diff --git a/netbox/ipam/forms/__init__.py b/netbox/ipam/forms/__init__.py index fc3352358..ba97d6dfa 100644 --- a/netbox/ipam/forms/__init__.py +++ b/netbox/ipam/forms/__init__.py @@ -1,4 +1,4 @@ -from .models import * +from .model_forms import * from .filtersets import * from .bulk_create import * from .bulk_edit import * diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/model_forms.py similarity index 99% rename from netbox/ipam/forms/models.py rename to netbox/ipam/forms/model_forms.py index 86a083361..6a3acf4c2 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/model_forms.py @@ -3,14 +3,12 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup -from extras.models import Tag from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField from ipam.models import * from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm -from tenancy.models import Tenant from utilities.exceptions import PermissionsViolation from utilities.forms import ( add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, diff --git a/netbox/tenancy/forms/__init__.py b/netbox/tenancy/forms/__init__.py index 61f0bc961..96c1e50f7 100644 --- a/netbox/tenancy/forms/__init__.py +++ b/netbox/tenancy/forms/__init__.py @@ -1,5 +1,5 @@ from .forms import * -from .models import * +from .model_forms import * from .filtersets import * from .bulk_edit import * from .bulk_import import * diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/model_forms.py similarity index 100% rename from netbox/tenancy/forms/models.py rename to netbox/tenancy/forms/model_forms.py diff --git a/netbox/virtualization/forms/__init__.py b/netbox/virtualization/forms/__init__.py index 00f28b852..3c9dd3651 100644 --- a/netbox/virtualization/forms/__init__.py +++ b/netbox/virtualization/forms/__init__.py @@ -1,4 +1,4 @@ -from .models import * +from .model_forms import * from .filtersets import * from .object_create import * from .bulk_create import * diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/model_forms.py similarity index 99% rename from netbox/virtualization/forms/models.py rename to netbox/virtualization/forms/model_forms.py index 1b5c5adc6..5438002b4 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/model_forms.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from dcim.forms.common import InterfaceCommonForm -from dcim.forms.models import INTERFACE_MODE_HELP_TEXT +from dcim.forms.model_forms import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from ipam.models import IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index 79457a56e..2e0cc5db1 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -1,5 +1,5 @@ from utilities.forms import ExpandableNameField -from .models import VMInterfaceForm +from .model_forms import VMInterfaceForm __all__ = ( 'VMInterfaceCreateForm', diff --git a/netbox/wireless/forms/__init__.py b/netbox/wireless/forms/__init__.py index 62c2ec2d9..3098b356a 100644 --- a/netbox/wireless/forms/__init__.py +++ b/netbox/wireless/forms/__init__.py @@ -1,4 +1,4 @@ -from .models import * +from .model_forms import * from .filtersets import * from .bulk_edit import * from .bulk_import import * diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/model_forms.py similarity index 100% rename from netbox/wireless/forms/models.py rename to netbox/wireless/forms/model_forms.py From 01654765e853e68290f1c2f22871657b0658ae87 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 25 Oct 2022 16:38:32 -0400 Subject: [PATCH 09/20] Fixes #10746: Add missing status attribute to cluster view --- docs/release-notes/version-3.3.md | 1 + netbox/templates/virtualization/cluster.html | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 5f3d50098..b05fa9074 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -19,6 +19,7 @@ * [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable * [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables * [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+ +* [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view --- diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index bf7c8a69a..bc02424cc 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -19,6 +19,10 @@ Type {{ object.type|linkify }} + + Status + {% badge object.get_status_display bg_color=object.get_status_color %} + Group {{ object.group|linkify|placeholder }} From eb91934d701cfc295969158fd8217d5c534a474f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 25 Oct 2022 16:41:07 -0400 Subject: [PATCH 10/20] Fixes #10745: Correct display of status field in clusters list --- docs/release-notes/version-3.3.md | 1 + netbox/virtualization/tables/clusters.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index b05fa9074..e66ee1a28 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -19,6 +19,7 @@ * [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable * [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables * [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+ +* [#10745](https://github.com/netbox-community/netbox/issues/10745) - Correct display of status field in clusters list * [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view --- diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index fec539b8c..ae4c610d7 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -64,6 +64,7 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): group = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() site = tables.Column( linkify=True ) From 8d486c5838e0c882640e9a747cc1965e60e4f9e5 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 26 Oct 2022 05:05:15 -0700 Subject: [PATCH 11/20] 10716 add left-right plugins to tags page (#10744) * 10716 add left-right plugins to tags page * 10716 add back plugin_full_width --- netbox/templates/extras/tag.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index b0b88b5af..6e4c5aee9 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -39,6 +39,7 @@ + {% plugin_left_page object %}
@@ -64,6 +65,7 @@
+ {% plugin_right_page object %}
From d8c07abd6817771b4f5491df19550d9f58261165 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 25 Oct 2022 09:07:39 -0700 Subject: [PATCH 12/20] 10610 interface_id query on lag return vc interfaces --- netbox/dcim/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index a999383c7..917f57923 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1363,7 +1363,7 @@ class InterfaceFilterSet( try: devices = Device.objects.filter(pk__in=id_list) for device in devices: - vc_interface_ids += device.vc_interfaces().values_list('id', flat=True) + vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True) return queryset.filter(pk__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() From 2a62b628cf8c3648468e14bf67ec5a9b24278704 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 08:23:50 -0400 Subject: [PATCH 13/20] Fixes #10723: Distinguish between inside/outside NAT assignments for device/VM primary IPs --- docs/release-notes/version-3.3.md | 3 +++ netbox/templates/dcim/device.html | 4 ++-- netbox/templates/virtualization/virtualmachine.html | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index e66ee1a28..fe37cce3e 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -15,10 +15,13 @@ * [#9669](https://github.com/netbox-community/netbox/issues/9669) - Strip colons from usernames when using remote authentication * [#10575](https://github.com/netbox-community/netbox/issues/10575) - Include OIDC dependencies for python-social-auth * [#10584](https://github.com/netbox-community/netbox/issues/10584) - Fix service clone link +* [#10610](https://github.com/netbox-community/netbox/issues/10610) - Allow assignment of VC member to LAG on non-master peer * [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms * [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable * [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables * [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+ +* [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view +* [#10723](https://github.com/netbox-community/netbox/issues/10723) - Distinguish between inside/outside NAT assignments for device/VM primary IPs * [#10745](https://github.com/netbox-community/netbox/issues/10745) - Correct display of status field in clusters list * [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d800658a5..b0cd76de4 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -178,7 +178,7 @@ {% if object.primary_ip4.nat_inside %} (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) {% elif object.primary_ip4.nat_outside.exists %} - (NAT for {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + (NAT: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} {% else %} {{ ''|placeholder }} @@ -193,7 +193,7 @@ {% if object.primary_ip6.nat_inside %} (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) {% elif object.primary_ip6.nat_outside.exists %} - (NAT for {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + (NAT: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} {% else %} {{ ''|placeholder }} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 5756d939a..c0e2ebd07 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -46,7 +46,7 @@ {% if object.primary_ip4.nat_inside %} (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) {% elif object.primary_ip4.nat_outside.exists %} - (NAT for {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + (NAT: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} {% else %} {{ ''|placeholder }} @@ -61,7 +61,7 @@ {% if object.primary_ip6.nat_inside %} (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) {% elif object.primary_ip6.nat_outside.exists %} - (NAT for {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + (NAT: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} {% else %} {{ ''|placeholder }} From 7b3ef2ade5266ec0738c4e210cf1c6f9b85fbee7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 08:44:20 -0400 Subject: [PATCH 14/20] Fixes #10719: Prevent user without sufficient permission from creating an IP address via FHRP group creation --- docs/release-notes/version-3.3.md | 1 + netbox/ipam/forms/models.py | 3 ++- netbox/ipam/views.py | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index fe37cce3e..0015dc2df 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -21,6 +21,7 @@ * [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables * [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+ * [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view +* [#10719](https://github.com/netbox-community/netbox/issues/10719) - Prevent user without sufficient permission from creating an IP address via FHRP group creation * [#10723](https://github.com/netbox-community/netbox/issues/10723) - Distinguish between inside/outside NAT assignments for device/VM primary IPs * [#10745](https://github.com/netbox-community/netbox/issues/10745) - Correct display of status field in clusters list * [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index f66b7efba..1986b1590 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -552,6 +552,7 @@ class FHRPGroupForm(NetBoxModelForm): def save(self, *args, **kwargs): instance = super().save(*args, **kwargs) + user = getattr(instance, '_user', None) # Set under FHRPGroupEditView.alter_object() # Check if we need to create a new IPAddress for the group if self.cleaned_data.get('ip_address'): @@ -565,7 +566,7 @@ class FHRPGroupForm(NetBoxModelForm): ipaddress.save() # Check that the new IPAddress conforms with any assigned object-level permissions - if not IPAddress.objects.filter(pk=ipaddress.pk).first(): + if not IPAddress.objects.restrict(user, 'add').filter(pk=ipaddress.pk).first(): raise PermissionsViolation() return instance diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 04d07e356..72483d40f 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -930,6 +930,12 @@ class FHRPGroupEditView(generic.ObjectEditView): return return_url + def alter_object(self, obj, request, url_args, url_kwargs): + # Workaround to solve #10719. Capture the current user on the FHRPGroup instance so that + # we can evaluate permissions during the creation of a new IPAddress within the form. + obj._user = request.user + return obj + class FHRPGroupDeleteView(generic.ObjectDeleteView): queryset = FHRPGroup.objects.all() From 658c9347f39c7376866f02c917df8dcb0224e079 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 09:32:29 -0400 Subject: [PATCH 15/20] Fixes #10682: Correct home view links to connection lists --- docs/release-notes/version-3.3.md | 1 + netbox/netbox/views/__init__.py | 114 ++++++++++++++---------------- netbox/templates/home.html | 4 +- 3 files changed, 56 insertions(+), 63 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 0015dc2df..1e2c4a90e 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -19,6 +19,7 @@ * [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms * [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable * [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables +* [#10682](https://github.com/netbox-community/netbox/issues/10682) - Correct home view links to connection lists * [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+ * [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view * [#10719](https://github.com/netbox-community/netbox/issues/10719) - Prevent user without sufficient permission from creating an IP address via FHRP group creation diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index bc1f0e2ca..18b64344f 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -1,5 +1,6 @@ import platform import sys +from collections import namedtuple from django.conf import settings from django.core.cache import cache @@ -8,6 +9,7 @@ from django.shortcuts import redirect, render from django.template import loader from django.template.exceptions import TemplateDoesNotExist from django.urls import reverse +from django.utils.translation import gettext as _ 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 @@ -24,100 +26,90 @@ from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF from netbox.constants import SEARCH_MAX_RESULTS from netbox.forms import SearchForm from netbox.search import SEARCH_TYPES -from tenancy.models import Tenant +from tenancy.models import Contact, Tenant from virtualization.models import Cluster, VirtualMachine from wireless.models import WirelessLAN, WirelessLink +Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count')) + + class HomeView(View): template_name = 'home.html' def get(self, request): if settings.LOGIN_REQUIRED and not request.user.is_authenticated: - return redirect("login") + return redirect('login') - connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( + console_connections = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( _path__is_complete=True - ) - connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( + ).count + power_connections = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( _path__is_complete=True - ) - connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter( + ).count + interface_connections = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter( _path__is_complete=True - ) + ).count + + def get_count_queryset(model): + return model.objects.restrict(request.user, 'view').count def build_stats(): org = ( - ("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count), - ("tenancy.view_tenant", "Tenants", Tenant.objects.restrict(request.user, 'view').count), + Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)), + Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)), + Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)), ) dcim = ( - ("dcim.view_rack", "Racks", Rack.objects.restrict(request.user, 'view').count), - ("dcim.view_devicetype", "Device Types", DeviceType.objects.restrict(request.user, 'view').count), - ("dcim.view_device", "Devices", Device.objects.restrict(request.user, 'view').count), + Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)), + Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)), + Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)), ) ipam = ( - ("ipam.view_vrf", "VRFs", VRF.objects.restrict(request.user, 'view').count), - ("ipam.view_aggregate", "Aggregates", Aggregate.objects.restrict(request.user, 'view').count), - ("ipam.view_prefix", "Prefixes", Prefix.objects.restrict(request.user, 'view').count), - ("ipam.view_iprange", "IP Ranges", IPRange.objects.restrict(request.user, 'view').count), - ("ipam.view_ipaddress", "IP Addresses", IPAddress.objects.restrict(request.user, 'view').count), - ("ipam.view_vlan", "VLANs", VLAN.objects.restrict(request.user, 'view').count) - + Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)), + Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)), + Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)), + Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)), + Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)), + Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)), ) circuits = ( - ("circuits.view_provider", "Providers", Provider.objects.restrict(request.user, 'view').count), - ("circuits.view_circuit", "Circuits", Circuit.objects.restrict(request.user, 'view').count), + Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)), + Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit)) ) virtualization = ( - ("virtualization.view_cluster", "Clusters", Cluster.objects.restrict(request.user, 'view').count), - ("virtualization.view_virtualmachine", "Virtual Machines", VirtualMachine.objects.restrict(request.user, 'view').count), - + Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster', + get_count_queryset(Cluster)), + Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine', + get_count_queryset(VirtualMachine)), ) connections = ( - ("dcim.view_cable", "Cables", Cable.objects.restrict(request.user, 'view').count), - ("dcim.view_consoleport", "Console", connected_consoleports.count), - ("dcim.view_interface", "Interfaces", connected_interfaces.count), - ("dcim.view_powerport", "Power Connections", connected_powerports.count), + Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)), + Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections), + Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections), + Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections), ) power = ( - ("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count), - ("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count), + Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)), + Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)), ) wireless = ( - ("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count), - ("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count), + Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan', + get_count_queryset(WirelessLAN)), + Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink', + get_count_queryset(WirelessLink)), ) - sections = ( - ("Organization", org, "domain"), - ("IPAM", ipam, "counter"), - ("Virtualization", virtualization, "monitor"), - ("Inventory", dcim, "server"), - ("Circuits", circuits, "transit-connection-variant"), - ("Connections", connections, "cable-data"), - ("Power", power, "flash"), - ("Wireless", wireless, "wifi"), + stats = ( + (_('Organization'), org, 'domain'), + (_('IPAM'), ipam, 'counter'), + (_('Virtualization'), virtualization, 'monitor'), + (_('Inventory'), dcim, 'server'), + (_('Circuits'), circuits, 'transit-connection-variant'), + (_('Connections'), connections, 'cable-data'), + (_('Power'), power, 'flash'), + (_('Wireless'), wireless, 'wifi'), ) - stats = [] - for section_label, section_items, icon_class in sections: - items = [] - for perm, item_label, get_count in section_items: - app, scope = perm.split(".") - url = ":".join((app, scope.replace("view_", "") + "_list")) - item = { - "label": item_label, - "count": None, - "url": url, - "disabled": True, - "icon": icon_class, - } - if request.user.has_perm(perm): - item["count"] = get_count() - item["disabled"] = False - items.append(item) - stats.append((section_label, items, icon_class)) - return stats # Compile changelog table diff --git a/netbox/templates/home.html b/netbox/templates/home.html index a12ec9277..f98d0ccf3 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -36,8 +36,8 @@
{% for item in items %} - {% if not item.disabled %} - + {% if item.permission in perms %} +
{{ item.label }}

{{ item.count }}

From 174ba6cf0f9d2c04f5d7ed533654f9c30bd3f75c Mon Sep 17 00:00:00 2001 From: Kevin Petremann Date: Fri, 14 Oct 2022 17:00:20 +0200 Subject: [PATCH 16/20] Fix LDAP auth: user never updated if inactive --- netbox/netbox/api/authentication.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index b8607a0bb..814ca1ed6 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -58,22 +58,24 @@ class TokenAuthentication(authentication.TokenAuthentication): if token.is_expired: raise exceptions.AuthenticationFailed("Token expired") - if not token.user.is_active: - raise exceptions.AuthenticationFailed("User inactive") - + user = token.user # When LDAP authentication is active try to load user data from LDAP directory if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend': from netbox.authentication import LDAPBackend ldap_backend = LDAPBackend() # Load from LDAP if FIND_GROUP_PERMS is active - if ldap_backend.settings.FIND_GROUP_PERMS: - user = ldap_backend.populate_user(token.user.username) + # Always query LDAP when user is not active, otherwise it is never activated again + if ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active: + ldap_user = ldap_backend.populate_user(token.user.username) # If the user is found in the LDAP directory use it, if not fallback to the local user - if user: - return user, token + if ldap_user: + user = ldap_user - return token.user, token + if not user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return user, token class TokenPermissions(DjangoObjectPermissions): From eac2ace80b2ee333700c59dc1493cde42eb2e1b6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 09:58:31 -0400 Subject: [PATCH 17/20] Release v3.3.6 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.3.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 907ad6cf7..56c14e966 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.3.5 + placeholder: v3.3.6 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 3cd9bc4ee..bef1ce587 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.3.5 + placeholder: v3.3.6 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 1e2c4a90e..ffb831e9d 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,6 +1,6 @@ # NetBox v3.3 -## v3.3.6 (FUTURE) +## v3.3.6 (2022-10-26) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 14b66b2dd..cb26652b9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.6-dev' +VERSION = '3.3.6' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 9afcaea03..bce015110 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,18 +19,18 @@ graphene-django==2.15.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==8.5.6 +mkdocs-material==8.5.7 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 Pillow==9.2.0 -psycopg2-binary==2.9.3 +psycopg2-binary==2.9.5 PyYAML==6.0 -sentry-sdk==1.9.10 +sentry-sdk==1.10.1 social-auth-app-django==5.0.0 social-auth-core[openidconnect]==4.3.0 svgwrite==1.4.3 tablib==3.2.1 -tzdata==2022.4 +tzdata==2022.5 # Workaround for #7401 jsonschema==3.2.0 From 18332bdbf10d8d79d2110a6a18cc80efbb8dc67d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 10:23:50 -0400 Subject: [PATCH 18/20] PRVB --- docs/release-notes/version-3.3.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index ffb831e9d..8b8bd0060 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,5 +1,9 @@ # NetBox v3.3 +## v3.3.7 (FUTURE) + +--- + ## v3.3.6 (2022-10-26) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index cb26652b9..02e80b6cd 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.6' +VERSION = '3.3.7-dev' # Hostname HOSTNAME = platform.node() From 9e8234bb4528a1f1960fa5c7f8a7e9d53575954d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 11:33:11 -0400 Subject: [PATCH 19/20] Closes #8274: Enable associating a custom link with multiple object types --- docs/release-notes/version-3.4.md | 4 +++ netbox/extras/api/serializers.py | 7 ++-- netbox/extras/filtersets.py | 6 +++- netbox/extras/forms/bulk_edit.py | 5 --- netbox/extras/forms/bulk_import.py | 6 ++-- netbox/extras/forms/filtersets.py | 4 +-- netbox/extras/forms/model_forms.py | 4 +-- netbox/extras/graphql/types.py | 2 +- .../0081_customlink_content_types.py | 32 +++++++++++++++++++ netbox/extras/models/models.py | 8 ++--- netbox/extras/templatetags/custom_links.py | 3 +- netbox/extras/tests/test_api.py | 11 +++---- netbox/extras/tests/test_filtersets.py | 11 ++++--- netbox/extras/tests/test_views.py | 20 ++++++------ netbox/netbox/tables/tables.py | 2 +- netbox/templates/extras/customlink.html | 20 ++++++++---- 16 files changed, 94 insertions(+), 51 deletions(-) create mode 100644 netbox/extras/migrations/0081_customlink_content_types.py diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index ba5947364..873967456 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -8,6 +8,7 @@ * Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error. * The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. * The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. +* The `content_type` field on the CustomLink model has been renamed to `content_types` and now supports the assignment of multiple content types. ### New Features @@ -22,6 +23,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a ### Enhancements * [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects +* [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types @@ -57,6 +59,8 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * Added optional `weight` and `weight_unit` fields * dcim.Rack * Added optional `weight` and `weight_unit` fields +* extras.CustomLink + * Renamed `content_type` field to `content_types` * ipam.FHRPGroup * Added optional `name` field diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 99f4dd02b..f8a5862a3 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -117,14 +117,15 @@ class CustomFieldSerializer(ValidatedModelSerializer): class CustomLinkSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') - content_type = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()) + content_types = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), + many=True ) class Meta: model = CustomLink fields = [ - 'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', + 'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', 'created', 'last_updated', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 1b1b049c7..c0114bb58 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -93,11 +93,15 @@ class CustomLinkFilterSet(BaseFilterSet): method='search', label='Search', ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) + content_types = ContentTypeFilter() class Meta: model = CustomLink fields = [ - 'id', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', + 'id', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', ] def search(self, queryset, name, value): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index b1d8a6c21..26c6a195d 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -53,11 +53,6 @@ class CustomLinkBulkEditForm(BulkEditForm): queryset=CustomLink.objects.all(), widget=forms.MultipleHiddenInput ) - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links'), - required=False - ) enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 0303dae30..bcc392805 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -53,16 +53,16 @@ class CustomFieldCSVForm(CSVModelForm): class CustomLinkCSVForm(CSVModelForm): - content_type = CSVContentTypeField( + content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links'), - help_text="Assigned object type" + help_text="One or more assigned object types" ) class Meta: model = CustomLink fields = ( - 'name', 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', + 'name', 'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 059f0d9f2..2e8d4862d 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -121,9 +121,9 @@ class JobResultFilterForm(FilterForm): class CustomLinkFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('content_type', 'enabled', 'new_window', 'weight')), + ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')), ) - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links'), required=False diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index eca93849b..8b00c2779 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -63,13 +63,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm): - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links') ) fieldsets = ( - ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), + ('Custom Link', ('name', 'content_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), ('Templates', ('link_text', 'link_url')), ) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 41a6103d3..c9f897715 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -35,7 +35,7 @@ class CustomLinkType(ObjectType): class Meta: model = models.CustomLink - fields = '__all__' + exclude = ('content_types', ) filterset_class = filtersets.CustomLinkFilterSet diff --git a/netbox/extras/migrations/0081_customlink_content_types.py b/netbox/extras/migrations/0081_customlink_content_types.py new file mode 100644 index 000000000..2f0f23509 --- /dev/null +++ b/netbox/extras/migrations/0081_customlink_content_types.py @@ -0,0 +1,32 @@ +from django.db import migrations, models + + +def copy_content_types(apps, schema_editor): + CustomLink = apps.get_model('extras', 'CustomLink') + + for customlink in CustomLink.objects.all(): + customlink.content_types.set([customlink.content_type]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0080_search'), + ] + + operations = [ + migrations.AddField( + model_name='customlink', + name='content_types', + field=models.ManyToManyField(related_name='custom_links', to='contenttypes.contenttype'), + ), + migrations.RunPython( + code=copy_content_types, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveField( + model_name='customlink', + name='content_type', + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 6d7d2ae04..5c07c360c 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -197,10 +197,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. """ - content_type = models.ForeignKey( + content_types = models.ManyToManyField( to=ContentType, - on_delete=models.CASCADE, - limit_choices_to=FeatureQuery('custom_links') + related_name='custom_links', + help_text='The object type(s) to which this link applies.' ) name = models.CharField( max_length=100, @@ -236,7 +236,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged ) clone_fields = ( - 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', + 'enabled', 'weight', 'group_name', 'button_class', 'new_window', ) class Meta: diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index a73eb3fb4..b7d8d1448 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -3,7 +3,6 @@ from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe from extras.models import CustomLink -from utilities.utils import render_jinja2 register = template.Library() @@ -34,7 +33,7 @@ def custom_links(context, obj): Render all applicable links for the given object. """ content_type = ContentType.objects.get_for_model(obj) - custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) + custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True) if not custom_links: return '' diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 7a9ee487d..c26b95c08 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -137,21 +137,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { - 'content_type': 'dcim.site', + 'content_types': ['dcim.site'], 'name': 'Custom Link 4', 'enabled': True, 'link_text': 'Link 4', 'link_url': 'http://example.com/?4', }, { - 'content_type': 'dcim.site', + 'content_types': ['dcim.site'], 'name': 'Custom Link 5', 'enabled': True, 'link_text': 'Link 5', 'link_url': 'http://example.com/?5', }, { - 'content_type': 'dcim.site', + 'content_types': ['dcim.site'], 'name': 'Custom Link 6', 'enabled': False, 'link_text': 'Link 6', @@ -169,21 +169,18 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): custom_links = ( CustomLink( - content_type=site_ct, name='Custom Link 1', enabled=True, link_text='Link 1', link_url='http://example.com/?1', ), CustomLink( - content_type=site_ct, name='Custom Link 2', enabled=True, link_text='Link 2', link_url='http://example.com/?2', ), CustomLink( - content_type=site_ct, name='Custom Link 3', enabled=False, link_text='Link 3', @@ -191,6 +188,8 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): ), ) CustomLink.objects.bulk_create(custom_links) + for i, custom_link in enumerate(custom_links): + custom_link.content_types.set([site_ct]) class ExportTemplateTest(APIViewTestCases.APIViewTestCase): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 9f9483bbb..3d4dd4cf1 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -168,7 +168,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): custom_links = ( CustomLink( name='Custom Link 1', - content_type=content_types[0], enabled=True, weight=100, new_window=False, @@ -177,7 +176,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): ), CustomLink( name='Custom Link 2', - content_type=content_types[1], enabled=True, weight=200, new_window=False, @@ -186,7 +184,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): ), CustomLink( name='Custom Link 3', - content_type=content_types[2], enabled=False, weight=300, new_window=True, @@ -195,13 +192,17 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): ), ) CustomLink.objects.bulk_create(custom_links) + for i, custom_link in enumerate(custom_links): + custom_link.content_types.set([content_types[i]]) def test_name(self): params = {'name': ['Custom Link 1', 'Custom Link 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_type(self): - params = {'content_type': ContentType.objects.get(model='site').pk} + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_weight(self): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 9634038c1..cfde58782 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -59,17 +59,19 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) - CustomLink.objects.bulk_create(( - CustomLink(name='Custom Link 1', content_type=site_ct, enabled=True, link_text='Link 1', link_url='http://example.com/?1'), - CustomLink(name='Custom Link 2', content_type=site_ct, enabled=True, link_text='Link 2', link_url='http://example.com/?2'), - CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'), - )) + custom_links = ( + CustomLink(name='Custom Link 1', enabled=True, link_text='Link 1', link_url='http://example.com/?1'), + CustomLink(name='Custom Link 2', enabled=True, link_text='Link 2', link_url='http://example.com/?2'), + CustomLink(name='Custom Link 3', enabled=False, link_text='Link 3', link_url='http://example.com/?3'), + ) + CustomLink.objects.bulk_create(custom_links) + for i, custom_link in enumerate(custom_links): + custom_link.content_types.set([site_ct]) cls.form_data = { 'name': 'Custom Link X', - 'content_type': site_ct.pk, + 'content_types': [site_ct.pk], 'enabled': False, 'weight': 100, 'button_class': CustomLinkButtonClassChoices.DEFAULT, @@ -78,7 +80,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,content_type,enabled,weight,button_class,link_text,link_url", + "name,content_types,enabled,weight,button_class,link_text,link_url", "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4", "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5", "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6", @@ -327,13 +329,13 @@ class CustomLinkTest(TestCase): def test_view_object_with_custom_link(self): customlink = CustomLink( - content_type=ContentType.objects.get_for_model(Site), name='Test', link_text='FOO {{ obj.name }} BAR', link_url='http://example.com/?site={{ obj.slug }}', new_window=False ) customlink.save() + customlink.content_types.set([ContentType.objects.get_for_model(Site)]) site = Site(name='Test Site', slug='test-site') site.save() diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 9b86b2ed3..50c109be8 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -191,7 +191,7 @@ class NetBoxTable(BaseTable): extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) - custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) + custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True) extra_columns.extend([ (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links ]) diff --git a/netbox/templates/extras/customlink.html b/netbox/templates/extras/customlink.html index 1f3866182..ff0f7423e 100644 --- a/netbox/templates/extras/customlink.html +++ b/netbox/templates/extras/customlink.html @@ -6,19 +6,13 @@
-
- Custom Link -
+
Custom Link
- - - - @@ -42,6 +36,18 @@
Name {{ object.name }}
Content Type{{ object.content_type }}
Enabled {% checkmark object.enabled %}
+
+
Assigned Models
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+
{% plugin_left_page object %}
From 16919cc1d9484635e5fb9d6c1da9dd9344db2747 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 13:30:45 -0400 Subject: [PATCH 20/20] Closes #10761: Enable associating an export template with multiple object types --- docs/release-notes/version-3.4.md | 5 ++- netbox/extras/api/serializers.py | 5 ++- netbox/extras/filtersets.py | 6 ++- netbox/extras/forms/bulk_edit.py | 5 --- netbox/extras/forms/bulk_import.py | 6 +-- netbox/extras/forms/filtersets.py | 4 +- netbox/extras/forms/model_forms.py | 4 +- netbox/extras/graphql/types.py | 2 +- .../0082_exporttemplate_content_types.py | 40 +++++++++++++++++++ netbox/extras/models/models.py | 16 +++----- netbox/extras/tests/test_api.py | 13 +++--- netbox/extras/tests/test_filtersets.py | 15 ++++--- netbox/extras/tests/test_views.py | 19 +++++---- netbox/netbox/views/generic/bulk_views.py | 2 +- netbox/templates/extras/exporttemplate.html | 16 ++++++-- netbox/utilities/templatetags/buttons.py | 2 +- 16 files changed, 104 insertions(+), 56 deletions(-) create mode 100644 netbox/extras/migrations/0082_exporttemplate_content_types.py diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 873967456..93e2c8841 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -8,7 +8,7 @@ * Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error. * The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. * The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. -* The `content_type` field on the CustomLink model has been renamed to `content_types` and now supports the assignment of multiple content types. +* The `content_type` field on the CustomLink and ExportTemplate models have been renamed to `content_types` and now supports the assignment of multiple content types. ### New Features @@ -32,6 +32,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types * [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields +* [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types ### Plugins API @@ -61,6 +62,8 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * Added optional `weight` and `weight_unit` fields * extras.CustomLink * Renamed `content_type` field to `content_types` +* extras.ExportTemplate + * Renamed `content_type` field to `content_types` * ipam.FHRPGroup * Added optional `name` field diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index f8a5862a3..ac025ff16 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -136,14 +136,15 @@ class CustomLinkSerializer(ValidatedModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') - content_type = ContentTypeField( + content_types = ContentTypeField( queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), + many=True ) class Meta: model = ExportTemplate fields = [ - 'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', + 'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type', 'file_extension', 'as_attachment', 'created', 'last_updated', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index c0114bb58..22fe6537e 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -120,10 +120,14 @@ class ExportTemplateFilterSet(BaseFilterSet): method='search', label='Search', ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) + content_types = ContentTypeFilter() class Meta: model = ExportTemplate - fields = ['id', 'content_type', 'name', 'description'] + fields = ['id', 'content_types', 'name', 'description'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 26c6a195d..df17324ec 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -76,11 +76,6 @@ class ExportTemplateBulkEditForm(BulkEditForm): queryset=ExportTemplate.objects.all(), widget=forms.MultipleHiddenInput ) - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('export_templates'), - required=False - ) description = forms.CharField( max_length=200, required=False diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index bcc392805..ee638015b 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -68,16 +68,16 @@ class CustomLinkCSVForm(CSVModelForm): class ExportTemplateCSVForm(CSVModelForm): - content_type = CSVContentTypeField( + content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates'), - help_text="Assigned object type" + help_text="One or more assigned object types" ) class Meta: model = ExportTemplate fields = ( - 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', + 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 2e8d4862d..a164a3d95 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -148,9 +148,9 @@ class CustomLinkFilterForm(FilterForm): class ExportTemplateFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('content_type', 'mime_type', 'file_extension', 'as_attachment')), + ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')), ) - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates'), required=False diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 8b00c2779..7ff4f3e27 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -89,13 +89,13 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): class ExportTemplateForm(BootstrapMixin, forms.ModelForm): - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates') ) fieldsets = ( - ('Export Template', ('name', 'content_type', 'description')), + ('Export Template', ('name', 'content_types', 'description')), ('Template', ('template_code',)), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index c9f897715..3be7b371e 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -43,7 +43,7 @@ class ExportTemplateType(ObjectType): class Meta: model = models.ExportTemplate - fields = '__all__' + exclude = ('content_types', ) filterset_class = filtersets.ExportTemplateFilterSet diff --git a/netbox/extras/migrations/0082_exporttemplate_content_types.py b/netbox/extras/migrations/0082_exporttemplate_content_types.py new file mode 100644 index 000000000..34a9c77e6 --- /dev/null +++ b/netbox/extras/migrations/0082_exporttemplate_content_types.py @@ -0,0 +1,40 @@ +from django.db import migrations, models + + +def copy_content_types(apps, schema_editor): + ExportTemplate = apps.get_model('extras', 'ExportTemplate') + + for et in ExportTemplate.objects.all(): + et.content_types.set([et.content_type]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0081_customlink_content_types'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='content_types', + field=models.ManyToManyField(related_name='export_templates', to='contenttypes.contenttype'), + ), + migrations.RunPython( + code=copy_content_types, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveConstraint( + model_name='exporttemplate', + name='extras_exporttemplate_unique_content_type_name', + ), + migrations.RemoveField( + model_name='exporttemplate', + name='content_type', + ), + migrations.AlterModelOptions( + name='exporttemplate', + options={'ordering': ('name',)}, + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 5c07c360c..a8b2f2647 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -268,10 +268,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): - content_type = models.ForeignKey( + content_types = models.ManyToManyField( to=ContentType, - on_delete=models.CASCADE, - limit_choices_to=FeatureQuery('export_templates') + related_name='export_templates', + help_text='The object type(s) to which this template applies.' ) name = models.CharField( max_length=100 @@ -301,16 +301,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): ) class Meta: - ordering = ['content_type', 'name'] - constraints = ( - models.UniqueConstraint( - fields=('content_type', 'name'), - name='%(app_label)s_%(class)s_unique_content_type_name' - ), - ) + ordering = ('name',) def __str__(self): - return f"{self.content_type}: {self.name}" + return self.name def get_absolute_url(self): return reverse('extras:exporttemplate', args=[self.pk]) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index c26b95c08..42246b651 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -197,17 +197,17 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { - 'content_type': 'dcim.device', + 'content_types': ['dcim.device'], 'name': 'Test Export Template 4', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, { - 'content_type': 'dcim.device', + 'content_types': ['dcim.device'], 'name': 'Test Export Template 5', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, { - 'content_type': 'dcim.device', + 'content_types': ['dcim.device'], 'name': 'Test Export Template 6', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, @@ -218,26 +218,23 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): - ct = ContentType.objects.get_for_model(Device) - export_templates = ( ExportTemplate( - content_type=ct, name='Export Template 1', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ), ExportTemplate( - content_type=ct, name='Export Template 2', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ), ExportTemplate( - content_type=ct, name='Export Template 3', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ), ) ExportTemplate.objects.bulk_create(export_templates) + for et in export_templates: + et.content_types.set([ContentType.objects.get_for_model(Device)]) class TagTest(APIViewTestCases.APIViewTestCase): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 3d4dd4cf1..dd1fdb6b3 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -228,22 +228,25 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests): @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) export_templates = ( - ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING', description='foobar1'), - ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING', description='foobar2'), - ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'), + ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'), + ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'), + ExportTemplate(name='Export Template 3', template_code='TESTING'), ) ExportTemplate.objects.bulk_create(export_templates) + for i, et in enumerate(export_templates): + et.content_types.set([content_types[i]]) def test_name(self): params = {'name': ['Export Template 1', 'Export Template 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_type(self): - params = {'content_type': ContentType.objects.get(model='site').pk} + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_description(self): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index cfde58782..da11d42ad 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -98,23 +98,26 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}""" - ExportTemplate.objects.bulk_create(( - ExportTemplate(name='Export Template 1', content_type=site_ct, template_code=TEMPLATE_CODE), - ExportTemplate(name='Export Template 2', content_type=site_ct, template_code=TEMPLATE_CODE), - ExportTemplate(name='Export Template 3', content_type=site_ct, template_code=TEMPLATE_CODE), - )) + + export_templates = ( + ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE), + ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE), + ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE), + ) + ExportTemplate.objects.bulk_create(export_templates) + for et in export_templates: + et.content_types.set([site_ct]) cls.form_data = { 'name': 'Export Template X', - 'content_type': site_ct.pk, + 'content_types': [site_ct.pk], 'template_code': TEMPLATE_CODE, } cls.csv_data = ( - "name,content_type,template_code", + "name,content_types,template_code", f"Export Template 4,dcim.site,{TEMPLATE_CODE}", f"Export Template 5,dcim.site,{TEMPLATE_CODE}", f"Export Template 6,dcim.site,{TEMPLATE_CODE}", diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index f0741af2c..69f9842ca 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -142,7 +142,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): # Render an ExportTemplate elif request.GET['export']: - template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + template = get_object_or_404(ExportTemplate, content_types=content_type, name=request.GET['export']) return self.export_template(template, request) # Check for YAML export support on the model diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html index 912702b86..d14294355 100644 --- a/netbox/templates/extras/exporttemplate.html +++ b/netbox/templates/extras/exporttemplate.html @@ -18,10 +18,6 @@
- - - - @@ -45,6 +41,18 @@
Content Type{{ object.content_type }}
Name {{ object.name }}
+
+
Assigned Models
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+
{% plugin_left_page object %}
diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 4b8178405..bcdb099d8 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -83,7 +83,7 @@ def export_button(context, model): data_format = 'YAML' if hasattr(content_type.model_class(), 'to_yaml') else 'CSV' # Retrieve all export templates for this model - export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_type=content_type) + export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_types=content_type) return { 'perms': context['perms'],