Просмотр исходного кода

Merge branch 'develop' into feature

jeremystretch 3 лет назад
Родитель
Сommit
8d53b46e82
38 измененных файлов с 606 добавлено и 385 удалено
  1. 1 1
      .github/workflows/ci.yml
  2. 1 1
      base_requirements.txt
  3. 12 0
      docs/release-notes/version-3.1.md
  4. 4 4
      netbox/circuits/filtersets.py
  5. 5 3
      netbox/circuits/forms/filtersets.py
  6. 4 1
      netbox/circuits/tables/circuits.py
  7. 4 1
      netbox/circuits/tables/providers.py
  8. 3 3
      netbox/dcim/api/serializers.py
  9. 10 10
      netbox/dcim/filtersets.py
  10. 32 10
      netbox/dcim/forms/filtersets.py
  11. 13 1
      netbox/dcim/tables/cables.py
  12. 5 2
      netbox/dcim/tables/devices.py
  13. 4 1
      netbox/dcim/tables/devicetypes.py
  14. 4 1
      netbox/dcim/tables/power.py
  15. 4 1
      netbox/dcim/tables/racks.py
  16. 19 5
      netbox/dcim/tables/sites.py
  17. 12 0
      netbox/dcim/views.py
  18. 1 1
      netbox/ipam/filtersets.py
  19. 1 1
      netbox/ipam/forms/bulk_import.py
  20. 207 163
      netbox/netbox/constants.py
  21. 17 32
      netbox/netbox/forms/__init__.py
  22. 0 0
      netbox/project-static/dist/netbox-dark.css
  23. 2 2
      netbox/project-static/styles/theme-dark.scss
  24. 1 3
      netbox/templates/dcim/device/status.html
  25. 16 0
      netbox/templates/dcim/inc/cable_termination.html
  26. 62 0
      netbox/templates/dcim/inc/nonracked_devices.html
  27. 1 0
      netbox/templates/dcim/location.html
  28. 1 44
      netbox/templates/dcim/rack.html
  29. 1 0
      netbox/templates/dcim/site.html
  30. 93 79
      netbox/tenancy/filtersets.py
  31. 6 1
      netbox/tenancy/forms/filtersets.py
  32. 15 1
      netbox/tenancy/forms/forms.py
  33. 3 0
      netbox/tenancy/models/contacts.py
  34. 7 1
      netbox/tenancy/tables/tenants.py
  35. 9 4
      netbox/virtualization/filtersets.py
  36. 11 4
      netbox/virtualization/forms/filtersets.py
  37. 10 3
      netbox/virtualization/tables/clusters.py
  38. 5 1
      netbox/virtualization/tables/virtualmachines.py

+ 1 - 1
.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

+ 1 - 1
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

+ 12 - 0
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)

+ 4 - 4
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)',

+ 5 - 3
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(),

+ 4 - 1
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',

+ 4 - 1
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')
 

+ 3 - 3
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)

+ 10 - 10
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',

+ 32 - 10
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(),

+ 13 - 1
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 = (

+ 5 - 2
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',

+ 4 - 1
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',

+ 4 - 1
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')
 
 

+ 4 - 1
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',

+ 19 - 5
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')

+ 12 - 0
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,
         }
 
 

+ 1 - 1
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(),

+ 1 - 1
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',
         }
 

+ 207 - 163
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,
+
+CIRCUIT_TYPES = OrderedDict(
+    (
+        ('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_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',
-                'device_count',
+                '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',
             ),
-            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': 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()

+ 17 - 32
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():

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


+ 2 - 2
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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
+$navbar-light-color: $darker;
 $navbar-light-toggler-border-color: $gray-700;
+$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-toggler-border-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
 
 // Dropdowns
 $dropdown-color: $body-color;

+ 1 - 3
netbox/templates/dcim/device/status.html

@@ -37,9 +37,7 @@
                         </tr>
                         <tr>
                             <th scope="row">Serial Number</th>
-                            <td>
-                                <code id="serial_number"></code>
-                            </td>
+                            <td id="serial_number" class="text-monospace"></td>
                         </tr>
                         <tr>
                             <th scope="row">OS Version</th>

+ 16 - 0
netbox/templates/dcim/inc/cable_termination.html

@@ -8,6 +8,22 @@
                 <a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
             </td>
         </tr>
+        {% if termination.device.site %}
+        <tr>
+            <td>Site</td>
+            <td>
+                <a href="{{ termination.device.site.get_absolute_url }}">{{ termination.device.site }}</a>
+            </td>
+        </tr>
+        {% endif %}
+        {% if termination.device.rack %}
+        <tr>
+            <td>Rack</td>
+            <td>
+                <a href="{{ termination.device.rack.get_absolute_url }}">{{ termination.device.rack }}</a>
+            </td>
+        </tr>
+        {% endif %}
         <tr>
             <td>Type</td>
             <td>

+ 62 - 0
netbox/templates/dcim/inc/nonracked_devices.html

@@ -0,0 +1,62 @@
+{% load helpers %}
+
+<div class="card">
+<h5 class="card-header">
+    Non-Racked Devices
+</h5>
+<div class="card-body">
+{% if nonracked_devices %}
+    <table class="table table-hover">
+        <tr>
+            <th>Name</th>
+            <th>Role</th>
+            <th>Type</th>
+            <th colspan="2">Parent Device</th>
+        </tr>
+        {% for device in nonracked_devices %}
+        <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
+            <td>
+                <a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
+            </td>
+            <td>{{ device.device_role }}</td>
+            <td>{{ device.device_type }}</td>
+            {% if device.parent_bay %}
+                <td><a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay.device }}</a></td>
+                <td>{{ device.parent_bay }}</td>
+            {% else %}
+                <td colspan="2" class="text-muted">&mdash;</td>
+            {% endif %}
+        </tr>
+        {% endfor %}
+    </table>
+    {% else %}
+        <div class="text-muted">
+            None
+        </div>
+    {% endif %}
+    </div>
+    {% if perms.dcim.add_device %}
+        {% if object|meta:'verbose_name' == 'rack' %}
+        <div class="card-footer text-end noprint">
+            <a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&rack={{ object.pk }}" class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
+                Add a Non-Racked Device
+            </a>
+        </div>
+        {% elif object|meta:'verbose_name' == 'site' %}
+        <div class="card-footer text-end noprint">
+            <a href="{% url 'dcim:device_add' %}?site={{ object.pk }}" class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
+                Add a Non-Racked Device
+            </a>
+        </div>
+        {% elif object|meta:'verbose_name' == 'location' %}
+        <div class="card-footer text-end noprint">
+            <a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&location={{ object.pk }}" class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
+                Add a Non-Racked Device
+            </a>
+        </div>
+        {% endif %}
+    {% endif %}
+</div>

+ 1 - 0
netbox/templates/dcim/location.html

@@ -90,6 +90,7 @@
 	<div class="col col-md-6">
     {% 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 %}
 	</div>

+ 1 - 44
netbox/templates/dcim/rack.html

@@ -286,50 +286,7 @@
               </div>
             </div>
         </div>
-        <div class="card">
-            <h5 class="card-header">
-                Non-Racked Devices
-            </h5>
-            <div class="card-body">
-            {% if nonracked_devices %}
-                <table class="table table-hover">
-                    <tr>
-                        <th>Name</th>
-                        <th>Role</th>
-                        <th>Type</th>
-                        <th colspan="2">Parent Device</th>
-                    </tr>
-                    {% for device in nonracked_devices %}
-                    <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
-                        <td>
-                            <a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
-                        </td>
-                        <td>{{ device.device_role }}</td>
-                        <td>{{ device.device_type }}</td>
-                        {% if device.parent_bay %}
-                            <td><a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay.device }}</a></td>
-                            <td>{{ device.parent_bay }}</td>
-                        {% else %}
-                            <td colspan="2" class="text-muted">&mdash;</td>
-                        {% endif %}
-                    </tr>
-                    {% endfor %}
-                </table>
-            {% else %}
-                <div class="text-muted">
-                    None
-                </div>
-            {% endif %}
-            </div>
-            {% if perms.dcim.add_device %}
-                <div class="card-footer text-end noprint">
-                    <a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&rack={{ object.pk }}" class="btn btn-primary btn-sm">
-                        <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
-                        Add a Non-Racked Device
-                    </a>
-                </div>
-            {% endif %}
-        </div>
+        {% include 'dcim/inc/nonracked_devices.html' %}
         {% include 'inc/panels/contacts.html' %}
         {% plugin_right_page object %}
     </div>

+ 1 - 0
netbox/templates/dcim/site.html

@@ -225,6 +225,7 @@
           </table>
         </div>
       </div>
+      {% include 'dcim/inc/nonracked_devices.html' %}
       {% include 'inc/panels/contacts.html' %}
       <div class="card">
         <h5 class="card-header">Locations</h5>

+ 93 - 79
netbox/tenancy/filtersets.py

@@ -10,6 +10,7 @@ __all__ = (
     'ContactAssignmentFilterSet',
     'ContactFilterSet',
     'ContactGroupFilterSet',
+    'ContactModelFilterSet',
     'ContactRoleFilterSet',
     'TenancyFilterSet',
     'TenantFilterSet',
@@ -18,162 +19,175 @@ __all__ = (
 
 
 #
-# Tenancy
+# Contacts
 #
 
-class TenantGroupFilterSet(OrganizationalModelFilterSet):
+class ContactGroupFilterSet(OrganizationalModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
-        label='Tenant group (ID)',
+        queryset=ContactGroup.objects.all(),
+        label='Contact group (ID)',
     )
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
-        queryset=TenantGroup.objects.all(),
+        queryset=ContactGroup.objects.all(),
         to_field_name='slug',
-        label='Tenant group (slug)',
+        label='Contact group (slug)',
     )
 
     class Meta:
-        model = TenantGroup
+        model = ContactGroup
+        fields = ['id', 'name', 'slug', 'description']
+
+
+class ContactRoleFilterSet(OrganizationalModelFilterSet):
+
+    class Meta:
+        model = ContactRole
         fields = ['id', 'name', 'slug', 'description']
 
 
-class TenantFilterSet(NetBoxModelFilterSet):
+class ContactFilterSet(NetBoxModelFilterSet):
     group_id = TreeNodeMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
+        queryset=ContactGroup.objects.all(),
         field_name='group',
         lookup_expr='in',
-        label='Tenant group (ID)',
+        label='Contact group (ID)',
     )
     group = TreeNodeMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
+        queryset=ContactGroup.objects.all(),
         field_name='group',
         lookup_expr='in',
         to_field_name='slug',
-        label='Tenant group (slug)',
+        label='Contact group (slug)',
     )
 
     class Meta:
-        model = Tenant
-        fields = ['id', 'name', 'slug', 'description']
+        model = Contact
+        fields = ['id', 'name', 'title', 'phone', 'email', 'address']
 
     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(title__icontains=value) |
+            Q(phone__icontains=value) |
+            Q(email__icontains=value) |
+            Q(address__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)',
+class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet):
+    content_type = ContentTypeFilter()
+    contact_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Contact.objects.all(),
+        label='Contact (ID)',
     )
-    tenant_group = TreeNodeMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
-        field_name='tenant__group',
+    role_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ContactRole.objects.all(),
+        label='Contact role (ID)',
+    )
+    role = django_filters.ModelMultipleChoiceFilter(
+        field_name='role__slug',
+        queryset=ContactRole.objects.all(),
         to_field_name='slug',
-        lookup_expr='in',
-        label='Tenant Group (slug)',
+        label='Contact role (slug)',
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        label='Tenant (ID)',
+
+    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',
     )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        field_name='tenant__slug',
-        to_field_name='slug',
-        label='Tenant (slug)',
+    contact_role = django_filters.ModelMultipleChoiceFilter(
+        field_name='contacts__role',
+        queryset=ContactRole.objects.all(),
+        label='Contact Role'
     )
 
 
 #
-# Contacts
+# Tenancy
 #
 
-class ContactGroupFilterSet(OrganizationalModelFilterSet):
+class TenantGroupFilterSet(OrganizationalModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=ContactGroup.objects.all(),
-        label='Contact group (ID)',
+        queryset=TenantGroup.objects.all(),
+        label='Tenant group (ID)',
     )
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
-        queryset=ContactGroup.objects.all(),
+        queryset=TenantGroup.objects.all(),
         to_field_name='slug',
-        label='Contact group (slug)',
+        label='Tenant group (slug)',
     )
 
     class Meta:
-        model = ContactGroup
-        fields = ['id', 'name', 'slug', 'description']
-
-
-class ContactRoleFilterSet(OrganizationalModelFilterSet):
-
-    class Meta:
-        model = ContactRole
+        model = TenantGroup
         fields = ['id', 'name', 'slug', 'description']
 
 
-class ContactFilterSet(NetBoxModelFilterSet):
+class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
     group_id = TreeNodeMultipleChoiceFilter(
-        queryset=ContactGroup.objects.all(),
+        queryset=TenantGroup.objects.all(),
         field_name='group',
         lookup_expr='in',
-        label='Contact group (ID)',
+        label='Tenant group (ID)',
     )
     group = TreeNodeMultipleChoiceFilter(
-        queryset=ContactGroup.objects.all(),
+        queryset=TenantGroup.objects.all(),
         field_name='group',
         lookup_expr='in',
         to_field_name='slug',
-        label='Contact group (slug)',
+        label='Tenant group (slug)',
     )
 
     class Meta:
-        model = Contact
-        fields = ['id', 'name', 'title', 'phone', 'email', 'address']
+        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(title__icontains=value) |
-            Q(phone__icontains=value) |
-            Q(email__icontains=value) |
-            Q(address__icontains=value) |
+            Q(slug__icontains=value) |
+            Q(description__icontains=value) |
             Q(comments__icontains=value)
         )
 
 
-class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet):
-    content_type = ContentTypeFilter()
-    contact_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Contact.objects.all(),
-        label='Contact (ID)',
+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)',
     )
-    role_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=ContactRole.objects.all(),
-        label='Contact role (ID)',
+    tenant_group = TreeNodeMultipleChoiceFilter(
+        queryset=TenantGroup.objects.all(),
+        field_name='tenant__group',
+        to_field_name='slug',
+        lookup_expr='in',
+        label='Tenant Group (slug)',
     )
-    role = django_filters.ModelMultipleChoiceFilter(
-        field_name='role__slug',
-        queryset=ContactRole.objects.all(),
+    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='Contact role (slug)',
+        label='Tenant (slug)',
     )
-
-    class Meta:
-        model = ContactAssignment
-        fields = ['id', 'content_type_id', 'object_id', 'priority']

+ 6 - 1
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,

+ 15 - 1
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')
+    )

+ 3 - 0
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])

+ 7 - 1
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')

+ 9 - 4
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

+ 11 - 4
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(),

+ 10 - 3
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')

+ 5 - 1
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')
 

Некоторые файлы не были показаны из-за большого количества измененных файлов