7961 fix merge

This commit is contained in:
Arthur 2022-10-26 11:54:50 -07:00
commit 969c6dbae8
58 changed files with 365 additions and 220 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.3.5 placeholder: v3.3.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.3.5 placeholder: v3.3.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -1,13 +1,14 @@
<!-- <!--
Thank you for your interest in contributing to NetBox! Please note that Thank you for your interest in contributing to NetBox! Please note that
our contribution policy requires that a feature request or bug report be our contribution policy requires that a feature request or bug report be
approved and assigned prior to filing a pull request. This helps avoid approved and assigned prior to opening a pull request. This helps avoid
wasting time and effort on something that we might not be able to accept. waste time and effort on a proposed change that we might not be able to
accept.
IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED
TO YOU, IT WE BE CLOSED AUTOMATICALLY. TO YOU, IT WILL BE CLOSED AUTOMATICALLY.
Specify your assigned issue number on the line below. Please specify your assigned issue number on the line below.
--> -->
### Fixes: #1234 ### Fixes: #1234

View File

@ -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. 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 ::: netbox.views.generic.base.BaseObjectView
options:
members:
- get_queryset
- get_object
- get_extra_context
::: netbox.views.generic.ObjectView ::: netbox.views.generic.ObjectView
options: options:
members: members:
- get_object
- get_template_name - get_template_name
::: netbox.views.generic.ObjectEditView ::: netbox.views.generic.ObjectEditView
options: options:
members: members:
- get_object
- alter_object - alter_object
::: netbox.views.generic.ObjectDeleteView ::: netbox.views.generic.ObjectDeleteView
options: options:
members: members: false
- get_object
::: netbox.views.generic.ObjectChildrenView ::: netbox.views.generic.ObjectChildrenView
options: 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. 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 ::: netbox.views.generic.base.BaseMultiObjectView
options:
members:
- get_queryset
- get_extra_context
::: netbox.views.generic.ObjectListView ::: netbox.views.generic.ObjectListView
options: options:

View File

@ -1,10 +1,17 @@
# NetBox v3.3 # NetBox v3.3
## v3.3.6 (FUTURE) ## v3.3.7 (FUTURE)
---
## v3.3.6 (2022-10-26)
### Enhancements ### 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 * [#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 * [#10685](https://github.com/netbox-community/netbox/issues/10685) - Position A/Z termination cards above the fold under circuit view
### Bug Fixes ### Bug Fixes
@ -12,10 +19,17 @@
* [#9669](https://github.com/netbox-community/netbox/issues/9669) - Strip colons from usernames when using remote authentication * [#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 * [#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 * [#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 * [#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 * [#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 * [#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+ * [#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
--- ---

View File

@ -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. * 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 `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 `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 ### New Features
@ -22,6 +23,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
### Enhancements ### Enhancements
* [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 ### 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 * [#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 * [#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 * [#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 ### 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 * Added optional `weight` and `weight_unit` fields
* dcim.Rack * dcim.Rack
* Added optional `weight` and `weight_unit` fields * 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 * ipam.FHRPGroup
* Added optional `name` field * Added optional `name` field

View File

@ -1,4 +1,4 @@
from .bulk_edit import * from .bulk_edit import *
from .bulk_import import * from .bulk_import import *
from .filtersets import * from .filtersets import *
from .models import * from .model_forms import *

View File

@ -800,6 +800,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
to_field_name='slug', to_field_name='slug',
label='Manufacturer (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( device_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),
label='Device type (ID)', label='Device type (ID)',
@ -1360,7 +1366,7 @@ class InterfaceFilterSet(
try: try:
devices = Device.objects.filter(pk__in=id_list) devices = Device.objects.filter(pk__in=id_list)
for device in devices: 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) return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist: except Device.DoesNotExist:
return queryset.none() return queryset.none()

View File

@ -1,4 +1,4 @@
from .models import * from .model_forms import *
from .filtersets import * from .filtersets import *
from .object_create import * from .object_create import *
from .object_import import * from .object_import import *

View File

@ -3,7 +3,7 @@ from django import forms
from circuits.models import Circuit, CircuitTermination, Provider from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import * from dcim.models import *
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .models import CableForm from .model_forms import CableForm
def get_cable_form(a_type, b_type): def get_cable_form(a_type, b_type):

View File

@ -3,7 +3,7 @@ from django import forms
from dcim.models import * from dcim.models import *
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from . import models as model_forms from . import model_forms
__all__ = ( __all__ = (
'ComponentCreateForm', 'ComponentCreateForm',

View File

@ -1670,6 +1670,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
device_types = DeviceType.objects.all()[:2] device_types = DeviceType.objects.all()[:2]
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_devicerole(self):
device_roles = DeviceRole.objects.all()[:2] device_roles = DeviceRole.objects.all()[:2]

View File

@ -117,14 +117,15 @@ class CustomFieldSerializer(ValidatedModelSerializer):
class CustomLinkSerializer(ValidatedModelSerializer): class CustomLinkSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
content_type = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()) queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
many=True
) )
class Meta: class Meta:
model = CustomLink model = CustomLink
fields = [ 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', 'button_class', 'new_window', 'created', 'last_updated',
] ]
@ -135,14 +136,15 @@ class CustomLinkSerializer(ValidatedModelSerializer):
class ExportTemplateSerializer(ValidatedModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
content_type = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
many=True
) )
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = [ 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', 'file_extension', 'as_attachment', 'created', 'last_updated',
] ]

View File

@ -93,11 +93,15 @@ class CustomLinkFilterSet(BaseFilterSet):
method='search', method='search',
label='Search', label='Search',
) )
content_type_id = MultiValueNumberFilter(
field_name='content_types__id'
)
content_types = ContentTypeFilter()
class Meta: class Meta:
model = CustomLink model = CustomLink
fields = [ 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): def search(self, queryset, name, value):
@ -116,10 +120,14 @@ class ExportTemplateFilterSet(BaseFilterSet):
method='search', method='search',
label='Search', label='Search',
) )
content_type_id = MultiValueNumberFilter(
field_name='content_types__id'
)
content_types = ContentTypeFilter()
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ['id', 'content_type', 'name', 'description'] fields = ['id', 'content_types', 'name', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -1,4 +1,4 @@
from .models import * from .model_forms import *
from .filtersets import * from .filtersets import *
from .bulk_edit import * from .bulk_edit import *
from .bulk_import import * from .bulk_import import *

View File

@ -53,11 +53,6 @@ class CustomLinkBulkEditForm(BulkEditForm):
queryset=CustomLink.objects.all(), queryset=CustomLink.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
) )
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links'),
required=False
)
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(
required=False, required=False,
widget=BulkEditNullBooleanSelect() widget=BulkEditNullBooleanSelect()
@ -81,11 +76,6 @@ class ExportTemplateBulkEditForm(BulkEditForm):
queryset=ExportTemplate.objects.all(), queryset=ExportTemplate.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
) )
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'),
required=False
)
description = forms.CharField( description = forms.CharField(
max_length=200, max_length=200,
required=False required=False

View File

@ -53,31 +53,31 @@ class CustomFieldCSVForm(CSVModelForm):
class CustomLinkCSVForm(CSVModelForm): class CustomLinkCSVForm(CSVModelForm):
content_type = CSVContentTypeField( content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links'), limit_choices_to=FeatureQuery('custom_links'),
help_text="Assigned object type" help_text="One or more assigned object types"
) )
class Meta: class Meta:
model = CustomLink model = CustomLink
fields = ( 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', 'link_url',
) )
class ExportTemplateCSVForm(CSVModelForm): class ExportTemplateCSVForm(CSVModelForm):
content_type = CSVContentTypeField( content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'), limit_choices_to=FeatureQuery('export_templates'),
help_text="Assigned object type" help_text="One or more assigned object types"
) )
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ( 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',
) )

View File

@ -121,9 +121,9 @@ class JobResultFilterForm(FilterForm):
class CustomLinkFilterForm(FilterForm): class CustomLinkFilterForm(FilterForm):
fieldsets = ( fieldsets = (
(None, ('q',)), (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(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links'), limit_choices_to=FeatureQuery('custom_links'),
required=False required=False
@ -148,9 +148,9 @@ class CustomLinkFilterForm(FilterForm):
class ExportTemplateFilterForm(FilterForm): class ExportTemplateFilterForm(FilterForm):
fieldsets = ( fieldsets = (
(None, ('q',)), (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(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'), limit_choices_to=FeatureQuery('export_templates'),
required=False required=False

View File

@ -63,13 +63,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
class CustomLinkForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField( content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links') limit_choices_to=FeatureQuery('custom_links')
) )
fieldsets = ( 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')), ('Templates', ('link_text', 'link_url')),
) )
@ -89,13 +89,13 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
class ExportTemplateForm(BootstrapMixin, forms.ModelForm): class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField( content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates') limit_choices_to=FeatureQuery('export_templates')
) )
fieldsets = ( fieldsets = (
('Export Template', ('name', 'content_type', 'description')), ('Export Template', ('name', 'content_types', 'description')),
('Template', ('template_code',)), ('Template', ('template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
) )

View File

@ -35,7 +35,7 @@ class CustomLinkType(ObjectType):
class Meta: class Meta:
model = models.CustomLink model = models.CustomLink
fields = '__all__' exclude = ('content_types', )
filterset_class = filtersets.CustomLinkFilterSet filterset_class = filtersets.CustomLinkFilterSet
@ -43,7 +43,7 @@ class ExportTemplateType(ObjectType):
class Meta: class Meta:
model = models.ExportTemplate model = models.ExportTemplate
fields = '__all__' exclude = ('content_types', )
filterset_class = filtersets.ExportTemplateFilterSet filterset_class = filtersets.ExportTemplateFilterSet

View File

@ -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',
),
]

View File

@ -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',)},
),
]

View File

@ -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 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. code to be rendered with an object as context.
""" """
content_type = models.ForeignKey( content_types = models.ManyToManyField(
to=ContentType, to=ContentType,
on_delete=models.CASCADE, related_name='custom_links',
limit_choices_to=FeatureQuery('custom_links') help_text='The object type(s) to which this link applies.'
) )
name = models.CharField( name = models.CharField(
max_length=100, max_length=100,
@ -236,7 +236,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
) )
clone_fields = ( clone_fields = (
'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
) )
class Meta: class Meta:
@ -268,10 +268,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
content_type = models.ForeignKey( content_types = models.ManyToManyField(
to=ContentType, to=ContentType,
on_delete=models.CASCADE, related_name='export_templates',
limit_choices_to=FeatureQuery('export_templates') help_text='The object type(s) to which this template applies.'
) )
name = models.CharField( name = models.CharField(
max_length=100 max_length=100
@ -301,16 +301,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
) )
class Meta: class Meta:
ordering = ['content_type', 'name'] ordering = ('name',)
constraints = (
models.UniqueConstraint(
fields=('content_type', 'name'),
name='%(app_label)s_%(class)s_unique_content_type_name'
),
)
def __str__(self): def __str__(self):
return f"{self.content_type}: {self.name}" return self.name
def get_absolute_url(self): def get_absolute_url(self):
return reverse('extras:exporttemplate', args=[self.pk]) return reverse('extras:exporttemplate', args=[self.pk])

View File

@ -3,7 +3,6 @@ from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from extras.models import CustomLink from extras.models import CustomLink
from utilities.utils import render_jinja2
register = template.Library() register = template.Library()
@ -34,7 +33,7 @@ def custom_links(context, obj):
Render all applicable links for the given object. Render all applicable links for the given object.
""" """
content_type = ContentType.objects.get_for_model(obj) 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: if not custom_links:
return '' return ''

View File

@ -137,21 +137,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['display', 'id', 'name', 'url']
create_data = [ create_data = [
{ {
'content_type': 'dcim.site', 'content_types': ['dcim.site'],
'name': 'Custom Link 4', 'name': 'Custom Link 4',
'enabled': True, 'enabled': True,
'link_text': 'Link 4', 'link_text': 'Link 4',
'link_url': 'http://example.com/?4', 'link_url': 'http://example.com/?4',
}, },
{ {
'content_type': 'dcim.site', 'content_types': ['dcim.site'],
'name': 'Custom Link 5', 'name': 'Custom Link 5',
'enabled': True, 'enabled': True,
'link_text': 'Link 5', 'link_text': 'Link 5',
'link_url': 'http://example.com/?5', 'link_url': 'http://example.com/?5',
}, },
{ {
'content_type': 'dcim.site', 'content_types': ['dcim.site'],
'name': 'Custom Link 6', 'name': 'Custom Link 6',
'enabled': False, 'enabled': False,
'link_text': 'Link 6', 'link_text': 'Link 6',
@ -169,21 +169,18 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
custom_links = ( custom_links = (
CustomLink( CustomLink(
content_type=site_ct,
name='Custom Link 1', name='Custom Link 1',
enabled=True, enabled=True,
link_text='Link 1', link_text='Link 1',
link_url='http://example.com/?1', link_url='http://example.com/?1',
), ),
CustomLink( CustomLink(
content_type=site_ct,
name='Custom Link 2', name='Custom Link 2',
enabled=True, enabled=True,
link_text='Link 2', link_text='Link 2',
link_url='http://example.com/?2', link_url='http://example.com/?2',
), ),
CustomLink( CustomLink(
content_type=site_ct,
name='Custom Link 3', name='Custom Link 3',
enabled=False, enabled=False,
link_text='Link 3', link_text='Link 3',
@ -191,6 +188,8 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
), ),
) )
CustomLink.objects.bulk_create(custom_links) 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): class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
@ -198,17 +197,17 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['display', 'id', 'name', 'url']
create_data = [ create_data = [
{ {
'content_type': 'dcim.device', 'content_types': ['dcim.device'],
'name': 'Test Export Template 4', 'name': 'Test Export Template 4',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}, },
{ {
'content_type': 'dcim.device', 'content_types': ['dcim.device'],
'name': 'Test Export Template 5', 'name': 'Test Export Template 5',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}, },
{ {
'content_type': 'dcim.device', 'content_types': ['dcim.device'],
'name': 'Test Export Template 6', 'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}, },
@ -219,26 +218,23 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
ct = ContentType.objects.get_for_model(Device)
export_templates = ( export_templates = (
ExportTemplate( ExportTemplate(
content_type=ct,
name='Export Template 1', name='Export Template 1',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
), ),
ExportTemplate( ExportTemplate(
content_type=ct,
name='Export Template 2', name='Export Template 2',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
), ),
ExportTemplate( ExportTemplate(
content_type=ct,
name='Export Template 3', name='Export Template 3',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
), ),
) )
ExportTemplate.objects.bulk_create(export_templates) 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): class TagTest(APIViewTestCases.APIViewTestCase):

View File

@ -168,7 +168,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
custom_links = ( custom_links = (
CustomLink( CustomLink(
name='Custom Link 1', name='Custom Link 1',
content_type=content_types[0],
enabled=True, enabled=True,
weight=100, weight=100,
new_window=False, new_window=False,
@ -177,7 +176,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
), ),
CustomLink( CustomLink(
name='Custom Link 2', name='Custom Link 2',
content_type=content_types[1],
enabled=True, enabled=True,
weight=200, weight=200,
new_window=False, new_window=False,
@ -186,7 +184,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
), ),
CustomLink( CustomLink(
name='Custom Link 3', name='Custom Link 3',
content_type=content_types[2],
enabled=False, enabled=False,
weight=300, weight=300,
new_window=True, new_window=True,
@ -195,13 +192,17 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
), ),
) )
CustomLink.objects.bulk_create(custom_links) 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): def test_name(self):
params = {'name': ['Custom Link 1', 'Custom Link 2']} params = {'name': ['Custom Link 1', 'Custom Link 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_type(self): def test_content_types(self):
params = {'content_type': ContentType.objects.get(model='site').pk} 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) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_weight(self): def test_weight(self):
@ -227,22 +228,25 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
export_templates = ( export_templates = (
ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING', description='foobar1'), ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING', description='foobar2'), ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'),
ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'), ExportTemplate(name='Export Template 3', template_code='TESTING'),
) )
ExportTemplate.objects.bulk_create(export_templates) ExportTemplate.objects.bulk_create(export_templates)
for i, et in enumerate(export_templates):
et.content_types.set([content_types[i]])
def test_name(self): def test_name(self):
params = {'name': ['Export Template 1', 'Export Template 2']} params = {'name': ['Export Template 1', 'Export Template 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_type(self): def test_content_types(self):
params = {'content_type': ContentType.objects.get(model='site').pk} 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) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self): def test_description(self):

View File

@ -66,18 +66,19 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site) site_ct = ContentType.objects.get_for_model(Site)
custom_links = ( 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 1', 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 2', 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 3', enabled=False, link_text='Link 3', link_url='http://example.com/?3'),
) )
CustomLink.objects.bulk_create(custom_links) CustomLink.objects.bulk_create(custom_links)
for i, custom_link in enumerate(custom_links):
custom_link.content_types.set([site_ct])
cls.form_data = { cls.form_data = {
'name': 'Custom Link X', 'name': 'Custom Link X',
'content_type': site_ct.pk, 'content_types': [site_ct.pk],
'enabled': False, 'enabled': False,
'weight': 100, 'weight': 100,
'button_class': CustomLinkButtonClassChoices.DEFAULT, 'button_class': CustomLinkButtonClassChoices.DEFAULT,
@ -86,7 +87,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( 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 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 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", "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6",
@ -111,25 +112,26 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site) site_ct = ContentType.objects.get_for_model(Site)
TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}""" TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
export_templates = ( export_templates = (
ExportTemplate(name='Export Template 1', content_type=site_ct, template_code=TEMPLATE_CODE), ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE),
ExportTemplate(name='Export Template 2', content_type=site_ct, template_code=TEMPLATE_CODE), ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE),
ExportTemplate(name='Export Template 3', content_type=site_ct, template_code=TEMPLATE_CODE), ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE),
) )
ExportTemplate.objects.bulk_create(export_templates) ExportTemplate.objects.bulk_create(export_templates)
for et in export_templates:
et.content_types.set([site_ct])
cls.form_data = { cls.form_data = {
'name': 'Export Template X', 'name': 'Export Template X',
'content_type': site_ct.pk, 'content_types': [site_ct.pk],
'template_code': TEMPLATE_CODE, 'template_code': TEMPLATE_CODE,
} }
cls.csv_data = ( cls.csv_data = (
"name,content_type,template_code", "name,content_types,template_code",
f"Export Template 4,dcim.site,{TEMPLATE_CODE}", f"Export Template 4,dcim.site,{TEMPLATE_CODE}",
f"Export Template 5,dcim.site,{TEMPLATE_CODE}", f"Export Template 5,dcim.site,{TEMPLATE_CODE}",
f"Export Template 6,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): def test_view_object_with_custom_link(self):
customlink = CustomLink( customlink = CustomLink(
content_type=ContentType.objects.get_for_model(Site),
name='Test', name='Test',
link_text='FOO {{ obj.name }} BAR', link_text='FOO {{ obj.name }} BAR',
link_url='http://example.com/?site={{ obj.slug }}', link_url='http://example.com/?site={{ obj.slug }}',
new_window=False new_window=False
) )
customlink.save() customlink.save()
customlink.content_types.set([ContentType.objects.get_for_model(Site)])
site = Site(name='Test Site', slug='test-site') site = Site(name='Test Site', slug='test-site')
site.save() site.save()

View File

@ -1,4 +1,4 @@
from .models import * from .model_forms import *
from .filtersets import * from .filtersets import *
from .bulk_create import * from .bulk_create import *
from .bulk_edit import * from .bulk_edit import *

View File

@ -3,14 +3,12 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
from extras.models import Tag
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.formfields import IPNetworkFormField from ipam.formfields import IPNetworkFormField
from ipam.models import * from ipam.models import *
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.exceptions import PermissionsViolation from utilities.exceptions import PermissionsViolation
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
@ -552,6 +550,7 @@ class FHRPGroupForm(NetBoxModelForm):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
instance = super().save(*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 # Check if we need to create a new IPAddress for the group
if self.cleaned_data.get('ip_address'): if self.cleaned_data.get('ip_address'):
@ -565,7 +564,7 @@ class FHRPGroupForm(NetBoxModelForm):
ipaddress.save() ipaddress.save()
# Check that the new IPAddress conforms with any assigned object-level permissions # 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() raise PermissionsViolation()
return instance return instance

View File

@ -375,7 +375,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
) )
assigned = columns.BooleanColumn( assigned = columns.BooleanColumn(
accessor='assigned_object_id', accessor='assigned_object_id',
linkify=True, linkify=lambda record: record.assigned_object.get_absolute_url(),
verbose_name='Assigned' verbose_name='Assigned'
) )
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -985,6 +985,12 @@ class FHRPGroupEditView(generic.ObjectEditView):
return return_url 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') @register_model_view(FHRPGroup, 'delete')
class FHRPGroupDeleteView(generic.ObjectDeleteView): class FHRPGroupDeleteView(generic.ObjectDeleteView):

View File

@ -58,22 +58,24 @@ class TokenAuthentication(authentication.TokenAuthentication):
if token.is_expired: if token.is_expired:
raise exceptions.AuthenticationFailed("Token expired") raise exceptions.AuthenticationFailed("Token expired")
if not token.user.is_active: user = token.user
raise exceptions.AuthenticationFailed("User inactive")
# When LDAP authentication is active try to load user data from LDAP directory # When LDAP authentication is active try to load user data from LDAP directory
if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend': if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
from netbox.authentication import LDAPBackend from netbox.authentication import LDAPBackend
ldap_backend = LDAPBackend() ldap_backend = LDAPBackend()
# Load from LDAP if FIND_GROUP_PERMS is active # Load from LDAP if FIND_GROUP_PERMS is active
if ldap_backend.settings.FIND_GROUP_PERMS: # Always query LDAP when user is not active, otherwise it is never activated again
user = ldap_backend.populate_user(token.user.username) 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 the user is found in the LDAP directory use it, if not fallback to the local user
if user: if ldap_user:
return user, token user = ldap_user
return token.user, token if not user.is_active:
raise exceptions.AuthenticationFailed("User inactive")
return user, token
class TokenPermissions(DjangoObjectPermissions): class TokenPermissions(DjangoObjectPermissions):

View File

@ -108,6 +108,5 @@ class ObjectValidationMixin:
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
if conforming_count != len(instance): if conforming_count != len(instance):
raise ObjectDoesNotExist raise ObjectDoesNotExist
else: elif not self.queryset.filter(pk=instance.pk).exists():
# Check that the instance is matched by the view's queryset raise ObjectDoesNotExist
self.queryset.get(pk=instance.pk)

View File

@ -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_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
CSRF_COOKIE_PATH = BASE_PATH or '/'
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') 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', {}) SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') 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_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')

View File

@ -191,7 +191,7 @@ class NetBoxTable(BaseTable):
extra_columns.extend([ extra_columns.extend([
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields (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([ extra_columns.extend([
(f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
]) ])

View File

@ -1,5 +1,6 @@
import platform import platform
import sys import sys
from collections import namedtuple
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -8,6 +9,7 @@ from django.http import HttpResponseServerError
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.template import loader from django.template import loader
from django.template.exceptions import TemplateDoesNotExist 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.decorators.csrf import requires_csrf_token
from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
from django.views.generic import View from django.views.generic import View
@ -26,102 +28,91 @@ from netbox.forms import SearchForm
from netbox.search import LookupTypes from netbox.search import LookupTypes
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
from netbox.tables import SearchTable from netbox.tables import SearchTable
from tenancy.models import Tenant from tenancy.models import Contact, Tenant
from utilities.htmx import is_htmx from utilities.htmx import is_htmx
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from virtualization.models import Cluster, VirtualMachine from virtualization.models import Cluster, VirtualMachine
from wireless.models import WirelessLAN, WirelessLink from wireless.models import WirelessLAN, WirelessLink
Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
class HomeView(View): class HomeView(View):
template_name = 'home.html' template_name = 'home.html'
def get(self, request): def get(self, request):
if settings.LOGIN_REQUIRED and not request.user.is_authenticated: 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 _path__is_complete=True
) ).count
connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( power_connections = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
_path__is_complete=True _path__is_complete=True
) ).count
connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter( interface_connections = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
_path__is_complete=True _path__is_complete=True
) ).count
def get_count_queryset(model):
return model.objects.restrict(request.user, 'view').count
def build_stats(): def build_stats():
org = ( org = (
("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count), Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)),
("tenancy.view_tenant", "Tenants", Tenant.objects.restrict(request.user, 'view').count), 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 = (
("dcim.view_rack", "Racks", Rack.objects.restrict(request.user, 'view').count), Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)),
("dcim.view_devicetype", "Device Types", DeviceType.objects.restrict(request.user, 'view').count), Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)),
("dcim.view_device", "Devices", Device.objects.restrict(request.user, 'view').count), Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)),
) )
ipam = ( ipam = (
("ipam.view_vrf", "VRFs", VRF.objects.restrict(request.user, 'view').count), Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)),
("ipam.view_aggregate", "Aggregates", Aggregate.objects.restrict(request.user, 'view').count), Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)),
("ipam.view_prefix", "Prefixes", Prefix.objects.restrict(request.user, 'view').count), Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)),
("ipam.view_iprange", "IP Ranges", IPRange.objects.restrict(request.user, 'view').count), Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)),
("ipam.view_ipaddress", "IP Addresses", IPAddress.objects.restrict(request.user, 'view').count), Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)),
("ipam.view_vlan", "VLANs", VLAN.objects.restrict(request.user, 'view').count) Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)),
) )
circuits = ( circuits = (
("circuits.view_provider", "Providers", Provider.objects.restrict(request.user, 'view').count), Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)),
("circuits.view_circuit", "Circuits", Circuit.objects.restrict(request.user, 'view').count), Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit))
) )
virtualization = ( virtualization = (
("virtualization.view_cluster", "Clusters", Cluster.objects.restrict(request.user, 'view').count), Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster',
("virtualization.view_virtualmachine", "Virtual Machines", VirtualMachine.objects.restrict(request.user, 'view').count), get_count_queryset(Cluster)),
Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine',
get_count_queryset(VirtualMachine)),
) )
connections = ( connections = (
("dcim.view_cable", "Cables", Cable.objects.restrict(request.user, 'view').count), Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)),
("dcim.view_consoleport", "Console", connected_consoleports.count), Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections),
("dcim.view_interface", "Interfaces", connected_interfaces.count), Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections),
("dcim.view_powerport", "Power Connections", connected_powerports.count), Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections),
) )
power = ( power = (
("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count), Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)),
("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count), Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)),
) )
wireless = ( wireless = (
("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count), Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan',
("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count), get_count_queryset(WirelessLAN)),
Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink',
get_count_queryset(WirelessLink)),
) )
sections = ( stats = (
("Organization", org, "domain"), (_('Organization'), org, 'domain'),
("IPAM", ipam, "counter"), (_('IPAM'), ipam, 'counter'),
("Virtualization", virtualization, "monitor"), (_('Virtualization'), virtualization, 'monitor'),
("Inventory", dcim, "server"), (_('Inventory'), dcim, 'server'),
("Circuits", circuits, "transit-connection-variant"), (_('Circuits'), circuits, 'transit-connection-variant'),
("Connections", connections, "cable-data"), (_('Connections'), connections, 'cable-data'),
("Power", power, "flash"), (_('Power'), power, 'flash'),
("Wireless", wireless, "wifi"), (_('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 return stats
# Compile changelog table # Compile changelog table

View File

@ -1,18 +1,40 @@
from django.core.exceptions import ImproperlyConfigured
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.views.generic import View from django.views.generic import View
from utilities.views import ObjectPermissionRequiredMixin 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: Attributes:
queryset: Django QuerySet from which the object(s) will be fetched queryset: Django QuerySet from which the object(s) will be fetched
template_name: The name of the HTML template file to render template_name: The name of the HTML template file to render
""" """
queryset = None
template_name = None template_name = None
def get_object(self, **kwargs): def get_object(self, **kwargs):
@ -35,16 +57,15 @@ class BaseObjectView(ObjectPermissionRequiredMixin, View):
return {} 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: Attributes:
queryset: Django QuerySet from which the object(s) will be fetched queryset: Django QuerySet from which the object(s) will be fetched
table: The django-tables2 Table class used to render the objects list table: The django-tables2 Table class used to render the objects list
template_name: The name of the HTML template file to render template_name: The name of the HTML template file to render
""" """
queryset = None
table = None table = None
template_name = None template_name = None

View File

@ -142,7 +142,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Render an ExportTemplate # Render an ExportTemplate
elif request.GET['export']: 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) return self.export_template(template, request)
# Check for YAML export support on the model # Check for YAML export support on the model
@ -335,7 +335,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
ids = [int(record["id"]) for record in records] ids = [int(record["id"]) for record in records]
qs = self.queryset.model.objects.filter(id__in=ids) qs = self.queryset.model.objects.filter(id__in=ids)
print(qs)
objs = {} objs = {}
for obj in qs: for obj in qs:
objs[obj.id] = obj objs[obj.id] = obj

View File

@ -179,7 +179,7 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView):
obj = model_form.save() obj = model_form.save()
# Enforce object-level permissions # Enforce object-level permissions
if not self.queryset.filter(pk=obj.pk).first(): if not self.queryset.filter(pk=obj.pk).exists():
raise PermissionsViolation() raise PermissionsViolation()
# Iterate through the related object forms (if any), validating and saving each instance. # Iterate through the related object forms (if any), validating and saving each instance.
@ -396,7 +396,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
obj = form.save() obj = form.save()
# Check that the new object conforms with any assigned object-level permissions # 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() raise PermissionsViolation()
msg = '{} {}'.format( msg = '{} {}'.format(

View File

@ -178,7 +178,7 @@
{% if object.primary_ip4.nat_inside %} {% if object.primary_ip4.nat_inside %}
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>) (NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
{% elif object.primary_ip4.nat_outside.exists %} {% elif object.primary_ip4.nat_outside.exists %}
(NAT for {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}) (NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %} {% endif %}
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}
@ -193,7 +193,7 @@
{% if object.primary_ip6.nat_inside %} {% if object.primary_ip6.nat_inside %}
(NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>) (NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
{% elif object.primary_ip6.nat_outside.exists %} {% elif object.primary_ip6.nat_outside.exists %}
(NAT for {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}) (NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %} {% endif %}
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}

View File

@ -6,19 +6,13 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-5"> <div class="col col-md-5">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Custom Link</h5>
Custom Link
</h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">Name</th> <th scope="row">Name</th>
<td>{{ object.name }}</td> <td>{{ object.name }}</td>
</tr> </tr>
<tr>
<th scope="row">Content Type</th>
<td>{{ object.content_type }}</td>
</tr>
<tr> <tr>
<th scope="row">Enabled</th> <th scope="row">Enabled</th>
<td>{% checkmark object.enabled %}</td> <td>{% checkmark object.enabled %}</td>
@ -42,6 +36,18 @@
</table> </table>
</div> </div>
</div> </div>
<div class="card">
<h5 class="card-header">Assigned Models</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for ct in object.content_types.all %}
<tr>
<td>{{ ct }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-7"> <div class="col col-md-7">

View File

@ -18,10 +18,6 @@
</h5> </h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr>
<th scope="row">Content Type</th>
<td>{{ object.content_type }}</td>
</tr>
<tr> <tr>
<th scope="row">Name</th> <th scope="row">Name</th>
<td>{{ object.name }}</td> <td>{{ object.name }}</td>
@ -45,6 +41,18 @@
</table> </table>
</div> </div>
</div> </div>
<div class="card">
<h5 class="card-header">Assigned Models</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for ct in object.content_types.all %}
<tr>
<td>{{ ct }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-7"> <div class="col col-md-7">

View File

@ -39,6 +39,7 @@
</table> </table>
</div> </div>
</div> </div>
{% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
@ -64,6 +65,7 @@
</table> </table>
</div> </div>
</div> </div>
{% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">

View File

@ -36,8 +36,8 @@
<div class="card-body"> <div class="card-body">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
{% for item in items %} {% for item in items %}
{% if not item.disabled %} {% if item.permission in perms %}
<a href="{% url item.url %}" class="list-group-item list-group-item-action"> <a href="{% url item.viewname %}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between align-items-center"> <div class="d-flex w-100 justify-content-between align-items-center">
{{ item.label }} {{ item.label }}
<h4 class="mb-1">{{ item.count }}</h4> <h4 class="mb-1">{{ item.count }}</h4>

View File

@ -19,6 +19,10 @@
<th scope="row">Type</th> <th scope="row">Type</th>
<td>{{ object.type|linkify }}</td> <td>{{ object.type|linkify }}</td>
</tr> </tr>
<tr>
<th scope="row">Status</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr> <tr>
<th scope="row">Group</th> <th scope="row">Group</th>
<td>{{ object.group|linkify|placeholder }}</td> <td>{{ object.group|linkify|placeholder }}</td>

View File

@ -46,7 +46,7 @@
{% if object.primary_ip4.nat_inside %} {% if object.primary_ip4.nat_inside %}
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>) (NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
{% elif object.primary_ip4.nat_outside.exists %} {% elif object.primary_ip4.nat_outside.exists %}
(NAT for {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}) (NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %} {% endif %}
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}
@ -61,7 +61,7 @@
{% if object.primary_ip6.nat_inside %} {% if object.primary_ip6.nat_inside %}
(NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>) (NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
{% elif object.primary_ip6.nat_outside.exists %} {% elif object.primary_ip6.nat_outside.exists %}
(NAT for {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}) (NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %} {% endif %}
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}

View File

@ -1,5 +1,5 @@
from .forms import * from .forms import *
from .models import * from .model_forms import *
from .filtersets import * from .filtersets import *
from .bulk_edit import * from .bulk_edit import *
from .bulk_import import * from .bulk_import import *

View File

@ -83,7 +83,7 @@ def export_button(context, model):
data_format = 'YAML' if hasattr(content_type.model_class(), 'to_yaml') else 'CSV' data_format = 'YAML' if hasattr(content_type.model_class(), 'to_yaml') else 'CSV'
# Retrieve all export templates for this model # 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 { return {
'perms': context['perms'], 'perms': context['perms'],

View File

@ -1,4 +1,4 @@
from .models import * from .model_forms import *
from .filtersets import * from .filtersets import *
from .object_create import * from .object_create import *
from .bulk_create import * from .bulk_create import *

View File

@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from dcim.forms.common import InterfaceCommonForm 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 dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
from ipam.models import IPAddress, VLAN, VLANGroup, VRF from ipam.models import IPAddress, VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm

View File

@ -1,5 +1,5 @@
from utilities.forms import ExpandableNameField from utilities.forms import ExpandableNameField
from .models import VMInterfaceForm from .model_forms import VMInterfaceForm
__all__ = ( __all__ = (
'VMInterfaceCreateForm', 'VMInterfaceCreateForm',

View File

@ -64,6 +64,7 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
group = tables.Column( group = tables.Column(
linkify=True linkify=True
) )
status = columns.ChoiceFieldColumn()
site = tables.Column( site = tables.Column(
linkify=True linkify=True
) )

View File

@ -1,4 +1,4 @@
from .models import * from .model_forms import *
from .filtersets import * from .filtersets import *
from .bulk_edit import * from .bulk_edit import *
from .bulk_import import * from .bulk_import import *

View File

@ -19,18 +19,18 @@ graphene-django==3.0.0
gunicorn==20.1.0 gunicorn==20.1.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
mkdocs-material==8.5.6 mkdocs-material==8.5.7
mkdocstrings[python-legacy]==0.19.0 mkdocstrings[python-legacy]==0.19.0
netaddr==0.8.0 netaddr==0.8.0
Pillow==9.2.0 Pillow==9.2.0
psycopg2-binary==2.9.3 psycopg2-binary==2.9.5
PyYAML==6.0 PyYAML==6.0
sentry-sdk==1.9.10 sentry-sdk==1.10.1
social-auth-app-django==5.0.0 social-auth-app-django==5.0.0
social-auth-core[openidconnect]==4.3.0 social-auth-core[openidconnect]==4.3.0
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.2.1 tablib==3.2.1
tzdata==2022.4 tzdata==2022.5
# Workaround for #7401 # Workaround for #7401
jsonschema==3.2.0 jsonschema==3.2.0