diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 64cf5482a..67f5028cd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -58,7 +58,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- pip install pycodestyle coverage
+ pip install pycodestyle coverage tblib
- name: Build documentation
run: mkdocs build
diff --git a/base_requirements.txt b/base_requirements.txt
index e9f06e99c..77a5bb8aa 100644
--- a/base_requirements.txt
+++ b/base_requirements.txt
@@ -87,7 +87,7 @@ mkdocs-material
mkdocstrings
# Library for manipulating IP prefixes and addresses
-# https://github.com/drkjam/netaddr
+# https://github.com/netaddr/netaddr
netaddr
# Fork of PIL (Python Imaging Library) for image processing
diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md
index 4f00434d7..6279a109f 100644
--- a/docs/release-notes/version-3.1.md
+++ b/docs/release-notes/version-3.1.md
@@ -2,6 +2,18 @@
## v3.1.10 (FUTURE)
+### Enhancements
+
+* [#8457](https://github.com/netbox-community/netbox/issues/8457) - Enable adding non-racked devices from site & location views
+* [#8553](https://github.com/netbox-community/netbox/issues/8553) - Add missing object types to global search form
+* [#8575](https://github.com/netbox-community/netbox/issues/8575) - Add rack columns to cables list
+* [#8645](https://github.com/netbox-community/netbox/issues/8645) - Enable filtering objects by assigned contacts & contact roles
+
+### Bug Fixes
+
+* [#8820](https://github.com/netbox-community/netbox/issues/8820) - Fix navbar background color in dark mode
+* [#8850](https://github.com/netbox-community/netbox/issues/8850) - Show airflow field on device REST API serializer when config context data is included
+
---
## v3.1.9 (2022-03-07)
diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py
index 65951d2e7..9bf2bb439 100644
--- a/netbox/circuits/filtersets.py
+++ b/netbox/circuits/filtersets.py
@@ -3,8 +3,8 @@ from django.db.models import Q
from dcim.filtersets import CableTerminationFilterSet
from dcim.models import Region, Site, SiteGroup
-from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
-from tenancy.filtersets import TenancyFilterSet
+from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet
+from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter
from .choices import *
from .models import *
@@ -18,7 +18,7 @@ __all__ = (
)
-class ProviderFilterSet(NetBoxModelFilterSet):
+class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='circuits__terminations__site__region',
@@ -107,7 +107,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'description']
-class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(),
label='Provider (ID)',
diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py
index e7e5287a6..209f7ad7a 100644
--- a/netbox/circuits/forms/filtersets.py
+++ b/netbox/circuits/forms/filtersets.py
@@ -5,7 +5,7 @@ from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.models import Region, Site, SiteGroup
from netbox.forms import NetBoxModelFilterSetForm
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
__all__ = (
@@ -16,12 +16,13 @@ __all__ = (
)
-class ProviderFilterForm(NetBoxModelFilterSetForm):
+class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Provider
fieldsets = (
(None, ('q', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('ASN', ('asn',)),
+ ('Contacts', ('contact', 'contact_role')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -72,7 +73,7 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class CircuitFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Circuit
fieldsets = (
(None, ('q', 'tag')),
@@ -80,6 +81,7 @@ class CircuitFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
('Attributes', ('type_id', 'status', 'commit_rate')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
+ ('Contacts', ('contact', 'contact_role')),
)
type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py
index 175d1eec8..cb8c940b0 100644
--- a/netbox/circuits/tables/circuits.py
+++ b/netbox/circuits/tables/circuits.py
@@ -59,6 +59,9 @@ class CircuitTable(NetBoxTable):
)
commit_rate = CommitRateColumn()
comments = columns.MarkdownColumn()
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='circuits:circuit_list'
)
@@ -67,7 +70,7 @@ class CircuitTable(NetBoxTable):
model = Circuit
fields = (
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
- 'commit_rate', 'description', 'comments', 'tags', 'created', 'last_updated',
+ 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py
index 9ffdf54e3..d5b4329fb 100644
--- a/netbox/circuits/tables/providers.py
+++ b/netbox/circuits/tables/providers.py
@@ -19,6 +19,9 @@ class ProviderTable(NetBoxTable):
verbose_name='Circuits'
)
comments = columns.MarkdownColumn()
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='circuits:provider_list'
)
@@ -27,7 +30,7 @@ class ProviderTable(NetBoxTable):
model = Provider
fields = (
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
- 'comments', 'tags', 'created', 'last_updated',
+ 'comments', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 155eca716..039b63ce9 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -576,9 +576,9 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class Meta(DeviceSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
- 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
- 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
- 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
+ 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
+ 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
+ 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index 3c63073e2..2f888390e 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -6,8 +6,8 @@ from ipam.models import ASN, VRF
from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
)
-from tenancy.filtersets import TenancyFilterSet
-from tenancy.models import Tenant
+from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
+from tenancy.models import *
from utilities.choices import ColorChoices
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
@@ -67,7 +67,7 @@ __all__ = (
)
-class RegionFilterSet(OrganizationalModelFilterSet):
+class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(),
label='Parent region (ID)',
@@ -84,7 +84,7 @@ class RegionFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'description']
-class SiteGroupFilterSet(OrganizationalModelFilterSet):
+class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
label='Parent site group (ID)',
@@ -101,7 +101,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'description']
-class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
status = django_filters.MultipleChoiceFilter(
choices=SiteStatusChoices,
null_value=None
@@ -166,7 +166,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
return queryset.filter(qs_filter)
-class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet):
+class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
@@ -237,7 +237,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'color', 'description']
-class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
@@ -385,7 +385,7 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
)
-class ManufacturerFilterSet(OrganizationalModelFilterSet):
+class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
class Meta:
model = Manufacturer
@@ -724,7 +724,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
-class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
+class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__manufacturer',
queryset=Manufacturer.objects.all(),
@@ -1514,7 +1514,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
return queryset
-class PowerPanelFilterSet(NetBoxModelFilterSet):
+class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 180d0c4e7..0e57c338e 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -8,7 +8,7 @@ from dcim.models import *
from extras.forms import LocalConfigContextFilterForm
from ipam.models import ASN, VRF
from netbox.forms import NetBoxModelFilterSetForm
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import (
APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget,
@@ -104,8 +104,12 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
)
-class RegionFilterForm(NetBoxModelFilterSetForm):
+class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Region
+ fieldsets = (
+ (None, ('q', 'tag', 'parent_id')),
+ ('Contacts', ('contact', 'contact_role'))
+ )
parent_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -114,8 +118,12 @@ class RegionFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class SiteGroupFilterForm(NetBoxModelFilterSetForm):
+class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = SiteGroup
+ fieldsets = (
+ (None, ('q', 'tag', 'parent_id')),
+ ('Contacts', ('contact', 'contact_role'))
+ )
parent_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
@@ -124,12 +132,13 @@ class SiteGroupFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class SiteFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Site
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
+ ('Contacts', ('contact', 'contact_role')),
)
status = forms.MultipleChoiceField(
choices=SiteStatusChoices,
@@ -154,12 +163,13 @@ class SiteFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class LocationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Location
fieldsets = (
(None, ('q', 'tag')),
('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
+ ('Contacts', ('contact', 'contact_role')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -197,7 +207,7 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class RackFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Rack
fieldsets = (
(None, ('q', 'tag')),
@@ -205,6 +215,7 @@ class RackFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
('Function', ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')),
+ ('Contacts', ('contact', 'contact_role')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -308,8 +319,12 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class ManufacturerFilterForm(NetBoxModelFilterSetForm):
+class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Manufacturer
+ fieldsets = (
+ (None, ('q', 'tag')),
+ ('Contacts', ('contact', 'contact_role'))
+ )
tag = TagFilterField(model)
@@ -465,7 +480,12 @@ class PlatformFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
+class DeviceFilterForm(
+ LocalConfigContextFilterForm,
+ TenancyFilterForm,
+ ContactModelFilterForm,
+ NetBoxModelFilterSetForm
+):
model = Device
fieldsets = (
(None, ('q', 'tag')),
@@ -473,6 +493,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
('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')),
('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
)),
@@ -741,11 +762,12 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class PowerPanelFilterForm(NetBoxModelFilterSetForm):
+class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = PowerPanel
fieldsets = (
(None, ('q', 'tag')),
- ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id'))
+ ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
+ ('Contacts', ('contact', 'contact_role')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py
index 6ddfb258c..4b062ad48 100644
--- a/netbox/dcim/tables/cables.py
+++ b/netbox/dcim/tables/cables.py
@@ -22,6 +22,12 @@ class CableTable(NetBoxTable):
orderable=False,
verbose_name='Side A'
)
+ rack_a = tables.Column(
+ accessor=Accessor('termination_a__device__rack'),
+ orderable=False,
+ linkify=True,
+ verbose_name='Rack A'
+ )
termination_a = tables.Column(
accessor=Accessor('termination_a'),
orderable=False,
@@ -34,6 +40,12 @@ class CableTable(NetBoxTable):
orderable=False,
verbose_name='Side B'
)
+ rack_b = tables.Column(
+ accessor=Accessor('termination_b__device__rack'),
+ orderable=False,
+ linkify=True,
+ verbose_name='Rack B'
+ )
termination_b = tables.Column(
accessor=Accessor('termination_b'),
orderable=False,
@@ -54,7 +66,7 @@ class CableTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Cable
fields = (
- 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
+ 'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b',
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
)
default_columns = (
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 71672dde2..d12a8327f 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -190,6 +190,9 @@ class DeviceTable(NetBoxTable):
verbose_name='VC Priority'
)
comments = columns.MarkdownColumn()
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='dcim:device_list'
)
@@ -199,8 +202,8 @@ class DeviceTable(NetBoxTable):
fields = (
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
- 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'created',
- 'last_updated',
+ 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags',
+ 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py
index 5c38b429f..e5e703ee0 100644
--- a/netbox/dcim/tables/devicetypes.py
+++ b/netbox/dcim/tables/devicetypes.py
@@ -41,6 +41,9 @@ class ManufacturerTable(NetBoxTable):
verbose_name='Platforms'
)
slug = tables.Column()
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='dcim:manufacturer_list'
)
@@ -49,7 +52,7 @@ class ManufacturerTable(NetBoxTable):
model = Manufacturer
fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
- 'actions', 'created', 'last_updated',
+ 'contacts', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py
index 99bc963f9..cab95bb02 100644
--- a/netbox/dcim/tables/power.py
+++ b/netbox/dcim/tables/power.py
@@ -26,13 +26,16 @@ class PowerPanelTable(NetBoxTable):
url_params={'power_panel_id': 'pk'},
verbose_name='Feeds'
)
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='dcim:powerpanel_list'
)
class Meta(NetBoxTable.Meta):
model = PowerPanel
- fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',)
+ fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py
index 416e9e8ff..e5a1c8488 100644
--- a/netbox/dcim/tables/racks.py
+++ b/netbox/dcim/tables/racks.py
@@ -69,6 +69,9 @@ class RackTable(NetBoxTable):
orderable=False,
verbose_name='Power'
)
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='dcim:rack_list'
)
@@ -86,7 +89,7 @@ class RackTable(NetBoxTable):
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
- 'get_power_utilization', 'tags', 'created', 'last_updated',
+ 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py
index 8e158a38f..d4d355474 100644
--- a/netbox/dcim/tables/sites.py
+++ b/netbox/dcim/tables/sites.py
@@ -26,6 +26,9 @@ class RegionTable(NetBoxTable):
url_params={'region_id': 'pk'},
verbose_name='Sites'
)
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='dcim:region_list'
)
@@ -33,7 +36,8 @@ class RegionTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Region
fields = (
- 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions',
+ 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated',
+ 'actions',
)
default_columns = ('pk', 'name', 'site_count', 'description')
@@ -51,6 +55,9 @@ class SiteGroupTable(NetBoxTable):
url_params={'group_id': 'pk'},
verbose_name='Sites'
)
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='dcim:sitegroup_list'
)
@@ -58,7 +65,8 @@ class SiteGroupTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = SiteGroup
fields = (
- 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions',
+ 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated',
+ 'actions',
)
default_columns = ('pk', 'name', 'site_count', 'description')
@@ -90,6 +98,9 @@ class SiteTable(NetBoxTable):
)
tenant = TenantColumn()
comments = columns.MarkdownColumn()
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='dcim:site_list'
)
@@ -99,7 +110,7 @@ class SiteTable(NetBoxTable):
fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
- 'tags', 'created', 'last_updated', 'actions',
+ 'contacts', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
@@ -126,6 +137,9 @@ class LocationTable(NetBoxTable):
url_params={'location_id': 'pk'},
verbose_name='Devices'
)
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='dcim:location_list'
)
@@ -136,7 +150,7 @@ class LocationTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Location
fields = (
- 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags',
- 'actions', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
+ 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description')
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 7105503bc..0c50bfcd1 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -338,6 +338,11 @@ class SiteView(generic.ObjectView):
'device_count',
cumulative=True
).restrict(request.user, 'view').filter(site=instance)
+ nonracked_devices = Device.objects.filter(
+ site=instance,
+ position__isnull=True,
+ parent_bay__isnull=True
+ ).prefetch_related('device_type__manufacturer')
asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance)
asn_count = asns.count()
@@ -348,6 +353,7 @@ class SiteView(generic.ObjectView):
'stats': stats,
'locations': locations,
'asns': asns,
+ 'nonracked_devices': nonracked_devices,
}
@@ -425,11 +431,17 @@ class LocationView(generic.ObjectView):
).filter(pk__in=location_ids).exclude(pk=instance.pk)
child_locations_table = tables.LocationTable(child_locations)
child_locations_table.configure(request)
+ nonracked_devices = Device.objects.filter(
+ location=instance,
+ position__isnull=True,
+ parent_bay__isnull=True
+ ).prefetch_related('device_type__manufacturer')
return {
'rack_count': rack_count,
'device_count': device_count,
'child_locations_table': child_locations_table,
+ 'nonracked_devices': nonracked_devices,
}
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index ebe4aea2f..88b586bf2 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -309,7 +309,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
)
vlan_vid = django_filters.NumberFilter(
field_name='vlan__vid',
- label='VLAN number (1-4095)',
+ label='VLAN number (1-4094)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=Role.objects.all(),
diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py
index 365f82858..17da242a0 100644
--- a/netbox/ipam/forms/bulk_import.py
+++ b/netbox/ipam/forms/bulk_import.py
@@ -388,7 +388,7 @@ class VLANCSVForm(NetBoxModelCSVForm):
model = VLAN
fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description')
help_texts = {
- 'vid': 'Numeric VLAN ID (1-4095)',
+ 'vid': 'Numeric VLAN ID (1-4094)',
'name': 'VLAN name',
}
diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py
index e29da6617..45de4d5b2 100644
--- a/netbox/netbox/constants.py
+++ b/netbox/netbox/constants.py
@@ -1,4 +1,5 @@
from collections import OrderedDict
+from typing import Dict
from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet
from circuits.models import Circuit, ProviderNetwork, Provider
@@ -26,169 +27,212 @@ from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineTable
SEARCH_MAX_RESULTS = 15
-SEARCH_TYPES = OrderedDict((
- # Circuits
- ('provider', {
- 'queryset': Provider.objects.annotate(
- count_circuits=count_related(Circuit, 'provider')
- ),
- 'filterset': ProviderFilterSet,
- 'table': ProviderTable,
- 'url': 'circuits:provider_list',
- }),
- ('circuit', {
- 'queryset': Circuit.objects.prefetch_related(
- 'type', 'provider', 'tenant', 'terminations__site'
- ),
- 'filterset': CircuitFilterSet,
- 'table': CircuitTable,
- 'url': 'circuits:circuit_list',
- }),
- ('providernetwork', {
- 'queryset': ProviderNetwork.objects.prefetch_related('provider'),
- 'filterset': ProviderNetworkFilterSet,
- 'table': ProviderNetworkTable,
- 'url': 'circuits:providernetwork_list',
- }),
- # DCIM
- ('site', {
- 'queryset': Site.objects.prefetch_related('region', 'tenant'),
- 'filterset': SiteFilterSet,
- 'table': SiteTable,
- 'url': 'dcim:site_list',
- }),
- ('rack', {
- 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role'),
- 'filterset': RackFilterSet,
- 'table': RackTable,
- 'url': 'dcim:rack_list',
- }),
- ('rackreservation', {
- 'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
- 'filterset': RackReservationFilterSet,
- 'table': RackReservationTable,
- 'url': 'dcim:rackreservation_list',
- }),
- ('location', {
- 'queryset': Location.objects.add_related_count(
- Location.objects.add_related_count(
- Location.objects.all(),
- Device,
- 'location',
- 'device_count',
- cumulative=True
+
+CIRCUIT_TYPES = OrderedDict(
+ (
+ ('provider', {
+ 'queryset': Provider.objects.annotate(
+ count_circuits=count_related(Circuit, 'provider')
),
- Rack,
- 'location',
- 'rack_count',
- cumulative=True
- ).prefetch_related('site'),
- 'filterset': LocationFilterSet,
- 'table': LocationTable,
- 'url': 'dcim:location_list',
- }),
- ('devicetype', {
- 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
- instance_count=count_related(Device, 'device_type')
- ),
- 'filterset': DeviceTypeFilterSet,
- 'table': 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,
- 'url': 'dcim:device_list',
- }),
- ('virtualchassis', {
- 'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
- member_count=count_related(Device, 'virtual_chassis')
- ),
- 'filterset': VirtualChassisFilterSet,
- 'table': VirtualChassisTable,
- 'url': 'dcim:virtualchassis_list',
- }),
- ('cable', {
- 'queryset': Cable.objects.all(),
- 'filterset': CableFilterSet,
- 'table': CableTable,
- 'url': 'dcim:cable_list',
- }),
- ('powerfeed', {
- 'queryset': PowerFeed.objects.all(),
- 'filterset': PowerFeedFilterSet,
- 'table': PowerFeedTable,
- 'url': 'dcim:powerfeed_list',
- }),
- # Virtualization
- ('cluster', {
- 'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
- device_count=count_related(Device, 'cluster'),
- vm_count=count_related(VirtualMachine, 'cluster')
- ),
- 'filterset': ClusterFilterSet,
- 'table': ClusterTable,
- 'url': 'virtualization:cluster_list',
- }),
- ('virtualmachine', {
- 'queryset': VirtualMachine.objects.prefetch_related(
- 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
- ),
- 'filterset': VirtualMachineFilterSet,
- 'table': VirtualMachineTable,
- 'url': 'virtualization:virtualmachine_list',
- }),
- # IPAM
- ('vrf', {
- 'queryset': VRF.objects.prefetch_related('tenant'),
- 'filterset': VRFFilterSet,
- 'table': VRFTable,
- 'url': 'ipam:vrf_list',
- }),
- ('aggregate', {
- 'queryset': Aggregate.objects.prefetch_related('rir'),
- 'filterset': AggregateFilterSet,
- 'table': AggregateTable,
- 'url': 'ipam:aggregate_list',
- }),
- ('prefix', {
- 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
- 'filterset': PrefixFilterSet,
- 'table': PrefixTable,
- 'url': 'ipam:prefix_list',
- }),
- ('ipaddress', {
- 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
- 'filterset': IPAddressFilterSet,
- 'table': IPAddressTable,
- 'url': 'ipam:ipaddress_list',
- }),
- ('vlan', {
- 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
- 'filterset': VLANFilterSet,
- 'table': VLANTable,
- 'url': 'ipam:vlan_list',
- }),
- ('asn', {
- 'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
- 'filterset': ASNFilterSet,
- 'table': ASNTable,
- 'url': 'ipam:asn_list',
- }),
- # Tenancy
- ('tenant', {
- 'queryset': Tenant.objects.prefetch_related('group'),
- 'filterset': TenantFilterSet,
- 'table': TenantTable,
- 'url': 'tenancy:tenant_list',
- }),
- ('contact', {
- 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(assignment_count=count_related(ContactAssignment, 'contact')),
- 'filterset': ContactFilterSet,
- 'table': ContactTable,
- 'url': 'tenancy:contact_list',
- }),
-))
+ 'filterset': ProviderFilterSet,
+ 'table': ProviderTable,
+ 'url': 'circuits:provider_list',
+ }),
+ ('circuit', {
+ 'queryset': Circuit.objects.prefetch_related(
+ 'type', 'provider', 'tenant', 'terminations__site'
+ ),
+ 'filterset': CircuitFilterSet,
+ 'table': CircuitTable,
+ 'url': 'circuits:circuit_list',
+ }),
+ ('providernetwork', {
+ 'queryset': ProviderNetwork.objects.prefetch_related('provider'),
+ 'filterset': ProviderNetworkFilterSet,
+ 'table': ProviderNetworkTable,
+ 'url': 'circuits:providernetwork_list',
+ }),
+ )
+)
+
+
+DCIM_TYPES = OrderedDict(
+ (
+ ('site', {
+ 'queryset': Site.objects.prefetch_related('region', 'tenant'),
+ 'filterset': SiteFilterSet,
+ 'table': SiteTable,
+ 'url': 'dcim:site_list',
+ }),
+ ('rack', {
+ 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role'),
+ 'filterset': RackFilterSet,
+ 'table': RackTable,
+ 'url': 'dcim:rack_list',
+ }),
+ ('rackreservation', {
+ 'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
+ 'filterset': RackReservationFilterSet,
+ 'table': RackReservationTable,
+ 'url': 'dcim:rackreservation_list',
+ }),
+ ('location', {
+ 'queryset': Location.objects.add_related_count(
+ Location.objects.add_related_count(
+ Location.objects.all(),
+ Device,
+ 'location',
+ 'device_count',
+ cumulative=True
+ ),
+ Rack,
+ 'location',
+ 'rack_count',
+ cumulative=True
+ ).prefetch_related('site'),
+ 'filterset': LocationFilterSet,
+ 'table': LocationTable,
+ 'url': 'dcim:location_list',
+ }),
+ ('devicetype', {
+ 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
+ instance_count=count_related(Device, 'device_type')
+ ),
+ 'filterset': DeviceTypeFilterSet,
+ 'table': 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,
+ 'url': 'dcim:device_list',
+ }),
+ ('virtualchassis', {
+ 'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
+ member_count=count_related(Device, 'virtual_chassis')
+ ),
+ 'filterset': VirtualChassisFilterSet,
+ 'table': VirtualChassisTable,
+ 'url': 'dcim:virtualchassis_list',
+ }),
+ ('cable', {
+ 'queryset': Cable.objects.all(),
+ 'filterset': CableFilterSet,
+ 'table': CableTable,
+ 'url': 'dcim:cable_list',
+ }),
+ ('powerfeed', {
+ 'queryset': PowerFeed.objects.all(),
+ 'filterset': PowerFeedFilterSet,
+ 'table': PowerFeedTable,
+ 'url': 'dcim:powerfeed_list',
+ }),
+ )
+)
+
+IPAM_TYPES = OrderedDict(
+ (
+ ('vrf', {
+ 'queryset': VRF.objects.prefetch_related('tenant'),
+ 'filterset': VRFFilterSet,
+ 'table': VRFTable,
+ 'url': 'ipam:vrf_list',
+ }),
+ ('aggregate', {
+ 'queryset': Aggregate.objects.prefetch_related('rir'),
+ 'filterset': AggregateFilterSet,
+ 'table': AggregateTable,
+ 'url': 'ipam:aggregate_list',
+ }),
+ ('prefix', {
+ 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
+ 'filterset': PrefixFilterSet,
+ 'table': PrefixTable,
+ 'url': 'ipam:prefix_list',
+ }),
+ ('ipaddress', {
+ 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
+ 'filterset': IPAddressFilterSet,
+ 'table': IPAddressTable,
+ 'url': 'ipam:ipaddress_list',
+ }),
+ ('vlan', {
+ 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
+ 'filterset': VLANFilterSet,
+ 'table': VLANTable,
+ 'url': 'ipam:vlan_list',
+ }),
+ ('asn', {
+ 'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
+ 'filterset': ASNFilterSet,
+ 'table': ASNTable,
+ 'url': 'ipam:asn_list',
+ }),
+ )
+)
+
+TENANCY_TYPES = OrderedDict(
+ (
+ ('tenant', {
+ 'queryset': Tenant.objects.prefetch_related('group'),
+ 'filterset': TenantFilterSet,
+ 'table': TenantTable,
+ 'url': 'tenancy:tenant_list',
+ }),
+ ('contact', {
+ 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
+ assignment_count=count_related(ContactAssignment, 'contact')),
+ 'filterset': ContactFilterSet,
+ 'table': ContactTable,
+ 'url': 'tenancy:contact_list',
+ }),
+ )
+)
+
+VIRTUALIZATION_TYPES = OrderedDict(
+ (
+ ('cluster', {
+ 'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
+ device_count=count_related(Device, 'cluster'),
+ vm_count=count_related(VirtualMachine, 'cluster')
+ ),
+ 'filterset': ClusterFilterSet,
+ 'table': ClusterTable,
+ 'url': 'virtualization:cluster_list',
+ }),
+ ('virtualmachine', {
+ 'queryset': VirtualMachine.objects.prefetch_related(
+ 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
+ ),
+ 'filterset': VirtualMachineFilterSet,
+ 'table': VirtualMachineTable,
+ 'url': 'virtualization:virtualmachine_list',
+ }),
+ )
+)
+
+SEARCH_TYPE_HIERARCHY = OrderedDict(
+ (
+ ("Circuits", CIRCUIT_TYPES),
+ ("DCIM", DCIM_TYPES),
+ ("IPAM", IPAM_TYPES),
+ ("Tenancy", TENANCY_TYPES),
+ ("Virtualization", VIRTUALIZATION_TYPES),
+ )
+)
+
+
+def build_search_types() -> Dict[str, Dict]:
+ result = dict()
+
+ for app_types in SEARCH_TYPE_HIERARCHY.values():
+ for name, items in app_types.items():
+ result[name] = items
+
+ return result
+
+
+SEARCH_TYPES = build_search_types()
diff --git a/netbox/netbox/forms/__init__.py b/netbox/netbox/forms/__init__.py
index 9984a4461..23848724d 100644
--- a/netbox/netbox/forms/__init__.py
+++ b/netbox/netbox/forms/__init__.py
@@ -1,40 +1,25 @@
from django import forms
+from netbox.constants import SEARCH_TYPE_HIERARCHY
from utilities.forms import BootstrapMixin
from .base import *
-OBJ_TYPE_CHOICES = (
- ('', 'All Objects'),
- ('Circuits', (
- ('provider', 'Providers'),
- ('circuit', 'Circuits'),
- )),
- ('DCIM', (
- ('site', 'Sites'),
- ('rack', 'Racks'),
- ('rackreservation', 'Rack reservations'),
- ('location', 'Locations'),
- ('devicetype', 'Device Types'),
- ('device', 'Devices'),
- ('virtualchassis', 'Virtual chassis'),
- ('cable', 'Cables'),
- ('powerfeed', 'Power feeds'),
- )),
- ('IPAM', (
- ('vrf', 'VRFs'),
- ('aggregate', 'Aggregates'),
- ('prefix', 'Prefixes'),
- ('ipaddress', 'IP Addresses'),
- ('vlan', 'VLANs'),
- )),
- ('Tenancy', (
- ('tenant', 'Tenants'),
- )),
- ('Virtualization', (
- ('cluster', 'Clusters'),
- ('virtualmachine', 'Virtual Machines'),
- )),
-)
+
+def build_search_choices():
+ result = list()
+ result.append(('', 'All Objects'))
+ for category, items in SEARCH_TYPE_HIERARCHY.items():
+ subcategories = list()
+ for slug, obj in items.items():
+ name = obj['queryset'].model._meta.verbose_name_plural
+ name = name[0].upper() + name[1:]
+ subcategories.append((slug, name))
+ result.append((category, tuple(subcategories)))
+
+ return tuple(result)
+
+
+OBJ_TYPE_CHOICES = build_search_choices()
def build_options():
diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css
index 2c8931027..b929f176a 100644
Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ
diff --git a/netbox/project-static/styles/theme-dark.scss b/netbox/project-static/styles/theme-dark.scss
index b86acc17a..c0933e991 100644
--- a/netbox/project-static/styles/theme-dark.scss
+++ b/netbox/project-static/styles/theme-dark.scss
@@ -145,9 +145,9 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg
$nav-pills-link-active-color: $component-active-color;
$nav-pills-link-active-bg: $component-active-bg;
-$navbar-light-color: $navbar-dark-color;
-$navbar-light-toggler-icon-bg: url("data:image/svg+xml, ");
+$navbar-light-color: $darker;
$navbar-light-toggler-border-color: $gray-700;
+$navbar-light-toggler-icon-bg: url("data:image/svg+xml, ");
// Dropdowns
$dropdown-color: $body-color;
diff --git a/netbox/templates/dcim/device/status.html b/netbox/templates/dcim/device/status.html
index 8cbc0979d..a668ebf1e 100644
--- a/netbox/templates/dcim/device/status.html
+++ b/netbox/templates/dcim/device/status.html
@@ -37,9 +37,7 @@
Serial Number
-
-
-
+
OS Version
diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html
index 1ba3d05c9..c9f3f0d4a 100644
--- a/netbox/templates/dcim/inc/cable_termination.html
+++ b/netbox/templates/dcim/inc/cable_termination.html
@@ -8,6 +8,22 @@
{{ termination.device }}
+ {% if termination.device.site %}
+
+ Site
+
+ {{ termination.device.site }}
+
+
+ {% endif %}
+ {% if termination.device.rack %}
+
+ Rack
+
+ {{ termination.device.rack }}
+
+
+ {% endif %}
Type
diff --git a/netbox/templates/dcim/inc/nonracked_devices.html b/netbox/templates/dcim/inc/nonracked_devices.html
new file mode 100644
index 000000000..f1b669eb9
--- /dev/null
+++ b/netbox/templates/dcim/inc/nonracked_devices.html
@@ -0,0 +1,62 @@
+{% load helpers %}
+
+
+
+
+{% if nonracked_devices %}
+
+
+ Name
+ Role
+ Type
+ Parent Device
+
+ {% for device in nonracked_devices %}
+
+
+ {{ device }}
+
+ {{ device.device_role }}
+ {{ device.device_type }}
+ {% if device.parent_bay %}
+ {{ device.parent_bay.device }}
+ {{ device.parent_bay }}
+ {% else %}
+ —
+ {% endif %}
+
+ {% endfor %}
+
+ {% else %}
+
+ None
+
+ {% endif %}
+
+ {% if perms.dcim.add_device %}
+ {% if object|meta:'verbose_name' == 'rack' %}
+
+ {% elif object|meta:'verbose_name' == 'site' %}
+
+ {% elif object|meta:'verbose_name' == 'location' %}
+
+ {% endif %}
+ {% endif %}
+
\ No newline at end of file
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html
index b684385a7..43bbfd114 100644
--- a/netbox/templates/dcim/location.html
+++ b/netbox/templates/dcim/location.html
@@ -90,6 +90,7 @@
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
+ {% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html
index 6e0da812d..aa8d3a94f 100644
--- a/netbox/templates/dcim/rack.html
+++ b/netbox/templates/dcim/rack.html
@@ -286,50 +286,7 @@
-
-
-
- {% if nonracked_devices %}
-
-
- Name
- Role
- Type
- Parent Device
-
- {% for device in nonracked_devices %}
-
-
- {{ device }}
-
- {{ device.device_role }}
- {{ device.device_type }}
- {% if device.parent_bay %}
- {{ device.parent_bay.device }}
- {{ device.parent_bay }}
- {% else %}
- —
- {% endif %}
-
- {% endfor %}
-
- {% else %}
-
- None
-
- {% endif %}
-
- {% if perms.dcim.add_device %}
-
- {% endif %}
-
+ {% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html
index 236c8e1c7..41255b4a0 100644
--- a/netbox/templates/dcim/site.html
+++ b/netbox/templates/dcim/site.html
@@ -225,6 +225,7 @@
+ {% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/contacts.html' %}
diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py
index a29e08863..03f3fdf6d 100644
--- a/netbox/tenancy/filtersets.py
+++ b/netbox/tenancy/filtersets.py
@@ -10,6 +10,7 @@ __all__ = (
'ContactAssignmentFilterSet',
'ContactFilterSet',
'ContactGroupFilterSet',
+ 'ContactModelFilterSet',
'ContactRoleFilterSet',
'TenancyFilterSet',
'TenantFilterSet',
@@ -17,86 +18,6 @@ __all__ = (
)
-#
-# Tenancy
-#
-
-class TenantGroupFilterSet(OrganizationalModelFilterSet):
- parent_id = django_filters.ModelMultipleChoiceFilter(
- queryset=TenantGroup.objects.all(),
- label='Tenant group (ID)',
- )
- parent = django_filters.ModelMultipleChoiceFilter(
- field_name='parent__slug',
- queryset=TenantGroup.objects.all(),
- to_field_name='slug',
- label='Tenant group (slug)',
- )
-
- class Meta:
- model = TenantGroup
- fields = ['id', 'name', 'slug', 'description']
-
-
-class TenantFilterSet(NetBoxModelFilterSet):
- group_id = TreeNodeMultipleChoiceFilter(
- queryset=TenantGroup.objects.all(),
- field_name='group',
- lookup_expr='in',
- label='Tenant group (ID)',
- )
- group = TreeNodeMultipleChoiceFilter(
- queryset=TenantGroup.objects.all(),
- field_name='group',
- lookup_expr='in',
- to_field_name='slug',
- label='Tenant group (slug)',
- )
-
- class Meta:
- model = Tenant
- fields = ['id', 'name', 'slug', 'description']
-
- def search(self, queryset, name, value):
- if not value.strip():
- return queryset
- return queryset.filter(
- Q(name__icontains=value) |
- Q(slug__icontains=value) |
- Q(description__icontains=value) |
- Q(comments__icontains=value)
- )
-
-
-class TenancyFilterSet(django_filters.FilterSet):
- """
- An inheritable FilterSet for models which support Tenant assignment.
- """
- tenant_group_id = TreeNodeMultipleChoiceFilter(
- queryset=TenantGroup.objects.all(),
- field_name='tenant__group',
- lookup_expr='in',
- label='Tenant Group (ID)',
- )
- tenant_group = TreeNodeMultipleChoiceFilter(
- queryset=TenantGroup.objects.all(),
- field_name='tenant__group',
- to_field_name='slug',
- lookup_expr='in',
- label='Tenant Group (slug)',
- )
- tenant_id = django_filters.ModelMultipleChoiceFilter(
- queryset=Tenant.objects.all(),
- label='Tenant (ID)',
- )
- tenant = django_filters.ModelMultipleChoiceFilter(
- queryset=Tenant.objects.all(),
- field_name='tenant__slug',
- to_field_name='slug',
- label='Tenant (slug)',
- )
-
-
#
# Contacts
#
@@ -177,3 +98,96 @@ class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = ContactAssignment
fields = ['id', 'content_type_id', 'object_id', 'priority']
+
+
+class ContactModelFilterSet(django_filters.FilterSet):
+ contact = django_filters.ModelMultipleChoiceFilter(
+ field_name='contacts__contact',
+ queryset=Contact.objects.all(),
+ label='Contact',
+ )
+ contact_role = django_filters.ModelMultipleChoiceFilter(
+ field_name='contacts__role',
+ queryset=ContactRole.objects.all(),
+ label='Contact Role'
+ )
+
+
+#
+# Tenancy
+#
+
+class TenantGroupFilterSet(OrganizationalModelFilterSet):
+ parent_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=TenantGroup.objects.all(),
+ label='Tenant group (ID)',
+ )
+ parent = django_filters.ModelMultipleChoiceFilter(
+ field_name='parent__slug',
+ queryset=TenantGroup.objects.all(),
+ to_field_name='slug',
+ label='Tenant group (slug)',
+ )
+
+ class Meta:
+ model = TenantGroup
+ fields = ['id', 'name', 'slug', 'description']
+
+
+class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
+ group_id = TreeNodeMultipleChoiceFilter(
+ queryset=TenantGroup.objects.all(),
+ field_name='group',
+ lookup_expr='in',
+ label='Tenant group (ID)',
+ )
+ group = TreeNodeMultipleChoiceFilter(
+ queryset=TenantGroup.objects.all(),
+ field_name='group',
+ lookup_expr='in',
+ to_field_name='slug',
+ label='Tenant group (slug)',
+ )
+
+ class Meta:
+ model = Tenant
+ fields = ['id', 'name', 'slug', 'description']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(slug__icontains=value) |
+ Q(description__icontains=value) |
+ Q(comments__icontains=value)
+ )
+
+
+class TenancyFilterSet(django_filters.FilterSet):
+ """
+ An inheritable FilterSet for models which support Tenant assignment.
+ """
+ tenant_group_id = TreeNodeMultipleChoiceFilter(
+ queryset=TenantGroup.objects.all(),
+ field_name='tenant__group',
+ lookup_expr='in',
+ label='Tenant Group (ID)',
+ )
+ tenant_group = TreeNodeMultipleChoiceFilter(
+ queryset=TenantGroup.objects.all(),
+ field_name='tenant__group',
+ to_field_name='slug',
+ lookup_expr='in',
+ label='Tenant Group (slug)',
+ )
+ tenant_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=Tenant.objects.all(),
+ label='Tenant (ID)',
+ )
+ tenant = django_filters.ModelMultipleChoiceFilter(
+ queryset=Tenant.objects.all(),
+ field_name='tenant__slug',
+ to_field_name='slug',
+ label='Tenant (slug)',
+ )
diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py
index 73e30cc77..15d7773b7 100644
--- a/netbox/tenancy/forms/filtersets.py
+++ b/netbox/tenancy/forms/filtersets.py
@@ -2,6 +2,7 @@ from django.utils.translation import gettext as _
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.models import *
+from tenancy.forms import ContactModelFilterForm
from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField
__all__ = (
@@ -27,8 +28,12 @@ class TenantGroupFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class TenantFilterForm(NetBoxModelFilterSetForm):
+class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Tenant
+ fieldsets = (
+ (None, ('q', 'tag', 'group_id')),
+ ('Contacts', ('contact', 'contact_role'))
+ )
group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py
index 9a3d00e05..5dcad1d43 100644
--- a/netbox/tenancy/forms/forms.py
+++ b/netbox/tenancy/forms/forms.py
@@ -1,10 +1,11 @@
from django import forms
from django.utils.translation import gettext as _
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
__all__ = (
+ 'ContactModelFilterForm',
'TenancyForm',
'TenancyFilterForm',
)
@@ -44,3 +45,16 @@ class TenancyFilterForm(forms.Form):
},
label=_('Tenant')
)
+
+
+class ContactModelFilterForm(forms.Form):
+ contact = DynamicModelMultipleChoiceField(
+ queryset=Contact.objects.all(),
+ required=False,
+ label=_('Contact')
+ )
+ contact_role = DynamicModelMultipleChoiceField(
+ queryset=ContactRole.objects.all(),
+ required=False,
+ label=_('Contact Role')
+ )
diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py
index 81dd99773..50b75ada7 100644
--- a/netbox/tenancy/models/contacts.py
+++ b/netbox/tenancy/models/contacts.py
@@ -162,3 +162,6 @@ class ContactAssignment(WebhooksMixin, ChangeLoggedModel):
if self.priority:
return f"{self.contact} ({self.get_priority_display()})"
return str(self.contact)
+
+ def get_absolute_url(self):
+ return reverse('tenancy:contact', args=[self.contact.pk])
diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py
index a5931052f..5577d90e0 100644
--- a/netbox/tenancy/tables/tenants.py
+++ b/netbox/tenancy/tables/tenants.py
@@ -38,11 +38,17 @@ class TenantTable(NetBoxTable):
linkify=True
)
comments = columns.MarkdownColumn()
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='tenancy:tenant_list'
)
class Meta(NetBoxTable.Meta):
model = Tenant
- fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'created', 'last_updated',)
+ fields = (
+ 'pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'contacts', 'tags', 'created',
+ 'last_updated',
+ )
default_columns = ('pk', 'name', 'group', 'description')
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py
index 70effe863..5a2aa8b42 100644
--- a/netbox/virtualization/filtersets.py
+++ b/netbox/virtualization/filtersets.py
@@ -5,7 +5,7 @@ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import VRF
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
-from tenancy.filtersets import TenancyFilterSet
+from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -26,14 +26,14 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'description']
-class ClusterGroupFilterSet(OrganizationalModelFilterSet):
+class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
class Meta:
model = ClusterGroup
fields = ['id', 'name', 'slug', 'description']
-class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
@@ -104,7 +104,12 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
)
-class VirtualMachineFilterSet(NetBoxModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
+class VirtualMachineFilterSet(
+ NetBoxModelFilterSet,
+ TenancyFilterSet,
+ ContactModelFilterSet,
+ LocalConfigContextFilterSet
+):
status = django_filters.MultipleChoiceFilter(
choices=VirtualMachineStatusChoices,
null_value=None
diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py
index 7702a23ae..e8ba79cc8 100644
--- a/netbox/virtualization/forms/filtersets.py
+++ b/netbox/virtualization/forms/filtersets.py
@@ -5,7 +5,7 @@ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from extras.forms import LocalConfigContextFilterForm
from ipam.models import VRF
from netbox.forms import NetBoxModelFilterSetForm
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import (
DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
@@ -26,18 +26,19 @@ class ClusterTypeFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class ClusterGroupFilterForm(NetBoxModelFilterSetForm):
+class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = ClusterGroup
tag = TagFilterField(model)
-class ClusterFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Cluster
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('group_id', 'type_id')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
+ ('Contacts', ('contact', 'contact_role')),
)
type_id = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(),
@@ -73,7 +74,12 @@ class ClusterFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
+class VirtualMachineFilterForm(
+ LocalConfigContextFilterForm,
+ TenancyFilterForm,
+ ContactModelFilterForm,
+ NetBoxModelFilterSetForm
+):
model = VirtualMachine
fieldsets = (
(None, ('q', 'tag')),
@@ -81,6 +87,7 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm,
('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')),
)
cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py
index ecc0d69ce..893d3c641 100644
--- a/netbox/virtualization/tables/clusters.py
+++ b/netbox/virtualization/tables/clusters.py
@@ -36,6 +36,9 @@ class ClusterGroupTable(NetBoxTable):
cluster_count = tables.Column(
verbose_name='Clusters'
)
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='virtualization:clustergroup_list'
)
@@ -43,7 +46,8 @@ class ClusterGroupTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ClusterGroup
fields = (
- 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'created', 'last_updated', 'actions',
+ 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'contacts', 'tags', 'created', 'last_updated',
+ 'actions',
)
default_columns = ('pk', 'name', 'cluster_count', 'description')
@@ -75,6 +79,9 @@ class ClusterTable(NetBoxTable):
verbose_name='VMs'
)
comments = columns.MarkdownColumn()
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='virtualization:cluster_list'
)
@@ -82,7 +89,7 @@ class ClusterTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Cluster
fields = (
- 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags',
- 'created', 'last_updated',
+ 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts',
+ 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py
index b0922ce88..d5017eb53 100644
--- a/netbox/virtualization/tables/virtualmachines.py
+++ b/netbox/virtualization/tables/virtualmachines.py
@@ -78,6 +78,9 @@ class VMInterfaceTable(BaseInterfaceTable):
vrf = tables.Column(
linkify=True
)
+ contacts = tables.ManyToManyColumn(
+ linkify_item=True
+ )
tags = columns.TagColumn(
url_name='virtualization:vminterface_list'
)
@@ -86,7 +89,8 @@ class VMInterfaceTable(BaseInterfaceTable):
model = VMInterface
fields = (
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
- 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
+ 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'contacts', 'created',
+ 'last_updated',
)
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')