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 %} + +
+
+ Non-Racked Devices +
+
+{% if nonracked_devices %} + + + + + + + + {% for device in nonracked_devices %} + + + + + {% if device.parent_bay %} + + + {% else %} + + {% endif %} + + {% endfor %} +
NameRoleTypeParent Device
+ {{ device }} + {{ device.device_role }}{{ device.device_type }}{{ device.parent_bay.device }}{{ device.parent_bay }}
+ {% 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 @@ -
-
- Non-Racked Devices -
-
- {% if nonracked_devices %} - - - - - - - - {% for device in nonracked_devices %} - - - - - {% if device.parent_bay %} - - - {% else %} - - {% endif %} - - {% endfor %} -
NameRoleTypeParent Device
- {{ device }} - {{ device.device_role }}{{ device.device_type }}{{ device.parent_bay.device }}{{ device.parent_bay }}
- {% 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' %}
Locations
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')