diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index df5ac6e81..a9af9c653 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.2.3
+ placeholder: v3.2.4
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 422b87f52..1fff99f1d 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.2.3
+ placeholder: v3.2.4
validations:
required: true
- type: dropdown
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index d8099923f..7390ec1df 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -8,7 +8,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v4
+ - uses: actions/stale@v5
with:
close-issue-message: >
This issue has been automatically closed due to lack of activity. In an
diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md
index 76fd0a12c..670cf524b 100644
--- a/docs/configuration/optional-settings.md
+++ b/docs/configuration/optional-settings.md
@@ -66,6 +66,14 @@ CORS_ORIGIN_WHITELIST = [
---
+## CSRF_COOKIE_NAME
+
+Default: `csrftoken`
+
+The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail.
+
+---
+
## CSRF_TRUSTED_ORIGINS
Default: `[]`
diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md
index 77e258def..6dccb4ee2 100644
--- a/docs/plugins/development/tables.md
+++ b/docs/plugins/development/tables.md
@@ -85,4 +85,5 @@ The table column classes listed below are supported for use in plugins. These cl
::: netbox.tables.TemplateColumn
selection:
- members: false
+ members:
+ - __init__
diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md
index 408d572c7..ea5e580b8 100644
--- a/docs/release-notes/version-3.2.md
+++ b/docs/release-notes/version-3.2.md
@@ -1,6 +1,33 @@
# NetBox v3.2
-## v3.2.4 (FUTURE)
+## v3.2.5 (FUTURE)
+
+---
+
+## v3.2.4 (2022-05-31)
+
+### Enhancements
+
+* [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated
+* [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view
+* [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports
+* [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment
+* [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter
+* [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search
+* [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device
+* [#9451](https://github.com/netbox-community/netbox/issues/9451) - Add `export_raw` argument for TemplateColumn
+
+### Bug Fixes
+
+* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters
+* [#9291](https://github.com/netbox-community/netbox/issues/9291) - Improve data validation for MultiObjectVar script fields
+* [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view
+* [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed
+* [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis
+* [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list
+* [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance
+* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields
+* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields
---
diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py
index ca3b003b9..46d3824bb 100644
--- a/netbox/circuits/forms/filtersets.py
+++ b/netbox/circuits/forms/filtersets.py
@@ -23,7 +23,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
(None, ('q', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('ASN', ('asn',)),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -87,7 +87,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
('Attributes', ('type_id', 'status', 'commit_rate')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
)
type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index a89960457..2e96f9c67 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -354,6 +354,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
# Other
TYPE_HARDWIRED = 'hardwired'
+ TYPE_OTHER = 'other'
CHOICES = (
('IEC 60320', (
@@ -471,6 +472,7 @@ class PowerPortTypeChoices(ChoiceSet):
)),
('Other', (
(TYPE_HARDWIRED, 'Hardwired'),
+ (TYPE_OTHER, 'Other'),
)),
)
@@ -580,6 +582,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
# Other
TYPE_HARDWIRED = 'hardwired'
+ TYPE_OTHER = 'other'
CHOICES = (
('IEC 60320', (
@@ -690,6 +693,7 @@ class PowerOutletTypeChoices(ChoiceSet):
)),
('Other', (
(TYPE_HARDWIRED, 'Hardwired'),
+ (TYPE_OTHER, 'Other'),
)),
)
@@ -1047,6 +1051,7 @@ class PortTypeChoices(ChoiceSet):
TYPE_URM_P2 = 'urm-p2'
TYPE_URM_P4 = 'urm-p4'
TYPE_URM_P8 = 'urm-p8'
+ TYPE_OTHER = 'other'
CHOICES = (
(
@@ -1099,6 +1104,12 @@ class PortTypeChoices(ChoiceSet):
(TYPE_URM_P4, 'URM-P4'),
(TYPE_URM_P8, 'URM-P8'),
(TYPE_SPLICE, 'Splice'),
+ ),
+ ),
+ (
+ 'Other',
+ (
+ (TYPE_OTHER, 'Other'),
)
)
)
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 0c7d02f9d..1535e5718 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -108,7 +108,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Region
fieldsets = (
(None, ('q', 'tag', 'parent_id')),
- ('Contacts', ('contact', 'contact_role'))
+ ('Contacts', ('contact', 'contact_role', 'contact_group'))
)
parent_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -122,7 +122,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = SiteGroup
fieldsets = (
(None, ('q', 'tag', 'parent_id')),
- ('Contacts', ('contact', 'contact_role'))
+ ('Contacts', ('contact', 'contact_role', 'contact_group'))
)
parent_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
@@ -138,7 +138,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
(None, ('q', 'tag')),
('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
)
status = MultipleChoiceField(
choices=SiteStatusChoices,
@@ -168,7 +168,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
(None, ('q', 'tag')),
('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -214,7 +214,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
('Function', ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -329,7 +329,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Manufacturer
fieldsets = (
(None, ('q', 'tag')),
- ('Contacts', ('contact', 'contact_role'))
+ ('Contacts', ('contact', 'contact_role', 'contact_group'))
)
tag = TagFilterField(model)
@@ -518,7 +518,7 @@ class DeviceFilterForm(
('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
)),
@@ -788,7 +788,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
(None, ('q', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -1102,7 +1102,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem
fieldsets = (
(None, ('q', 'tag')),
- ('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
+ ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
)
role_id = DynamicModelMultipleChoiceField(
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index e3e9c1179..8c9ddab19 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/netbox/dcim/forms/object_create.py
@@ -256,6 +256,8 @@ class VirtualChassisCreateForm(NetBoxModelForm):
]
def clean(self):
+ super().clean()
+
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
raise forms.ValidationError({
'initial_position': "A position must be specified for the first VC member."
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 8d50db958..e88af2d05 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -748,8 +748,12 @@ class Device(NetBoxModel, ConfigContextModel):
return f'{self.name} ({self.asset_tag})'
elif self.name:
return self.name
+ elif self.virtual_chassis and self.asset_tag:
+ return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
elif self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
+ elif self.device_type and self.asset_tag:
+ return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
elif self.device_type:
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
return super().__str__()
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index 95de7a2fe..d9148a5c3 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/netbox/extras/forms/bulk_import.py
@@ -27,6 +27,12 @@ class CustomFieldCSVForm(CSVModelForm):
choices=CustomFieldTypeChoices,
help_text='Field data type (e.g. text, integer, etc.)'
)
+ object_type = CSVContentTypeField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields'),
+ required=False,
+ help_text="Object type (for object or multi-object fields)"
+ )
choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,
@@ -36,9 +42,9 @@ class CustomFieldCSVForm(CSVModelForm):
class Meta:
model = CustomField
fields = (
- 'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic',
- 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
- 'ui_visibility',
+ 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight',
+ 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
+ 'validation_regex', 'ui_visibility',
)
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index 4332d72f7..29fab5be8 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -306,9 +306,16 @@ class BaseScript:
@classmethod
def _get_vars(cls):
vars = {}
- for name, attr in cls.__dict__.items():
- if name not in vars and issubclass(attr.__class__, ScriptVariable):
- vars[name] = attr
+
+ # Iterate all base classes looking for ScriptVariables
+ for base_class in inspect.getmro(cls):
+ # When object is reached there's no reason to continue
+ if base_class is object:
+ break
+
+ for name, attr in base_class.__dict__.items():
+ if name not in vars and issubclass(attr.__class__, ScriptVariable):
+ vars[name] = attr
# Order variables according to field_order
field_order = getattr(cls.Meta, 'field_order', None)
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index 0a9d85e15..936213cbf 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -40,10 +40,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
- 'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
- 'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3},read-write',
- 'field5,Field 5,integer,dcim.site,100,exact,,1,100,,read-write',
- 'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,,read-write',
+ 'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
+ 'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3},read-write',
+ 'field5,Field 5,integer,dcim.site,,100,exact,,1,100,,read-write',
+ 'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,,read-write',
+ 'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,,read-write',
)
cls.bulk_edit_data = {
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index 7839dc03e..a445022ca 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -145,9 +145,11 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
+ qs_filter |= Q(prefix__contains=value.strip())
try:
prefix = str(netaddr.IPNetwork(value.strip()).cidr)
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
+ qs_filter |= Q(prefix__contains=value.strip())
except (AddrFormatError, ValueError):
pass
return queryset.filter(qs_filter)
@@ -334,9 +336,11 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
+ qs_filter |= Q(prefix__contains=value.strip())
try:
prefix = str(netaddr.IPNetwork(value.strip()).cidr)
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
+ qs_filter |= Q(prefix__contains=value.strip())
except (AddrFormatError, ValueError):
pass
return queryset.filter(qs_filter)
diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py
index 475ad787e..558631585 100644
--- a/netbox/ipam/tables/ip.py
+++ b/netbox/ipam/tables/ip.py
@@ -226,8 +226,9 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
class PrefixTable(NetBoxTable):
- prefix = tables.TemplateColumn(
+ prefix = columns.TemplateColumn(
template_code=PREFIX_LINK,
+ export_raw=True,
attrs={'td': {'class': 'text-nowrap'}}
)
prefix_flat = tables.TemplateColumn(
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 078848b3e..d89d6a711 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -4,7 +4,7 @@ from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
-from circuits.models import Provider
+from circuits.models import Provider, Circuit
from circuits.tables import ProviderTable
from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site
@@ -225,7 +225,9 @@ class ASNView(generic.ObjectView):
sites_table.configure(request)
# Gather assigned Providers
- providers = instance.providers.restrict(request.user, 'view')
+ providers = instance.providers.restrict(request.user, 'view').annotate(
+ count_circuits=count_related(Circuit, 'provider')
+ )
providers_table = ProviderTable(providers, user=request.user)
providers_table.configure(request)
@@ -674,11 +676,14 @@ class IPAddressView(generic.ObjectView):
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
related_ips_table.configure(request)
+ services = Service.objects.restrict(request.user, 'view').filter(ipaddresses=instance)
+
return {
'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table,
'more_duplicate_ips': duplicate_ips.count() > 10,
'related_ips_table': related_ips_table,
+ 'services': services,
}
diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py
index c82749e3f..ad0dcc7c3 100644
--- a/netbox/netbox/configuration_example.py
+++ b/netbox/netbox/configuration_example.py
@@ -202,6 +202,9 @@ RQ_DEFAULT_TIMEOUT = 300
# this setting is derived from the installed location.
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
+# The name to use for the csrf token cookie.
+CSRF_COOKIE_NAME = 'csrftoken'
+
# The name to use for the session cookie.
SESSION_COOKIE_NAME = 'sessionid'
diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py
index e054dc9da..8ca0d98c1 100644
--- a/netbox/netbox/constants.py
+++ b/netbox/netbox/constants.py
@@ -1,32 +1,24 @@
from collections import OrderedDict
from typing import Dict
-from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet
+import circuits.filtersets
+import circuits.tables
+import dcim.filtersets
+import dcim.tables
+import ipam.filtersets
+import ipam.tables
+import tenancy.filtersets
+import tenancy.tables
+import virtualization.filtersets
+import virtualization.tables
from circuits.models import Circuit, ProviderNetwork, Provider
-from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
-from dcim.filtersets import (
- CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, LocationFilterSet, ModuleFilterSet, ModuleTypeFilterSet,
- PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet, SiteFilterSet, VirtualChassisFilterSet,
-)
from dcim.models import (
Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis,
)
-from dcim.tables import (
- CableTable, DeviceTable, DeviceTypeTable, LocationTable, ModuleTable, ModuleTypeTable, PowerFeedTable, RackTable,
- RackReservationTable, SiteTable, VirtualChassisTable,
-)
-from ipam.filtersets import (
- AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet,
-)
-from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
-from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
-from tenancy.filtersets import ContactFilterSet, TenantFilterSet
+from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
from tenancy.models import Contact, Tenant, ContactAssignment
-from tenancy.tables import ContactTable, TenantTable
from utilities.utils import count_related
-from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
from virtualization.models import Cluster, VirtualMachine
-from virtualization.tables import ClusterTable, VirtualMachineTable
SEARCH_MAX_RESULTS = 15
@@ -36,22 +28,22 @@ CIRCUIT_TYPES = OrderedDict(
'queryset': Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
),
- 'filterset': ProviderFilterSet,
- 'table': ProviderTable,
+ 'filterset': circuits.filtersets.ProviderFilterSet,
+ 'table': circuits.tables.ProviderTable,
'url': 'circuits:provider_list',
}),
('circuit', {
'queryset': Circuit.objects.prefetch_related(
'type', 'provider', 'tenant', 'terminations__site'
),
- 'filterset': CircuitFilterSet,
- 'table': CircuitTable,
+ 'filterset': circuits.filtersets.CircuitFilterSet,
+ 'table': circuits.tables.CircuitTable,
'url': 'circuits:circuit_list',
}),
('providernetwork', {
'queryset': ProviderNetwork.objects.prefetch_related('provider'),
- 'filterset': ProviderNetworkFilterSet,
- 'table': ProviderNetworkTable,
+ 'filterset': circuits.filtersets.ProviderNetworkFilterSet,
+ 'table': circuits.tables.ProviderNetworkTable,
'url': 'circuits:providernetwork_list',
}),
)
@@ -62,22 +54,22 @@ DCIM_TYPES = OrderedDict(
(
('site', {
'queryset': Site.objects.prefetch_related('region', 'tenant'),
- 'filterset': SiteFilterSet,
- 'table': SiteTable,
+ 'filterset': dcim.filtersets.SiteFilterSet,
+ 'table': dcim.tables.SiteTable,
'url': 'dcim:site_list',
}),
('rack', {
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate(
device_count=count_related(Device, 'rack')
),
- 'filterset': RackFilterSet,
- 'table': RackTable,
+ 'filterset': dcim.filtersets.RackFilterSet,
+ 'table': dcim.tables.RackTable,
'url': 'dcim:rack_list',
}),
('rackreservation', {
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
- 'filterset': RackReservationFilterSet,
- 'table': RackReservationTable,
+ 'filterset': dcim.filtersets.RackReservationFilterSet,
+ 'table': dcim.tables.RackReservationTable,
'url': 'dcim:rackreservation_list',
}),
('location', {
@@ -94,60 +86,60 @@ DCIM_TYPES = OrderedDict(
'rack_count',
cumulative=True
).prefetch_related('site'),
- 'filterset': LocationFilterSet,
- 'table': LocationTable,
+ 'filterset': dcim.filtersets.LocationFilterSet,
+ 'table': dcim.tables.LocationTable,
'url': 'dcim:location_list',
}),
('devicetype', {
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=count_related(Device, 'device_type')
),
- 'filterset': DeviceTypeFilterSet,
- 'table': DeviceTypeTable,
+ 'filterset': dcim.filtersets.DeviceTypeFilterSet,
+ 'table': dcim.tables.DeviceTypeTable,
'url': 'dcim:devicetype_list',
}),
('device', {
'queryset': Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
),
- 'filterset': DeviceFilterSet,
- 'table': DeviceTable,
+ 'filterset': dcim.filtersets.DeviceFilterSet,
+ 'table': dcim.tables.DeviceTable,
'url': 'dcim:device_list',
}),
('moduletype', {
'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
instance_count=count_related(Module, 'module_type')
),
- 'filterset': ModuleTypeFilterSet,
- 'table': ModuleTypeTable,
+ 'filterset': dcim.filtersets.ModuleTypeFilterSet,
+ 'table': dcim.tables.ModuleTypeTable,
'url': 'dcim:moduletype_list',
}),
('module', {
'queryset': Module.objects.prefetch_related(
'module_type__manufacturer', 'device', 'module_bay',
),
- 'filterset': ModuleFilterSet,
- 'table': ModuleTable,
+ 'filterset': dcim.filtersets.ModuleFilterSet,
+ 'table': dcim.tables.ModuleTable,
'url': 'dcim:module_list',
}),
('virtualchassis', {
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
member_count=count_related(Device, 'virtual_chassis')
),
- 'filterset': VirtualChassisFilterSet,
- 'table': VirtualChassisTable,
+ 'filterset': dcim.filtersets.VirtualChassisFilterSet,
+ 'table': dcim.tables.VirtualChassisTable,
'url': 'dcim:virtualchassis_list',
}),
('cable', {
'queryset': Cable.objects.all(),
- 'filterset': CableFilterSet,
- 'table': CableTable,
+ 'filterset': dcim.filtersets.CableFilterSet,
+ 'table': dcim.tables.CableTable,
'url': 'dcim:cable_list',
}),
('powerfeed', {
'queryset': PowerFeed.objects.all(),
- 'filterset': PowerFeedFilterSet,
- 'table': PowerFeedTable,
+ 'filterset': dcim.filtersets.PowerFeedFilterSet,
+ 'table': dcim.tables.PowerFeedTable,
'url': 'dcim:powerfeed_list',
}),
)
@@ -157,40 +149,46 @@ IPAM_TYPES = OrderedDict(
(
('vrf', {
'queryset': VRF.objects.prefetch_related('tenant'),
- 'filterset': VRFFilterSet,
- 'table': VRFTable,
+ 'filterset': ipam.filtersets.VRFFilterSet,
+ 'table': ipam.tables.VRFTable,
'url': 'ipam:vrf_list',
}),
('aggregate', {
'queryset': Aggregate.objects.prefetch_related('rir'),
- 'filterset': AggregateFilterSet,
- 'table': AggregateTable,
+ 'filterset': ipam.filtersets.AggregateFilterSet,
+ 'table': ipam.tables.AggregateTable,
'url': 'ipam:aggregate_list',
}),
('prefix', {
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
- 'filterset': PrefixFilterSet,
- 'table': PrefixTable,
+ 'filterset': ipam.filtersets.PrefixFilterSet,
+ 'table': ipam.tables.PrefixTable,
'url': 'ipam:prefix_list',
}),
('ipaddress', {
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
- 'filterset': IPAddressFilterSet,
- 'table': IPAddressTable,
+ 'filterset': ipam.filtersets.IPAddressFilterSet,
+ 'table': ipam.tables.IPAddressTable,
'url': 'ipam:ipaddress_list',
}),
('vlan', {
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
- 'filterset': VLANFilterSet,
- 'table': VLANTable,
+ 'filterset': ipam.filtersets.VLANFilterSet,
+ 'table': ipam.tables.VLANTable,
'url': 'ipam:vlan_list',
}),
('asn', {
'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
- 'filterset': ASNFilterSet,
- 'table': ASNTable,
+ 'filterset': ipam.filtersets.ASNFilterSet,
+ 'table': ipam.tables.ASNTable,
'url': 'ipam:asn_list',
}),
+ ('service', {
+ 'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
+ 'filterset': ipam.filtersets.ServiceFilterSet,
+ 'table': ipam.tables.ServiceTable,
+ 'url': 'ipam:service_list',
+ }),
)
)
@@ -198,15 +196,15 @@ TENANCY_TYPES = OrderedDict(
(
('tenant', {
'queryset': Tenant.objects.prefetch_related('group'),
- 'filterset': TenantFilterSet,
- 'table': TenantTable,
+ 'filterset': tenancy.filtersets.TenantFilterSet,
+ 'table': tenancy.tables.TenantTable,
'url': 'tenancy:tenant_list',
}),
('contact', {
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
assignment_count=count_related(ContactAssignment, 'contact')),
- 'filterset': ContactFilterSet,
- 'table': ContactTable,
+ 'filterset': tenancy.filtersets.ContactFilterSet,
+ 'table': tenancy.tables.ContactTable,
'url': 'tenancy:contact_list',
}),
)
@@ -219,16 +217,16 @@ VIRTUALIZATION_TYPES = OrderedDict(
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
),
- 'filterset': ClusterFilterSet,
- 'table': ClusterTable,
+ 'filterset': virtualization.filtersets.ClusterFilterSet,
+ 'table': virtualization.tables.ClusterTable,
'url': 'virtualization:cluster_list',
}),
('virtualmachine', {
'queryset': VirtualMachine.objects.prefetch_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
),
- 'filterset': VirtualMachineFilterSet,
- 'table': VirtualMachineTable,
+ 'filterset': virtualization.filtersets.VirtualMachineFilterSet,
+ 'table': virtualization.tables.VirtualMachineTable,
'url': 'virtualization:virtualmachine_list',
}),
)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index ff10d1096..fd3730e2c 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -84,6 +84,7 @@ if BASE_PATH:
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_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')
diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py
index 801b97766..e82e8a1ea 100644
--- a/netbox/netbox/tables/columns.py
+++ b/netbox/netbox/tables/columns.py
@@ -90,6 +90,15 @@ class TemplateColumn(tables.TemplateColumn):
"""
PLACEHOLDER = mark_safe('—')
+ def __init__(self, export_raw=False, **kwargs):
+ """
+ Args:
+ export_raw: If true, data export returns the raw field value rather than the rendered template. (Default:
+ False)
+ """
+ super().__init__(**kwargs)
+ self.export_raw = export_raw
+
def render(self, *args, **kwargs):
ret = super().render(*args, **kwargs)
if not ret.strip():
@@ -97,6 +106,10 @@ class TemplateColumn(tables.TemplateColumn):
return ret
def value(self, **kwargs):
+ if self.export_raw:
+ # Skip template rendering and export raw value
+ return kwargs.get('value')
+
ret = super().value(**kwargs)
if ret == self.PLACEHOLDER:
return ''
@@ -192,32 +205,35 @@ class ActionsColumn(tables.Column):
model = table.Meta.model
request = getattr(table, 'context', {}).get('request')
url_appendix = f'?return_url={request.path}' if request else ''
+ html = ''
+ # Compile actions menu
links = []
user = getattr(request, 'user', AnonymousUser())
for action, attrs in self.actions.items():
permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
if attrs.permission is None or user.has_perm(permission):
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
- links.append(f'
'
- f' {attrs.title}')
-
- if not links:
- return ''
-
- menu = f'' \
- f'' \
- f'' \
- f''
+ links.append(
+ f''
+ f' {attrs.title}'
+ )
+ if links:
+ html += (
+ f''
+ f''
+ f''
+ f''
+ )
# Render any extra buttons from template code
if self.extra_buttons:
template = Template(self.extra_buttons)
context = getattr(table, "context", Context())
context.update({'record': record})
- menu = template.render(context) + menu
+ html = template.render(context) + html
- return mark_safe(menu)
+ return mark_safe(html)
class ChoiceFieldColumn(tables.Column):
diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html
index 4683b775b..1ff9f2e9a 100644
--- a/netbox/templates/dcim/virtualchassis.html
+++ b/netbox/templates/dcim/virtualchassis.html
@@ -15,74 +15,70 @@
{% block content %}
-
-
-
-
-
- Domain |
- {{ object.domain|placeholder }} |
-
-
- Master |
- {{ object.master|linkify }} |
-
-
-
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
+
+
+
+
+
+ Domain |
+ {{ object.domain|placeholder }} |
+
+
+ Master |
+ {{ object.master|linkify }} |
+
+
+
+
+ {% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_left_page object %}
-
-
-
-
-
- Device |
- Position |
- Master |
- Priority |
-
- {% for vc_member in members %}
-
-
- {{ vc_member|linkify }}
- |
-
- {% badge vc_member.vc_position show_empty=True %}
- |
-
- {% if object.master == vc_member %}
- {% checkmark True %}
- {% endif %}
- |
-
- {{ vc_member.vc_priority|placeholder }}
- |
-
- {% endfor %}
-
-
- {% if perms.dcim.change_virtualchassis %}
-
- {% endif %}
+
+
+
+
+
+ Device |
+ Position |
+ Master |
+ Priority |
+
+ {% for vc_member in members %}
+
+
+ {{ vc_member|linkify }}
+ |
+
+ {% badge vc_member.vc_position show_empty=True %}
+ |
+
+ {% if object.master == vc_member %}
+ {% checkmark True %}
+ {% endif %}
+ |
+
+ {{ vc_member.vc_priority|placeholder }}
+ |
+
+ {% endfor %}
+
- {% plugin_right_page object %}
+ {% if perms.dcim.change_virtualchassis %}
+
+ {% endif %}
+
+ {% plugin_right_page object %}
-
- {% plugin_full_width_page object %}
-
+
+ {% plugin_full_width_page object %}
+
{% endblock %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html
index 96a76cf8c..7981ea0fe 100644
--- a/netbox/templates/ipam/ipaddress.html
+++ b/netbox/templates/ipam/ipaddress.html
@@ -134,6 +134,24 @@
{% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
+
+
+
+ {% if services %}
+
+ {% for service in services %}
+ {% include 'ipam/inc/service.html' %}
+ {% endfor %}
+
+ {% else %}
+
+ None
+
+ {% endif %}
+
+
{% plugin_right_page object %}
diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py
index 8ca4ae29c..dd14a412b 100644
--- a/netbox/tenancy/filtersets.py
+++ b/netbox/tenancy/filtersets.py
@@ -112,6 +112,12 @@ class ContactModelFilterSet(django_filters.FilterSet):
queryset=ContactRole.objects.all(),
label='Contact Role'
)
+ contact_group = TreeNodeMultipleChoiceFilter(
+ queryset=ContactGroup.objects.all(),
+ field_name='contacts__contact__group',
+ lookup_expr='in',
+ label='Contact group',
+ )
#
diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py
index 15d7773b7..02589d733 100644
--- a/netbox/tenancy/forms/filtersets.py
+++ b/netbox/tenancy/forms/filtersets.py
@@ -32,7 +32,7 @@ class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Tenant
fieldsets = (
(None, ('q', 'tag', 'group_id')),
- ('Contacts', ('contact', 'contact_role'))
+ ('Contacts', ('contact', 'contact_role', 'contact_group'))
)
group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py
index 5dcad1d43..5e78bc540 100644
--- a/netbox/tenancy/forms/forms.py
+++ b/netbox/tenancy/forms/forms.py
@@ -58,3 +58,8 @@ class ContactModelFilterForm(forms.Form):
required=False,
label=_('Contact Role')
)
+ contact_group = DynamicModelMultipleChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False,
+ label=_('Contact Group')
+ )
diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py
index f83fc6a7c..68e71610c 100644
--- a/netbox/utilities/forms/fields/dynamic.py
+++ b/netbox/utilities/forms/fields/dynamic.py
@@ -88,7 +88,12 @@ class DynamicModelChoiceMixin:
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
# will be populated on-demand via the APISelect widget.
data = bound_field.value()
+
if data:
+ # When the field is multiple choice pass the data as a list if it's not already
+ if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and not type(data) is list:
+ data = [data]
+
field_name = getattr(self, 'to_field_name') or 'pk'
filter = self.filter(field_name=field_name)
try:
@@ -130,11 +135,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
widget = widgets.APISelectMultiple
def clean(self, value):
- """
- When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
- string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
- """
+ value = value or []
+
+ # When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
+ # string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
return [None, *value]
+
return super().clean(value)
diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py
index b3da87f7a..e15a76a43 100644
--- a/netbox/virtualization/forms/filtersets.py
+++ b/netbox/virtualization/forms/filtersets.py
@@ -29,6 +29,10 @@ class ClusterTypeFilterForm(NetBoxModelFilterSetForm):
class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = ClusterGroup
tag = TagFilterField(model)
+ fieldsets = (
+ (None, ('q', 'tag')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
+ )
class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
@@ -38,7 +42,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
('Attributes', ('group_id', 'type_id', 'status')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
)
type_id = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(),
@@ -91,7 +95,7 @@ class VirtualMachineFilterForm(
('Location', ('region_id', 'site_group_id', 'site_id')),
('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
('Tenant', ('tenant_group_id', 'tenant_id')),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
)
cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
diff --git a/requirements.txt b/requirements.txt
index 0a15fcf20..293a33542 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -18,10 +18,10 @@ gunicorn==20.1.0
Jinja2==3.1.2
Markdown==3.3.7
markdown-include==0.6.0
-mkdocs-material==8.2.14
-mkdocstrings[python-legacy]==0.18.1
+mkdocs-material==8.2.16
+mkdocstrings[python-legacy]==0.19.0
netaddr==0.8.0
-Pillow==9.1.0
+Pillow==9.1.1
psycopg2-binary==2.9.3
PyYAML==6.0
sentry-sdk==1.5.12