diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 2d6ca5700..df5ac6e81 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.2
+ placeholder: v3.2.3
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 13b162741..422b87f52 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.2
+ placeholder: v3.2.3
validations:
required: true
- type: dropdown
diff --git a/README.md b/README.md
index d75c2c1a5..60f007946 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,8 @@ The complete documentation for NetBox can be found at [docs.netbox.dev](https://
[](https://ns1.com/)
+ [](https://sentry.io/)
+
[](https://stellar.tech/)
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 000000000..b389dd2b3
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,31 @@
+# Security Policy
+
+## No Warranty
+
+Per the terms of the Apache 2 license, NetBox is offered "as is" and without any guarantee or warranty pertaining to its operation. While every reasonable effort is made by its maintainers to ensure the product remains free of security vulnerabilities, users are ultimately responsible for conducting their own evaluations of each software release.
+
+## Recommendations
+
+Administrators are encouraged to adhere to industry best practices concerning the secure operation of software, such as:
+
+* Do not expose your NetBox installation to the public Internet
+* Do not permit multiple users to share an account
+* Enforce minimum password complexity requirements for local accounts
+* Prohibit access to your database from clients other than the NetBox application
+* Keep your deployment updated to the most recent stable release
+
+## Reporting a Suspected Vulnerability
+
+If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so via email. Please note that any reported vulnerabilities **MUST** meet all the following conditions:
+
+* Affects the most recent stable release of NetBox, or a current beta release
+* Affects a NetBox instance installed and configured per the official documentation
+* Is reproducible following a prescribed set of instructions
+
+Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
+
+If you believe that you've found a vulnerability which meets all of these conditions, please email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
+
+### Bug Bounties
+
+As NetBox is provided as free open source software, we do not offer any monetary compensation for vulnerability or bug reports, however your contributions are greatly appreciated.
diff --git a/base_requirements.txt b/base_requirements.txt
index 095906914..6bb537a6a 100644
--- a/base_requirements.txt
+++ b/base_requirements.txt
@@ -102,6 +102,10 @@ psycopg2-binary
# https://github.com/yaml/pyyaml
PyYAML
+# Sentry SDK
+# https://github.com/getsentry/sentry-python
+sentry-sdk
+
# Social authentication framework
# https://github.com/python-social-auth/social-core
social-auth-core
diff --git a/docs/administration/error-reporting.md b/docs/administration/error-reporting.md
new file mode 100644
index 000000000..e04372338
--- /dev/null
+++ b/docs/administration/error-reporting.md
@@ -0,0 +1,46 @@
+# Error Reporting
+
+## Sentry
+
+### Enabling Error Reporting
+
+NetBox v3.2.3 and later support native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis.
+
+```python
+SENTRY_ENABLED = True
+```
+
+### Using a Custom DSN
+
+If you prefer instead to use your own Sentry ingestor, you'll need to first create a new project under your Sentry account to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below:
+
+```
+https://examplePublicKey@o0.ingest.sentry.io/0
+```
+
+Once you have obtained a DSN, configure Sentry in NetBox's `configuration.py` file with the following parameters:
+
+```python
+SENTRY_ENABLED = True
+SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
+```
+
+### Assigning Tags
+
+You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter:
+
+```python
+SENTRY_TAGS = {
+ "custom.foo": "123",
+ "custom.bar": "abc",
+}
+```
+
+!!! warning "Reserved tag prefixes"
+ Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application.
+
+### Testing
+
+Once the configuration has been saved, restart the NetBox service.
+
+To test Sentry operation, try generating a 404 (page not found) error by navigating to an invalid URL, such as `https://netbox/404-error-testing`. (Be sure that debug mode has been disabled.) After receiving a 404 response from the NetBox server, you should see the issue appear shortly in Sentry.
diff --git a/docs/configuration/error-reporting.md b/docs/configuration/error-reporting.md
new file mode 100644
index 000000000..d1c47e2fb
--- /dev/null
+++ b/docs/configuration/error-reporting.md
@@ -0,0 +1,54 @@
+# Error Reporting Settings
+
+## SENTRY_DSN
+
+Default: None
+
+Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example:
+
+```
+SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
+```
+
+---
+
+## SENTRY_ENABLED
+
+Default: False
+
+Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
+
+---
+
+## SENTRY_SAMPLE_RATE
+
+Default: 1.0 (all)
+
+The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors).
+
+---
+
+## SENTRY_TAGS
+
+An optional dictionary of tag names and values to apply to Sentry error reports.For example:
+
+```
+SENTRY_TAGS = {
+ "custom.foo": "123",
+ "custom.bar": "abc",
+}
+```
+
+!!! warning "Reserved tag prefixes"
+ Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application.
+
+---
+
+## SENTRY_TRACES_SAMPLE_RATE
+
+Default: 0 (disabled)
+
+The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions).
+
+!!! warning "Consider performance implications"
+ A high sampling rate for transactions can induce significant performance penalties. If transaction reporting is desired, it is recommended to use a relatively low sample rate of 10% to 20% (0.1 to 0.2).
diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md
index 56f6b7357..0c56c92f7 100644
--- a/docs/release-notes/version-3.2.md
+++ b/docs/release-notes/version-3.2.md
@@ -1,5 +1,33 @@
# NetBox v3.2
+## v3.2.3 (2022-05-12)
+
+### Enhancements
+
+* [#8805](https://github.com/netbox-community/netbox/issues/8805) - Add "mixed" option for device airflow indication
+* [#8894](https://github.com/netbox-community/netbox/issues/8894) - Include full names when listing users
+* [#8998](https://github.com/netbox-community/netbox/issues/8998) - Enable filtering racks & reservations by site group
+* [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade
+* [#9221](https://github.com/netbox-community/netbox/issues/9221) - Add definition list support for Markdown
+* [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views
+* [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list
+* [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module
+* [#9314](https://github.com/netbox-community/netbox/issues/9314) - Add device and VM filters for FHRP group assignments
+* [#9340](https://github.com/netbox-community/netbox/issues/9340) - Introduce support for error reporting via Sentry
+* [#9343](https://github.com/netbox-community/netbox/issues/9343) - Add Ubiquiti SmartPower power outlet type
+
+### Bug Fixes
+
+* [#9190](https://github.com/netbox-community/netbox/issues/9190) - Prevent exception when attempting to instantiate module components which already exist on the parent device
+* [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices
+* [#9296](https://github.com/netbox-community/netbox/issues/9296) - Improve Markdown link sanitization
+* [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface
+* [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API
+* [#9313](https://github.com/netbox-community/netbox/issues/9313) - Remove HTML code from CSV output of many-to-many relationships
+* [#9330](https://github.com/netbox-community/netbox/issues/9330) - Add missing `module_type` field to REST API serializers for modular device component templates
+
+---
+
## v3.2.2 (2022-04-28)
### Enhancements
diff --git a/mkdocs.yml b/mkdocs.yml
index 225c6d4bf..5c973e0d6 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -73,6 +73,7 @@ nav:
- Required Settings: 'configuration/required-settings.md'
- Optional Settings: 'configuration/optional-settings.md'
- Dynamic Settings: 'configuration/dynamic-settings.md'
+ - Error Reporting: 'configuration/error-reporting.md'
- Remote Authentication: 'configuration/remote-authentication.md'
- Core Functionality:
- IP Address Management: 'core-functionality/ipam.md'
@@ -123,6 +124,7 @@ nav:
- Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.md'
- Okta: 'administration/authentication/okta.md'
- Permissions: 'administration/permissions.md'
+ - Error Reporting: 'administration/error-reporting.md'
- Housekeeping: 'administration/housekeeping.md'
- Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md'
diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py
index cb8c940b0..40f8918ae 100644
--- a/netbox/circuits/tables/circuits.py
+++ b/netbox/circuits/tables/circuits.py
@@ -59,7 +59,7 @@ class CircuitTable(NetBoxTable):
)
commit_rate = CommitRateColumn()
comments = columns.MarkdownColumn()
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py
index e97ade7d8..0ec6d439d 100644
--- a/netbox/circuits/tables/providers.py
+++ b/netbox/circuits/tables/providers.py
@@ -14,7 +14,7 @@ class ProviderTable(NetBoxTable):
name = tables.Column(
linkify=True
)
- asns = tables.ManyToManyColumn(
+ asns = columns.ManyToManyColumn(
linkify_item=True,
verbose_name='ASNs'
)
@@ -31,7 +31,7 @@ class ProviderTable(NetBoxTable):
verbose_name='Circuits'
)
comments = columns.MarkdownColumn()
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index c05aa31df..f3b1269f9 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -32,7 +32,7 @@ class ProviderView(generic.ObjectView):
).prefetch_related(
'type', 'tenant', 'terminations__site'
)
- circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
+ circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
circuits_table.configure(request)
return {
@@ -93,7 +93,7 @@ class ProviderNetworkView(generic.ObjectView):
).prefetch_related(
'type', 'tenant', 'terminations__site'
)
- circuits_table = tables.CircuitTable(circuits)
+ circuits_table = tables.CircuitTable(circuits, user=request.user)
circuits_table.configure(request)
return {
@@ -147,7 +147,7 @@ class CircuitTypeView(generic.ObjectView):
def get_extra_context(self, request, instance):
circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
- circuits_table = tables.CircuitTable(circuits, exclude=('type',))
+ circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('type',))
circuits_table.configure(request)
return {
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 813c946a3..7fcab6ba3 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -315,7 +315,16 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
class ConsolePortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
- device_type = NestedDeviceTypeSerializer()
+ device_type = NestedDeviceTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
+ module_type = NestedModuleTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
type = ChoiceField(
choices=ConsolePortTypeChoices,
allow_blank=True,
@@ -325,13 +334,23 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsolePortTemplate
fields = [
- 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated',
+ 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
+ 'last_updated',
]
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
- device_type = NestedDeviceTypeSerializer()
+ device_type = NestedDeviceTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
+ module_type = NestedModuleTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
type = ChoiceField(
choices=ConsolePortTypeChoices,
allow_blank=True,
@@ -341,13 +360,23 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsoleServerPortTemplate
fields = [
- 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated',
+ 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
+ 'last_updated',
]
class PowerPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
- device_type = NestedDeviceTypeSerializer()
+ device_type = NestedDeviceTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
+ module_type = NestedModuleTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
type = ChoiceField(
choices=PowerPortTypeChoices,
allow_blank=True,
@@ -357,14 +386,23 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPortTemplate
fields = [
- 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
- 'description', 'created', 'last_updated',
+ 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
+ 'allocated_draw', 'description', 'created', 'last_updated',
]
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
- device_type = NestedDeviceTypeSerializer()
+ device_type = NestedDeviceTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
+ module_type = NestedModuleTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
type = ChoiceField(
choices=PowerOutletTypeChoices,
allow_blank=True,
@@ -383,48 +421,75 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerOutletTemplate
fields = [
- 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
- 'created', 'last_updated',
+ 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
+ 'description', 'created', 'last_updated',
]
class InterfaceTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
- device_type = NestedDeviceTypeSerializer()
+ device_type = NestedDeviceTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
+ module_type = NestedModuleTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
type = ChoiceField(choices=InterfaceTypeChoices)
class Meta:
model = InterfaceTemplate
fields = [
- 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created',
- 'last_updated',
+ 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
+ 'created', 'last_updated',
]
class RearPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
- device_type = NestedDeviceTypeSerializer()
+ device_type = NestedDeviceTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
+ module_type = NestedModuleTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
type = ChoiceField(choices=PortTypeChoices)
class Meta:
model = RearPortTemplate
fields = [
- 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description',
- 'created', 'last_updated',
+ 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
+ 'description', 'created', 'last_updated',
]
class FrontPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
- device_type = NestedDeviceTypeSerializer()
+ device_type = NestedDeviceTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
+ module_type = NestedModuleTypeSerializer(
+ required=False,
+ allow_null=True,
+ default=None
+ )
type = ChoiceField(choices=PortTypeChoices)
rear_port = NestedRearPortTemplateSerializer()
class Meta:
model = FrontPortTemplate
fields = [
- 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
- 'description', 'created', 'last_updated',
+ 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
+ 'rear_port_position', 'description', 'created', 'last_updated',
]
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index e369201b4..a89960457 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -159,6 +159,7 @@ class DeviceAirflowChoices(ChoiceSet):
AIRFLOW_RIGHT_TO_LEFT = 'right-to-left'
AIRFLOW_SIDE_TO_REAR = 'side-to-rear'
AIRFLOW_PASSIVE = 'passive'
+ AIRFLOW_MIXED = 'mixed'
CHOICES = (
(AIRFLOW_FRONT_TO_REAR, 'Front to rear'),
@@ -167,6 +168,7 @@ class DeviceAirflowChoices(ChoiceSet):
(AIRFLOW_RIGHT_TO_LEFT, 'Right to left'),
(AIRFLOW_SIDE_TO_REAR, 'Side to rear'),
(AIRFLOW_PASSIVE, 'Passive'),
+ (AIRFLOW_MIXED, 'Mixed'),
)
@@ -575,6 +577,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32a'
TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1'
TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top'
+ TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
# Other
TYPE_HARDWIRED = 'hardwired'
@@ -683,6 +686,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'),
(TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'),
(TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
+ (TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'),
)),
('Other', (
(TYPE_HARDWIRED, 'Hardwired'),
diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py
index 45844b049..38bf16f0b 100644
--- a/netbox/dcim/constants.py
+++ b/netbox/dcim/constants.py
@@ -62,6 +62,8 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
# Device components
#
+MODULE_TOKEN = '{module}'
+
MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
app_label='dcim',
model__in=(
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index 54f533a7f..d57d0a59b 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -346,6 +346,32 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='slug',
label='Site (slug)',
)
+ region_id = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='rack__site__region',
+ lookup_expr='in',
+ label='Region (ID)',
+ )
+ region = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='rack__site__region',
+ lookup_expr='in',
+ to_field_name='slug',
+ label='Region (slug)',
+ )
+ site_group_id = TreeNodeMultipleChoiceFilter(
+ queryset=SiteGroup.objects.all(),
+ field_name='rack__site__group',
+ lookup_expr='in',
+ label='Site group (ID)',
+ )
+ site_group = TreeNodeMultipleChoiceFilter(
+ queryset=SiteGroup.objects.all(),
+ field_name='rack__site__group',
+ lookup_expr='in',
+ to_field_name='slug',
+ label='Site group (slug)',
+ )
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='rack__location',
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 7f30941a2..0c7d02f9d 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -210,7 +210,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
model = Rack
fieldsets = (
(None, ('q', 'tag')),
- ('Location', ('region_id', 'site_id', 'location_id')),
+ ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Function', ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -229,6 +229,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
},
label=_('Site')
)
+ site_group_id = DynamicModelMultipleChoiceField(
+ queryset=SiteGroup.objects.all(),
+ required=False,
+ label=_('Site group')
+ )
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
@@ -282,7 +287,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
(None, ('q', 'tag')),
('User', ('user_id',)),
- ('Rack', ('region_id', 'site_id', 'location_id')),
+ ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -298,6 +303,11 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
},
label=_('Site')
)
+ site_group_id = DynamicModelMultipleChoiceField(
+ queryset=SiteGroup.objects.all(),
+ required=False,
+ label=_('Site group')
+ )
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.prefetch_related('site'),
required=False,
diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py
index 31c5b957d..179893219 100644
--- a/netbox/dcim/forms/models.py
+++ b/netbox/dcim/forms/models.py
@@ -633,12 +633,18 @@ class ModuleForm(NetBoxModelForm):
help_text="Automatically populate components associated with this module type"
)
+ adopt_components = forms.BooleanField(
+ required=False,
+ initial=False,
+ help_text="Adopt already existing components"
+ )
+
fieldsets = (
('Module', (
'device', 'module_bay', 'manufacturer', 'module_type', 'tags',
)),
('Hardware', (
- 'serial', 'asset_tag', 'replicate_components',
+ 'serial', 'asset_tag', 'replicate_components', 'adopt_components',
)),
)
@@ -646,7 +652,7 @@ class ModuleForm(NetBoxModelForm):
model = Module
fields = [
'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags',
- 'replicate_components', 'comments',
+ 'replicate_components', 'adopt_components', 'comments',
]
def __init__(self, *args, **kwargs):
@@ -655,6 +661,8 @@ class ModuleForm(NetBoxModelForm):
if self.instance.pk:
self.fields['replicate_components'].initial = False
self.fields['replicate_components'].disabled = True
+ self.fields['adopt_components'].initial = False
+ self.fields['adopt_components'].disabled = True
def save(self, *args, **kwargs):
@@ -662,8 +670,62 @@ class ModuleForm(NetBoxModelForm):
if self.instance.pk or not self.cleaned_data['replicate_components']:
self.instance._disable_replication = True
+ if self.cleaned_data['adopt_components']:
+ self.instance._adopt_components = True
+
return super().save(*args, **kwargs)
+ def clean(self):
+ super().clean()
+
+ replicate_components = self.cleaned_data.get("replicate_components")
+ adopt_components = self.cleaned_data.get("adopt_components")
+ device = self.cleaned_data['device']
+ module_type = self.cleaned_data['module_type']
+ module_bay = self.cleaned_data['module_bay']
+
+ # Bail out if we are not installing a new module or if we are not replicating components
+ if self.instance.pk or not replicate_components:
+ return
+
+ for templates, component_attribute in [
+ ("consoleporttemplates", "consoleports"),
+ ("consoleserverporttemplates", "consoleserverports"),
+ ("interfacetemplates", "interfaces"),
+ ("powerporttemplates", "powerports"),
+ ("poweroutlettemplates", "poweroutlets"),
+ ("rearporttemplates", "rearports"),
+ ("frontporttemplates", "frontports")
+ ]:
+ # Prefetch installed components
+ installed_components = {
+ component.name: component for component in getattr(device, component_attribute).all()
+ }
+
+ # Get the templates for the module type.
+ for template in getattr(module_type, templates).all():
+ # Installing modules with placeholders require that the bay has a position value
+ if MODULE_TOKEN in template.name and not module_bay.position:
+ raise forms.ValidationError(
+ "Cannot install module with placeholder values in a module bay with no position defined"
+ )
+
+ resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
+ existing_item = installed_components.get(resolved_name)
+
+ # It is not possible to adopt components already belonging to a module
+ if adopt_components and existing_item and existing_item.module:
+ raise forms.ValidationError(
+ f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
+ f"to a module"
+ )
+
+ # If we are not adopting components we error if the component exists
+ if not adopt_components and resolved_name in installed_components:
+ raise forms.ValidationError(
+ f"{template.component_model.__name__} - {resolved_name} already exists"
+ )
+
class CableForm(TenancyForm, NetBoxModelForm):
@@ -1284,6 +1346,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
'rf_channel_width': "Populated by selected channel (if set)",
}
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Restrict LAG/bridge interface assignment by device/VC
+ device_id = self.data['device'] if self.is_bound else self.initial.get('device')
+ device = Device.objects.filter(pk=device_id).first()
+ if device and device.virtual_chassis and device.virtual_chassis.master:
+ self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
+ self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
+
class FrontPortForm(NetBoxModelForm):
module = DynamicModelChoiceField(
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index 647abe148..92658d310 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -121,12 +121,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
def resolve_name(self, module):
if module:
- return self.name.replace('{module}', module.module_bay.position)
+ return self.name.replace(MODULE_TOKEN, module.module_bay.position)
return self.name
def resolve_label(self, module):
if module:
- return self.label.replace('{module}', module.module_bay.position)
+ return self.label.replace(MODULE_TOKEN, module.module_bay.position)
return self.label
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index a3b182da1..9a0609c12 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -543,7 +543,8 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
)
speed = models.PositiveIntegerField(
blank=True,
- null=True
+ null=True,
+ verbose_name='Speed (Kbps)'
)
duplex = models.CharField(
max_length=50,
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 6ed7b349f..8d50db958 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -1065,30 +1065,52 @@ class Module(NetBoxModel, ConfigContextModel):
super().save(*args, **kwargs)
- # If this is a new Module and component replication has not been disabled, instantiate all its
- # related components per the ModuleType definition
- if is_new and not getattr(self, '_disable_replication', False):
- ConsolePort.objects.bulk_create(
- [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()]
- )
- ConsoleServerPort.objects.bulk_create(
- [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()]
- )
- PowerPort.objects.bulk_create(
- [x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()]
- )
- PowerOutlet.objects.bulk_create(
- [x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()]
- )
- Interface.objects.bulk_create(
- [x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()]
- )
- RearPort.objects.bulk_create(
- [x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()]
- )
- FrontPort.objects.bulk_create(
- [x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()]
- )
+ adopt_components = getattr(self, '_adopt_components', False)
+ disable_replication = getattr(self, '_disable_replication', False)
+
+ # We skip adding components if the module is being edited or
+ # both replication and component adoption is disabled
+ if not is_new or (disable_replication and not adopt_components):
+ return
+
+ # Iterate all component types
+ for templates, component_attribute, component_model in [
+ ("consoleporttemplates", "consoleports", ConsolePort),
+ ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
+ ("interfacetemplates", "interfaces", Interface),
+ ("powerporttemplates", "powerports", PowerPort),
+ ("poweroutlettemplates", "poweroutlets", PowerOutlet),
+ ("rearporttemplates", "rearports", RearPort),
+ ("frontporttemplates", "frontports", FrontPort)
+ ]:
+ create_instances = []
+ update_instances = []
+
+ # Prefetch installed components
+ installed_components = {
+ component.name: component for component in getattr(self.device, component_attribute).filter(module__isnull=True)
+ }
+
+ # Get the template for the module type.
+ for template in getattr(self.module_type, templates).all():
+ template_instance = template.instantiate(device=self.device, module=self)
+
+ if adopt_components:
+ existing_item = installed_components.get(template_instance.name)
+
+ # Check if there's a component with the same name already
+ if existing_item:
+ # Assign it to the module
+ existing_item.module = self
+ update_instances.append(existing_item)
+ continue
+
+ # Only create new components if replication is enabled
+ if not disable_replication:
+ create_instances.append(template_instance)
+
+ component_model.objects.bulk_create(create_instances)
+ component_model.objects.bulk_update(update_instances, ['module'])
#
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 25ad1415d..0f015b7f3 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -190,7 +190,7 @@ class DeviceTable(NetBoxTable):
verbose_name='VC Priority'
)
comments = columns.MarkdownColumn()
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py
index f5f5ed7bf..2da9daee7 100644
--- a/netbox/dcim/tables/devicetypes.py
+++ b/netbox/dcim/tables/devicetypes.py
@@ -31,7 +31,9 @@ class ManufacturerTable(NetBoxTable):
name = tables.Column(
linkify=True
)
- devicetype_count = tables.Column(
+ devicetype_count = columns.LinkedCountColumn(
+ viewname='dcim:devicetype_list',
+ url_params={'manufacturer_id': 'pk'},
verbose_name='Device Types'
)
inventoryitem_count = tables.Column(
@@ -41,7 +43,7 @@ class ManufacturerTable(NetBoxTable):
verbose_name='Platforms'
)
slug = tables.Column()
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py
index cab95bb02..92c4bb0aa 100644
--- a/netbox/dcim/tables/power.py
+++ b/netbox/dcim/tables/power.py
@@ -26,7 +26,7 @@ class PowerPanelTable(NetBoxTable):
url_params={'power_panel_id': 'pk'},
verbose_name='Feeds'
)
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py
index e5a1c8488..e6368cb74 100644
--- a/netbox/dcim/tables/racks.py
+++ b/netbox/dcim/tables/racks.py
@@ -69,7 +69,7 @@ class RackTable(NetBoxTable):
orderable=False,
verbose_name='Power'
)
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py
index 84522480f..fa3c73e12 100644
--- a/netbox/dcim/tables/sites.py
+++ b/netbox/dcim/tables/sites.py
@@ -26,7 +26,7 @@ class RegionTable(NetBoxTable):
url_params={'region_id': 'pk'},
verbose_name='Sites'
)
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
@@ -55,7 +55,7 @@ class SiteGroupTable(NetBoxTable):
url_params={'group_id': 'pk'},
verbose_name='Sites'
)
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
@@ -86,7 +86,7 @@ class SiteTable(NetBoxTable):
group = tables.Column(
linkify=True
)
- asns = tables.ManyToManyColumn(
+ asns = columns.ManyToManyColumn(
linkify_item=True,
verbose_name='ASNs'
)
@@ -98,7 +98,7 @@ class SiteTable(NetBoxTable):
)
tenant = TenantColumn()
comments = columns.MarkdownColumn()
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
@@ -137,7 +137,7 @@ class LocationTable(NetBoxTable):
url_params={'location_id': 'pk'},
verbose_name='Devices'
)
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index 5c7d22955..22537abe0 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -523,6 +523,9 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
+ moduletype = ModuleType.objects.create(
+ manufacturer=manufacturer, model='Module Type 1'
+ )
console_port_templates = (
ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'),
@@ -541,9 +544,13 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Console Port Template 5',
},
{
- 'device_type': devicetype.pk,
+ 'module_type': moduletype.pk,
'name': 'Console Port Template 6',
},
+ {
+ 'module_type': moduletype.pk,
+ 'name': 'Console Port Template 7',
+ },
]
@@ -560,6 +567,9 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
+ moduletype = ModuleType.objects.create(
+ manufacturer=manufacturer, model='Module Type 1'
+ )
console_server_port_templates = (
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'),
@@ -578,9 +588,13 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Console Server Port Template 5',
},
{
- 'device_type': devicetype.pk,
+ 'module_type': moduletype.pk,
'name': 'Console Server Port Template 6',
},
+ {
+ 'module_type': moduletype.pk,
+ 'name': 'Console Server Port Template 7',
+ },
]
@@ -597,6 +611,9 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
+ moduletype = ModuleType.objects.create(
+ manufacturer=manufacturer, model='Module Type 1'
+ )
power_port_templates = (
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
@@ -615,9 +632,13 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Power Port Template 5',
},
{
- 'device_type': devicetype.pk,
+ 'module_type': moduletype.pk,
'name': 'Power Port Template 6',
},
+ {
+ 'module_type': moduletype.pk,
+ 'name': 'Power Port Template 7',
+ },
]
@@ -634,6 +655,9 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
+ moduletype = ModuleType.objects.create(
+ manufacturer=manufacturer, model='Module Type 1'
+ )
power_port_templates = (
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
@@ -664,6 +688,14 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Power Outlet Template 6',
'power_port': None,
},
+ {
+ 'module_type': moduletype.pk,
+ 'name': 'Power Outlet Template 7',
+ },
+ {
+ 'module_type': moduletype.pk,
+ 'name': 'Power Outlet Template 8',
+ },
]
@@ -680,6 +712,9 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
+ moduletype = ModuleType.objects.create(
+ manufacturer=manufacturer, model='Module Type 1'
+ )
interface_templates = (
InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'),
@@ -700,10 +735,15 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
'type': '1000base-t',
},
{
- 'device_type': devicetype.pk,
+ 'module_type': moduletype.pk,
'name': 'Interface Template 6',
'type': '1000base-t',
},
+ {
+ 'module_type': moduletype.pk,
+ 'name': 'Interface Template 7',
+ 'type': '1000base-t',
+ },
]
@@ -720,14 +760,19 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
+ moduletype = ModuleType.objects.create(
+ manufacturer=manufacturer, model='Module Type 1'
+ )
rear_port_templates = (
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C),
- RearPortTemplate(device_type=devicetype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C),
- RearPortTemplate(device_type=devicetype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C),
+ RearPortTemplate(module_type=moduletype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C),
+ RearPortTemplate(module_type=moduletype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C),
+ RearPortTemplate(module_type=moduletype, name='Rear Port Template 7', type=PortTypeChoices.TYPE_8P8C),
+ RearPortTemplate(module_type=moduletype, name='Rear Port Template 8', type=PortTypeChoices.TYPE_8P8C),
)
RearPortTemplate.objects.bulk_create(rear_port_templates)
@@ -745,15 +790,28 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
rear_port=rear_port_templates[1]
),
FrontPortTemplate(
- device_type=devicetype,
- name='Front Port Template 3',
+ module_type=moduletype,
+ name='Front Port Template 5',
type=PortTypeChoices.TYPE_8P8C,
- rear_port=rear_port_templates[2]
+ rear_port=rear_port_templates[4]
+ ),
+ FrontPortTemplate(
+ module_type=moduletype,
+ name='Front Port Template 6',
+ type=PortTypeChoices.TYPE_8P8C,
+ rear_port=rear_port_templates[5]
),
)
FrontPortTemplate.objects.bulk_create(front_port_templates)
cls.create_data = [
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Front Port Template 3',
+ 'type': PortTypeChoices.TYPE_8P8C,
+ 'rear_port': rear_port_templates[2].pk,
+ 'rear_port_position': 1,
+ },
{
'device_type': devicetype.pk,
'name': 'Front Port Template 4',
@@ -762,17 +820,17 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
'rear_port_position': 1,
},
{
- 'device_type': devicetype.pk,
- 'name': 'Front Port Template 5',
+ 'module_type': moduletype.pk,
+ 'name': 'Front Port Template 7',
'type': PortTypeChoices.TYPE_8P8C,
- 'rear_port': rear_port_templates[4].pk,
+ 'rear_port': rear_port_templates[6].pk,
'rear_port_position': 1,
},
{
- 'device_type': devicetype.pk,
- 'name': 'Front Port Template 6',
+ 'module_type': moduletype.pk,
+ 'name': 'Front Port Template 8',
'type': PortTypeChoices.TYPE_8P8C,
- 'rear_port': rear_port_templates[5].pk,
+ 'rear_port': rear_port_templates[7].pk,
'rear_port_position': 1,
},
]
@@ -791,6 +849,9 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
+ moduletype = ModuleType.objects.create(
+ manufacturer=manufacturer, model='Module Type 1'
+ )
rear_port_templates = (
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
@@ -811,10 +872,15 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
'type': PortTypeChoices.TYPE_8P8C,
},
{
- 'device_type': devicetype.pk,
+ 'module_type': moduletype.pk,
'name': 'Rear Port Template 6',
'type': PortTypeChoices.TYPE_8P8C,
},
+ {
+ 'module_type': moduletype.pk,
+ 'name': 'Rear Port Template 7',
+ 'type': PortTypeChoices.TYPE_8P8C,
+ },
]
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index 8480c97bf..273ee6570 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -521,10 +521,26 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
+ regions = (
+ Region(name='Region 1', slug='region-1'),
+ Region(name='Region 2', slug='region-2'),
+ Region(name='Region 3', slug='region-3'),
+ )
+ for region in regions:
+ region.save()
+
+ groups = (
+ SiteGroup(name='Site Group 1', slug='site-group-1'),
+ SiteGroup(name='Site Group 2', slug='site-group-2'),
+ SiteGroup(name='Site Group 3', slug='site-group-3'),
+ )
+ for group in groups:
+ group.save()
+
sites = (
- Site(name='Site 1', slug='site-1'),
- Site(name='Site 2', slug='site-2'),
- Site(name='Site 3', slug='site-3'),
+ Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+ Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+ Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
)
Site.objects.bulk_create(sites)
@@ -572,6 +588,20 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
)
RackReservation.objects.bulk_create(reservations)
+ def test_region(self):
+ regions = Region.objects.all()[:2]
+ params = {'region_id': [regions[0].pk, regions[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'region': [regions[0].slug, regions[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_site_group(self):
+ site_groups = SiteGroup.objects.all()[:2]
+ params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index 70eb4b659..e17f94682 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -1869,6 +1869,44 @@ class ModuleTestCase(
self.assertHttpStatus(self.client.post(**request), 302)
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_module_component_adoption(self):
+ self.add_permissions('dcim.add_module')
+
+ interface_name = "Interface-1"
+
+ # Add an interface to the ModuleType
+ module_type = ModuleType.objects.first()
+ InterfaceTemplate(module_type=module_type, name=interface_name).save()
+
+ form_data = self.form_data.copy()
+ device = Device.objects.get(pk=form_data['device'])
+
+ # Create an interface to be adopted
+ interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED)
+ interface.save()
+
+ # Ensure that interface is created with no module
+ self.assertIsNone(interface.module)
+
+ # Create a module with adopted components
+ form_data['module_bay'] = ModuleBay.objects.filter(device=device).first()
+ form_data['module_type'] = module_type
+ form_data['replicate_components'] = False
+ form_data['adopt_components'] = True
+ request = {
+ 'path': self._get_url('add'),
+ 'data': post_data(form_data),
+ }
+
+ self.assertHttpStatus(self.client.post(**request), 302)
+
+ # Re-retrieve interface to get new module id
+ interface.refresh_from_db()
+
+ # Check that the Interface now has a module
+ self.assertIsNotNone(interface.module)
+
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 2622a1405..57e8b1c79 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -166,7 +166,7 @@ class RegionView(generic.ObjectView):
sites = Site.objects.restrict(request.user, 'view').filter(
region=instance
)
- sites_table = tables.SiteTable(sites, exclude=('region',))
+ sites_table = tables.SiteTable(sites, user=request.user, exclude=('region',))
sites_table.configure(request)
return {
@@ -251,7 +251,7 @@ class SiteGroupView(generic.ObjectView):
sites = Site.objects.restrict(request.user, 'view').filter(
group=instance
)
- sites_table = tables.SiteTable(sites, exclude=('group',))
+ sites_table = tables.SiteTable(sites, user=request.user, exclude=('group',))
sites_table.configure(request)
return {
@@ -435,7 +435,7 @@ class LocationView(generic.ObjectView):
'rack_count',
cumulative=True
).filter(pk__in=location_ids).exclude(pk=instance.pk)
- child_locations_table = tables.LocationTable(child_locations)
+ child_locations_table = tables.LocationTable(child_locations, user=request.user)
child_locations_table.configure(request)
nonracked_devices = Device.objects.filter(
@@ -514,7 +514,9 @@ class RackRoleView(generic.ObjectView):
role=instance
)
- racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization'))
+ racks_table = tables.RackTable(racks, user=request.user, exclude=(
+ 'role', 'get_utilization', 'get_power_utilization',
+ ))
racks_table.configure(request)
return {
@@ -767,7 +769,7 @@ class ManufacturerView(generic.ObjectView):
manufacturer=instance
)
- devicetypes_table = tables.DeviceTypeTable(device_types, exclude=('manufacturer',))
+ devicetypes_table = tables.DeviceTypeTable(device_types, user=request.user, exclude=('manufacturer',))
devicetypes_table.configure(request)
return {
@@ -1480,7 +1482,7 @@ class DeviceRoleView(generic.ObjectView):
devices = Device.objects.restrict(request.user, 'view').filter(
device_role=instance
)
- devices_table = tables.DeviceTable(devices, exclude=('device_role',))
+ devices_table = tables.DeviceTable(devices, user=request.user, exclude=('device_role',))
devices_table.configure(request)
return {
@@ -1544,7 +1546,7 @@ class PlatformView(generic.ObjectView):
devices = Device.objects.restrict(request.user, 'view').filter(
platform=instance
)
- devices_table = tables.DeviceTable(devices, exclude=('platform',))
+ devices_table = tables.DeviceTable(devices, user=request.user, exclude=('platform',))
devices_table.configure(request)
return {
diff --git a/netbox/extras/management/commands/clearcache.py b/netbox/extras/management/commands/clearcache.py
new file mode 100644
index 000000000..22843c490
--- /dev/null
+++ b/netbox/extras/management/commands/clearcache.py
@@ -0,0 +1,11 @@
+from django.core.cache import cache
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+ """Command to clear the entire cache."""
+ help = 'Clears the cache.'
+
+ def handle(self, *args, **kwargs):
+ cache.clear()
+ self.stdout.write('Cache has been cleared.', ending="\n")
diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py
index 152d8b726..a364d3c6a 100644
--- a/netbox/ipam/choices.py
+++ b/netbox/ipam/choices.py
@@ -91,7 +91,7 @@ class IPAddressRoleChoices(ChoiceSet):
(ROLE_VRRP, 'VRRP', 'green'),
(ROLE_HSRP, 'HSRP', 'green'),
(ROLE_GLBP, 'GLBP', 'green'),
- (ROLE_CARP, 'CARP'), 'green',
+ (ROLE_CARP, 'CARP', 'green'),
)
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index 53c589bb3..7839dc03e 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -681,11 +681,53 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
queryset=FHRPGroup.objects.all(),
label='Group (ID)',
)
+ device = MultiValueCharFilter(
+ method='filter_device',
+ field_name='name',
+ label='Device (name)',
+ )
+ device_id = MultiValueNumberFilter(
+ method='filter_device',
+ field_name='pk',
+ label='Device (ID)',
+ )
+ virtual_machine = MultiValueCharFilter(
+ method='filter_virtual_machine',
+ field_name='name',
+ label='Virtual machine (name)',
+ )
+ virtual_machine_id = MultiValueNumberFilter(
+ method='filter_virtual_machine',
+ field_name='pk',
+ label='Virtual machine (ID)',
+ )
class Meta:
model = FHRPGroupAssignment
fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority']
+ def filter_device(self, queryset, name, value):
+ devices = Device.objects.filter(**{f'{name}__in': value})
+ if not devices.exists():
+ return queryset.none()
+ interface_ids = []
+ for device in devices:
+ interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
+ return queryset.filter(
+ Q(interface_type=ContentType.objects.get_for_model(Interface), interface_id__in=interface_ids)
+ )
+
+ def filter_virtual_machine(self, queryset, name, value):
+ virtual_machines = VirtualMachine.objects.filter(**{f'{name}__in': value})
+ if not virtual_machines.exists():
+ return queryset.none()
+ interface_ids = []
+ for vm in virtual_machines:
+ interface_ids.extend(vm.interfaces.values_list('id', flat=True))
+ return queryset.filter(
+ Q(interface_type=ContentType.objects.get_for_model(VMInterface), interface_id__in=interface_ids)
+ )
+
class VLANGroupFilterSet(OrganizationalModelFilterSet):
scope_type = ContentTypeFilter()
diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py
index 244bcee8e..475ad787e 100644
--- a/netbox/ipam/tables/ip.py
+++ b/netbox/ipam/tables/ip.py
@@ -118,7 +118,7 @@ class ASNTable(NetBoxTable):
url_params={'asn_id': 'pk'},
verbose_name='Provider Count'
)
- sites = tables.ManyToManyColumn(
+ sites = columns.ManyToManyColumn(
linkify_item=True,
verbose_name='Sites'
)
diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py
index 4bb72dce2..198f9d62d 100644
--- a/netbox/ipam/tests/test_filtersets.py
+++ b/netbox/ipam/tests/test_filtersets.py
@@ -1024,6 +1024,20 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'priority': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ def test_device(self):
+ device = Device.objects.first()
+ params = {'device': [device.name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ params = {'device_id': [device.pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+ def test_virtual_machine(self):
+ vm = VirtualMachine.objects.first()
+ params = {'virtual_machine': [vm.name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ params = {'virtual_machine_id': [vm.pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VLANGroup.objects.all()
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 57a682c94..79804aabd 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -161,7 +161,7 @@ class RIRView(generic.ObjectView):
aggregates = Aggregate.objects.restrict(request.user, 'view').filter(rir=instance).annotate(
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
)
- aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization'))
+ aggregates_table = tables.AggregateTable(aggregates, user=request.user, exclude=('rir', 'utilization'))
aggregates_table.configure(request)
return {
@@ -221,12 +221,12 @@ class ASNView(generic.ObjectView):
def get_extra_context(self, request, instance):
# Gather assigned Sites
sites = instance.sites.restrict(request.user, 'view')
- sites_table = SiteTable(sites)
+ sites_table = SiteTable(sites, user=request.user)
sites_table.configure(request)
# Gather assigned Providers
providers = instance.providers.restrict(request.user, 'view')
- providers_table = ProviderTable(providers)
+ providers_table = ProviderTable(providers, user=request.user)
providers_table.configure(request)
return {
@@ -366,7 +366,7 @@ class RoleView(generic.ObjectView):
role=instance
)
- prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization'))
+ prefixes_table = tables.PrefixTable(prefixes, user=request.user, exclude=('role', 'utilization'))
prefixes_table.configure(request)
return {
@@ -805,7 +805,7 @@ class VLANGroupView(generic.ObjectView):
vlans_count = vlans.count()
vlans = add_available_vlans(vlans, vlan_group=instance)
- vlans_table = tables.VLANTable(vlans, exclude=('group',))
+ vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',))
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
vlans_table.columns.show('pk')
vlans_table.configure(request)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 7f0735546..999e39479 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -1,3 +1,4 @@
+import hashlib
import importlib
import logging
import os
@@ -8,9 +9,11 @@ import sys
import warnings
from urllib.parse import urlsplit
+import sentry_sdk
from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator
+from sentry_sdk.integrations.django import DjangoIntegration
from netbox.config import PARAMS
@@ -26,7 +29,7 @@ django.utils.encoding.force_text = force_str
# Environment setup
#
-VERSION = '3.2.2'
+VERSION = '3.2.3'
# Hostname
HOSTNAME = platform.node()
@@ -40,6 +43,7 @@ if sys.version_info < (3, 8):
f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})"
)
+DEFAULT_SENTRY_DSN = 'https://198cf560b29d4054ab8e583a1d10ea58@o1242133.ingest.sentry.io/6396485'
#
# Configuration import
@@ -68,6 +72,9 @@ DATABASE = getattr(configuration, 'DATABASE')
REDIS = getattr(configuration, 'REDIS')
SECRET_KEY = getattr(configuration, 'SECRET_KEY')
+# Calculate a unique deployment ID from the secret key
+DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16]
+
# Set static config parameters
ADMINS = getattr(configuration, 'ADMINS', [])
AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [])
@@ -113,6 +120,11 @@ REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATO
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
+SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
+SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
+SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
+SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
+SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
@@ -428,6 +440,36 @@ EXEMPT_PATHS = (
)
+#
+# Sentry
+#
+
+if SENTRY_ENABLED:
+ if not SENTRY_DSN:
+ raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.")
+ # If using the default DSN, force sampling rates
+ if SENTRY_DSN == DEFAULT_SENTRY_DSN:
+ SENTRY_SAMPLE_RATE = 1.0
+ SENTRY_TRACES_SAMPLE_RATE = 0
+ # Initialize the SDK
+ sentry_sdk.init(
+ dsn=SENTRY_DSN,
+ release=VERSION,
+ integrations=[DjangoIntegration()],
+ sample_rate=SENTRY_SAMPLE_RATE,
+ traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
+ send_default_pii=True,
+ http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
+ https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None
+ )
+ # Assign any configured tags
+ for k, v in SENTRY_TAGS.items():
+ sentry_sdk.set_tag(k, v)
+ # If using the default DSN, append a unique deployment ID tag for error correlation
+ if SENTRY_DSN == DEFAULT_SENTRY_DSN:
+ sentry_sdk.set_tag('netbox.deployment_id', DEPLOYMENT_ID)
+
+
#
# Django social auth
#
diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py
index ba5583a2e..801b97766 100644
--- a/netbox/netbox/tables/columns.py
+++ b/netbox/netbox/tables/columns.py
@@ -6,7 +6,7 @@ from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.db.models import DateField, DateTimeField
from django.template import Context, Template
-from django.urls import NoReverseMatch, reverse
+from django.urls import reverse
from django.utils.formats import date_format
from django.utils.safestring import mark_safe
from django_tables2.columns import library
@@ -27,6 +27,7 @@ __all__ = (
'CustomLinkColumn',
'LinkedCountColumn',
'MarkdownColumn',
+ 'ManyToManyColumn',
'MPTTColumn',
'TagColumn',
'TemplateColumn',
@@ -35,6 +36,10 @@ __all__ = (
)
+#
+# Django-tables2 overrides
+#
+
@library.register
class DateColumn(tables.DateColumn):
"""
@@ -42,7 +47,6 @@ class DateColumn(tables.DateColumn):
tables and null when exporting data. It is registered in the tables library to use this class instead of the
default, making this behavior consistent in all fields of type DateField.
"""
-
def value(self, value):
return value
@@ -59,7 +63,6 @@ class DateTimeColumn(tables.DateTimeColumn):
tables and null when exporting data. It is registered in the tables library to use this class instead of the
default, making this behavior consistent in all fields of type DateTimeField.
"""
-
def value(self, value):
if value:
return date_format(value, format="SHORT_DATETIME_FORMAT")
@@ -71,6 +74,39 @@ class DateTimeColumn(tables.DateTimeColumn):
return cls(**kwargs)
+class ManyToManyColumn(tables.ManyToManyColumn):
+ """
+ Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data.
+ """
+ def value(self, value):
+ items = [self.transform(item) for item in self.filter(value)]
+ return self.separator.join(items)
+
+
+class TemplateColumn(tables.TemplateColumn):
+ """
+ Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value
+ is an empty string.
+ """
+ PLACEHOLDER = mark_safe('—')
+
+ def render(self, *args, **kwargs):
+ ret = super().render(*args, **kwargs)
+ if not ret.strip():
+ return self.PLACEHOLDER
+ return ret
+
+ def value(self, **kwargs):
+ ret = super().value(**kwargs)
+ if ret == self.PLACEHOLDER:
+ return ''
+ return ret
+
+
+#
+# Custom columns
+#
+
class ToggleColumn(tables.CheckBoxColumn):
"""
Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
@@ -112,26 +148,6 @@ class BooleanColumn(tables.Column):
return str(value)
-class TemplateColumn(tables.TemplateColumn):
- """
- Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value
- is an empty string.
- """
- PLACEHOLDER = mark_safe('—')
-
- def render(self, *args, **kwargs):
- ret = super().render(*args, **kwargs)
- if not ret.strip():
- return self.PLACEHOLDER
- return ret
-
- def value(self, **kwargs):
- ret = super().value(**kwargs)
- if ret == self.PLACEHOLDER:
- return ''
- return ret
-
-
@dataclass
class ActionsItem:
title: str
diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py
index e76efe0fe..e8ee4b7b6 100644
--- a/netbox/netbox/urls.py
+++ b/netbox/netbox/urls.py
@@ -100,4 +100,5 @@ urlpatterns = [
path('{}'.format(settings.BASE_PATH), include(_patterns))
]
+handler404 = 'netbox.views.handler_404'
handler500 = 'netbox.views.server_error'
diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py
index fad347c36..f159ee637 100644
--- a/netbox/netbox/views/__init__.py
+++ b/netbox/netbox/views/__init__.py
@@ -2,7 +2,6 @@ import platform
import sys
from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db.models import F
from django.http import HttpResponseServerError
@@ -11,9 +10,10 @@ from django.template import loader
from django.template.exceptions import TemplateDoesNotExist
from django.urls import reverse
from django.views.decorators.csrf import requires_csrf_token
-from django.views.defaults import ERROR_500_TEMPLATE_NAME
+from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
from django.views.generic import View
from packaging import version
+from sentry_sdk import capture_message
from circuits.models import Circuit, Provider
from dcim.models import (
@@ -190,13 +190,21 @@ class StaticMediaFailureView(View):
"""
Display a user-friendly error message with troubleshooting tips when a static media file fails to load.
"""
-
def get(self, request):
return render(request, 'media_failure.html', {
'filename': request.GET.get('filename')
})
+def handler_404(request, exception):
+ """
+ Wrap Django's default 404 handler to enable Sentry reporting.
+ """
+ capture_message("Page not found", level="error")
+
+ return page_not_found(request, exception)
+
+
@requires_csrf_token
def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
"""
diff --git a/netbox/templates/extras/object_configcontext.html b/netbox/templates/extras/object_configcontext.html
index ab730410e..2a7003b8d 100644
--- a/netbox/templates/extras/object_configcontext.html
+++ b/netbox/templates/extras/object_configcontext.html
@@ -43,7 +43,7 @@