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/.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
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.3.md b/docs/release-notes/version-3.3.md
index de0f1a40a..8b8bd0060 100644
--- a/docs/release-notes/version-3.3.md
+++ b/docs/release-notes/version-3.3.md
@@ -1,10 +1,17 @@
# NetBox v3.3
-## v3.3.6 (FUTURE)
+## v3.3.7 (FUTURE)
+
+---
+
+## v3.3.6 (2022-10-26)
### 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
@@ -12,10 +19,17 @@
* [#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
+* [#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
+* [#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/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md
index 80b94d6c2..93e2c8841 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 and ExportTemplate models have 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
@@ -30,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
@@ -38,6 +41,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
@@ -56,6 +60,10 @@ 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`
+* extras.ExportTemplate
+ * Renamed `content_type` field to `content_types`
* ipam.FHRPGroup
* Added optional `name` field
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/filtersets.py b/netbox/dcim/filtersets.py
index a0c5e545c..a200b1ece 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)',
@@ -1360,7 +1366,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()
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/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index d4922fb1d..92298bd73 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -1670,6 +1670,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]
diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py
index 99f4dd02b..ac025ff16 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',
]
@@ -135,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 1b1b049c7..22fe6537e 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):
@@ -116,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/__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/bulk_edit.py b/netbox/extras/forms/bulk_edit.py
index b1d8a6c21..df17324ec 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()
@@ -81,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 0303dae30..ee638015b 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/netbox/extras/forms/bulk_import.py
@@ -53,31 +53,31 @@ 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',
)
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 059f0d9f2..a164a3d95 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
@@ -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/models.py b/netbox/extras/forms/model_forms.py
similarity index 96%
rename from netbox/extras/forms/models.py
rename to netbox/extras/forms/model_forms.py
index eca93849b..7ff4f3e27 100644
--- a/netbox/extras/forms/models.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')),
)
@@ -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 41a6103d3..3be7b371e 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
@@ -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/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/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 6d7d2ae04..a8b2f2647 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:
@@ -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/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..42246b651 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):
@@ -198,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 %}',
},
@@ -219,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 9f9483bbb..dd1fdb6b3 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):
@@ -227,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 2619364c5..85e5aea5e 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -66,18 +66,19 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod
def setUpTestData(cls):
-
site_ct = ContentType.objects.get_for_model(Site)
custom_links = (
- 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'),
+ 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,
@@ -86,7 +87,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",
@@ -111,25 +112,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 %}"""
export_templates = (
- 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),
+ 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}",
@@ -366,13 +368,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/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..061462e71 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,
@@ -552,6 +550,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 +564,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/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(
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index a5f487f7d..df307c99f 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -985,6 +985,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
+
@register_model_view(FHRPGroup, 'delete')
class FHRPGroupDeleteView(generic.ObjectDeleteView):
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):
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/settings.py b/netbox/netbox/settings.py
index b1b285283..e1fe10a69 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -79,6 +79,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')
@@ -124,6 +125,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')
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/netbox/views/__init__.py b/netbox/netbox/views/__init__.py
index 0f35dab49..bfad4af99 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.contrib.contenttypes.models import ContentType
@@ -8,6 +9,7 @@ from django.http import HttpResponseServerError
from django.shortcuts import redirect, render
from django.template import loader
from django.template.exceptions import TemplateDoesNotExist
+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
@@ -26,102 +28,91 @@ from netbox.forms import SearchForm
from netbox.search import LookupTypes
from netbox.search.backends import search_backend
from netbox.tables import SearchTable
-from tenancy.models import Tenant
+from tenancy.models import Contact, Tenant
from utilities.htmx import is_htmx
from utilities.paginator import EnhancedPaginator, get_paginate_count
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/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
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
index bd9983a9b..175ce7d54 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
@@ -335,7 +335,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
ids = [int(record["id"]) for record in records]
qs = self.queryset.model.objects.filter(id__in=ids)
- print(qs)
objs = {}
for obj in qs:
objs[obj.id] = obj
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index 9aa71b01b..3f5a9f614 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -179,7 +179,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.
@@ -396,7 +396,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(
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/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 @@
-
+
Name |
{{ object.name }} |
-
- Content Type |
- {{ object.content_type }} |
-
Enabled |
{% checkmark object.enabled %} |
@@ -42,6 +36,18 @@
+
+
+
+
+ {% for ct in object.content_types.all %}
+
+ {{ ct }} |
+
+ {% endfor %}
+
+
+
{% plugin_left_page object %}
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 @@
-
- Content Type |
- {{ object.content_type }} |
-
Name |
{{ object.name }} |
@@ -45,6 +41,18 @@
+
+
+
+
+ {% for ct in object.content_types.all %}
+
+ {{ ct }} |
+
+ {% endfor %}
+
+
+
{% plugin_left_page object %}
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 %}
+ {% plugin_right_page object %}
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 @@