Ver Fonte

Merge branch 'develop' into feature

jeremystretch há 3 anos atrás
pai
commit
8d53b46e82
38 ficheiros alterados com 606 adições e 385 exclusões
  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: |
       run: |
         python -m pip install --upgrade pip
         python -m pip install --upgrade pip
         pip install -r requirements.txt
         pip install -r requirements.txt
-        pip install pycodestyle coverage
+        pip install pycodestyle coverage tblib
 
 
     - name: Build documentation
     - name: Build documentation
       run: mkdocs build
       run: mkdocs build

+ 1 - 1
base_requirements.txt

@@ -87,7 +87,7 @@ mkdocs-material
 mkdocstrings
 mkdocstrings
 
 
 # Library for manipulating IP prefixes and addresses
 # Library for manipulating IP prefixes and addresses
-# https://github.com/drkjam/netaddr
+# https://github.com/netaddr/netaddr
 netaddr
 netaddr
 
 
 # Fork of PIL (Python Imaging Library) for image processing
 # 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)
 ## 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)
 ## 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.filtersets import CableTerminationFilterSet
 from dcim.models import Region, Site, SiteGroup
 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 utilities.filters import TreeNodeMultipleChoiceFilter
 from .choices import *
 from .choices import *
 from .models import *
 from .models import *
@@ -18,7 +18,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ProviderFilterSet(NetBoxModelFilterSet):
+class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='circuits__terminations__site__region',
         field_name='circuits__terminations__site__region',
@@ -107,7 +107,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     provider_id = django_filters.ModelMultipleChoiceFilter(
     provider_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         label='Provider (ID)',
         label='Provider (ID)',

+ 5 - 3
netbox/circuits/forms/filtersets.py

@@ -5,7 +5,7 @@ from circuits.choices import CircuitStatusChoices
 from circuits.models import *
 from circuits.models import *
 from dcim.models import Region, Site, SiteGroup
 from dcim.models import Region, Site, SiteGroup
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
 from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
 from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
 
 
 __all__ = (
 __all__ = (
@@ -16,12 +16,13 @@ __all__ = (
 )
 )
 
 
 
 
-class ProviderFilterForm(NetBoxModelFilterSetForm):
+class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Provider
     model = Provider
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('ASN', ('asn',)),
         ('ASN', ('asn',)),
+        ('Contacts', ('contact', 'contact_role')),
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -72,7 +73,7 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class CircuitFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Circuit
     model = Circuit
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
@@ -80,6 +81,7 @@ class CircuitFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         ('Attributes', ('type_id', 'status', 'commit_rate')),
         ('Attributes', ('type_id', 'status', 'commit_rate')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
+        ('Contacts', ('contact', 'contact_role')),
     )
     )
     type_id = DynamicModelMultipleChoiceField(
     type_id = DynamicModelMultipleChoiceField(
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),

+ 4 - 1
netbox/circuits/tables/circuits.py

@@ -59,6 +59,9 @@ class CircuitTable(NetBoxTable):
     )
     )
     commit_rate = CommitRateColumn()
     commit_rate = CommitRateColumn()
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='circuits:circuit_list'
         url_name='circuits:circuit_list'
     )
     )
@@ -67,7 +70,7 @@ class CircuitTable(NetBoxTable):
         model = Circuit
         model = Circuit
         fields = (
         fields = (
             'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
             '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 = (
         default_columns = (
             'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
             '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'
         verbose_name='Circuits'
     )
     )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='circuits:provider_list'
         url_name='circuits:provider_list'
     )
     )
@@ -27,7 +30,7 @@ class ProviderTable(NetBoxTable):
         model = Provider
         model = Provider
         fields = (
         fields = (
             'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
             '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')
         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):
     class Meta(DeviceSerializer.Meta):
         fields = [
         fields = [
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             '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)
     @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 (
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
     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.choices import ColorChoices
 from utilities.filters import (
 from utilities.filters import (
     ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
     ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
@@ -67,7 +67,7 @@ __all__ = (
 )
 )
 
 
 
 
-class RegionFilterSet(OrganizationalModelFilterSet):
+class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         label='Parent region (ID)',
         label='Parent region (ID)',
@@ -84,7 +84,7 @@ class RegionFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class SiteGroupFilterSet(OrganizationalModelFilterSet):
+class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         label='Parent site group (ID)',
         label='Parent site group (ID)',
@@ -101,7 +101,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=SiteStatusChoices,
         choices=SiteStatusChoices,
         null_value=None
         null_value=None
@@ -166,7 +166,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet):
+class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='site__region',
         field_name='site__region',
@@ -237,7 +237,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'color', 'description']
         fields = ['id', 'name', 'slug', 'color', 'description']
 
 
 
 
-class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='site__region',
         field_name='site__region',
@@ -385,7 +385,7 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         )
         )
 
 
 
 
-class ManufacturerFilterSet(OrganizationalModelFilterSet):
+class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
@@ -724,7 +724,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
         fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
 
 
 
 
-class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
+class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='device_type__manufacturer',
         field_name='device_type__manufacturer',
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -1514,7 +1514,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         return queryset
         return queryset
 
 
 
 
-class PowerPanelFilterSet(NetBoxModelFilterSet):
+class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='site__region',
         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 extras.forms import LocalConfigContextFilterForm
 from ipam.models import ASN, VRF
 from ipam.models import ASN, VRF
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from utilities.forms import (
 from utilities.forms import (
     APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
     APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
     StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget,
     StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget,
@@ -104,8 +104,12 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
     )
     )
 
 
 
 
-class RegionFilterForm(NetBoxModelFilterSetForm):
+class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Region
     model = Region
+    fieldsets = (
+        (None, ('q', 'tag', 'parent_id')),
+        ('Contacts', ('contact', 'contact_role'))
+    )
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -114,8 +118,12 @@ class RegionFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class SiteGroupFilterForm(NetBoxModelFilterSetForm):
+class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = SiteGroup
     model = SiteGroup
+    fieldsets = (
+        (None, ('q', 'tag', 'parent_id')),
+        ('Contacts', ('contact', 'contact_role'))
+    )
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         required=False,
         required=False,
@@ -124,12 +132,13 @@ class SiteGroupFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class SiteFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Site
     model = Site
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
         ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
         ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
+        ('Contacts', ('contact', 'contact_role')),
     )
     )
     status = forms.MultipleChoiceField(
     status = forms.MultipleChoiceField(
         choices=SiteStatusChoices,
         choices=SiteStatusChoices,
@@ -154,12 +163,13 @@ class SiteFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class LocationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Location
     model = Location
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
         ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
         ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
+        ('Contacts', ('contact', 'contact_role')),
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -197,7 +207,7 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class RackFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Rack
     model = Rack
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
@@ -205,6 +215,7 @@ class RackFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         ('Function', ('status', 'role_id')),
         ('Function', ('status', 'role_id')),
         ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
         ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
+        ('Contacts', ('contact', 'contact_role')),
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -308,8 +319,12 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ManufacturerFilterForm(NetBoxModelFilterSetForm):
+class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Manufacturer
     model = Manufacturer
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Contacts', ('contact', 'contact_role'))
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
@@ -465,7 +480,12 @@ class PlatformFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
+class DeviceFilterForm(
+    LocalConfigContextFilterForm,
+    TenancyFilterForm,
+    ContactModelFilterForm,
+    NetBoxModelFilterSetForm
+):
     model = Device
     model = Device
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
@@ -473,6 +493,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
         ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
         ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
         ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
         ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
+        ('Contacts', ('contact', 'contact_role')),
         ('Components', (
         ('Components', (
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
         )),
         )),
@@ -741,11 +762,12 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class PowerPanelFilterForm(NetBoxModelFilterSetForm):
+class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = PowerPanel
     model = PowerPanel
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (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(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),

+ 13 - 1
netbox/dcim/tables/cables.py

@@ -22,6 +22,12 @@ class CableTable(NetBoxTable):
         orderable=False,
         orderable=False,
         verbose_name='Side A'
         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(
     termination_a = tables.Column(
         accessor=Accessor('termination_a'),
         accessor=Accessor('termination_a'),
         orderable=False,
         orderable=False,
@@ -34,6 +40,12 @@ class CableTable(NetBoxTable):
         orderable=False,
         orderable=False,
         verbose_name='Side B'
         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(
     termination_b = tables.Column(
         accessor=Accessor('termination_b'),
         accessor=Accessor('termination_b'),
         orderable=False,
         orderable=False,
@@ -54,7 +66,7 @@ class CableTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Cable
         model = Cable
         fields = (
         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',
             'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (

+ 5 - 2
netbox/dcim/tables/devices.py

@@ -190,6 +190,9 @@ class DeviceTable(NetBoxTable):
         verbose_name='VC Priority'
         verbose_name='VC Priority'
     )
     )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:device_list'
         url_name='dcim:device_list'
     )
     )
@@ -199,8 +202,8 @@ class DeviceTable(NetBoxTable):
         fields = (
         fields = (
             'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
             'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
             'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
             '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 = (
         default_columns = (
             'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
             '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'
         verbose_name='Platforms'
     )
     )
     slug = tables.Column()
     slug = tables.Column()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:manufacturer_list'
         url_name='dcim:manufacturer_list'
     )
     )
@@ -49,7 +52,7 @@ class ManufacturerTable(NetBoxTable):
         model = Manufacturer
         model = Manufacturer
         fields = (
         fields = (
             'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
             'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
-            'actions', 'created', 'last_updated',
+            'contacts', 'actions', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
             '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'},
         url_params={'power_panel_id': 'pk'},
         verbose_name='Feeds'
         verbose_name='Feeds'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:powerpanel_list'
         url_name='dcim:powerpanel_list'
     )
     )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = PowerPanel
         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')
         default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
 
 
 
 

+ 4 - 1
netbox/dcim/tables/racks.py

@@ -69,6 +69,9 @@ class RackTable(NetBoxTable):
         orderable=False,
         orderable=False,
         verbose_name='Power'
         verbose_name='Power'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:rack_list'
         url_name='dcim:rack_list'
     )
     )
@@ -86,7 +89,7 @@ class RackTable(NetBoxTable):
         fields = (
         fields = (
             'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
             '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',
             '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 = (
         default_columns = (
             'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
             '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'},
         url_params={'region_id': 'pk'},
         verbose_name='Sites'
         verbose_name='Sites'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:region_list'
         url_name='dcim:region_list'
     )
     )
@@ -33,7 +36,8 @@ class RegionTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Region
         model = Region
         fields = (
         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')
         default_columns = ('pk', 'name', 'site_count', 'description')
 
 
@@ -51,6 +55,9 @@ class SiteGroupTable(NetBoxTable):
         url_params={'group_id': 'pk'},
         url_params={'group_id': 'pk'},
         verbose_name='Sites'
         verbose_name='Sites'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:sitegroup_list'
         url_name='dcim:sitegroup_list'
     )
     )
@@ -58,7 +65,8 @@ class SiteGroupTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = SiteGroup
         model = SiteGroup
         fields = (
         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')
         default_columns = ('pk', 'name', 'site_count', 'description')
 
 
@@ -90,6 +98,9 @@ class SiteTable(NetBoxTable):
     )
     )
     tenant = TenantColumn()
     tenant = TenantColumn()
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:site_list'
         url_name='dcim:site_list'
     )
     )
@@ -99,7 +110,7 @@ class SiteTable(NetBoxTable):
         fields = (
         fields = (
             'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
             'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
             'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
             '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')
         default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
 
 
@@ -126,6 +137,9 @@ class LocationTable(NetBoxTable):
         url_params={'location_id': 'pk'},
         url_params={'location_id': 'pk'},
         verbose_name='Devices'
         verbose_name='Devices'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:location_list'
         url_name='dcim:location_list'
     )
     )
@@ -136,7 +150,7 @@ class LocationTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Location
         model = Location
         fields = (
         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')
         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',
             'device_count',
             cumulative=True
             cumulative=True
         ).restrict(request.user, 'view').filter(site=instance)
         ).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)
         asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance)
         asn_count = asns.count()
         asn_count = asns.count()
@@ -348,6 +353,7 @@ class SiteView(generic.ObjectView):
             'stats': stats,
             'stats': stats,
             'locations': locations,
             'locations': locations,
             'asns': asns,
             'asns': asns,
+            'nonracked_devices': nonracked_devices,
         }
         }
 
 
 
 
@@ -425,11 +431,17 @@ class LocationView(generic.ObjectView):
         ).filter(pk__in=location_ids).exclude(pk=instance.pk)
         ).filter(pk__in=location_ids).exclude(pk=instance.pk)
         child_locations_table = tables.LocationTable(child_locations)
         child_locations_table = tables.LocationTable(child_locations)
         child_locations_table.configure(request)
         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 {
         return {
             'rack_count': rack_count,
             'rack_count': rack_count,
             'device_count': device_count,
             'device_count': device_count,
             'child_locations_table': child_locations_table,
             '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(
     vlan_vid = django_filters.NumberFilter(
         field_name='vlan__vid',
         field_name='vlan__vid',
-        label='VLAN number (1-4095)',
+        label='VLAN number (1-4094)',
     )
     )
     role_id = django_filters.ModelMultipleChoiceFilter(
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),

+ 1 - 1
netbox/ipam/forms/bulk_import.py

@@ -388,7 +388,7 @@ class VLANCSVForm(NetBoxModelCSVForm):
         model = VLAN
         model = VLAN
         fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description')
         fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description')
         help_texts = {
         help_texts = {
-            'vid': 'Numeric VLAN ID (1-4095)',
+            'vid': 'Numeric VLAN ID (1-4094)',
             'name': 'VLAN name',
             'name': 'VLAN name',
         }
         }
 
 

+ 207 - 163
netbox/netbox/constants.py

@@ -1,4 +1,5 @@
 from collections import OrderedDict
 from collections import OrderedDict
+from typing import Dict
 
 
 from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet
 from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet
 from circuits.models import Circuit, ProviderNetwork, Provider
 from circuits.models import Circuit, ProviderNetwork, Provider
@@ -26,169 +27,212 @@ from virtualization.models import Cluster, VirtualMachine
 from virtualization.tables import ClusterTable, VirtualMachineTable
 from virtualization.tables import ClusterTable, VirtualMachineTable
 
 
 SEARCH_MAX_RESULTS = 15
 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',
                 'location',
-                'device_count',
+                'rack_count',
                 cumulative=True
                 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 django import forms
 
 
+from netbox.constants import SEARCH_TYPE_HIERARCHY
 from utilities.forms import BootstrapMixin
 from utilities.forms import BootstrapMixin
 from .base import *
 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():
 def build_options():

Diff do ficheiro suprimidas por serem muito extensas
+ 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-color: $component-active-color;
 $nav-pills-link-active-bg: $component-active-bg;
 $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-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
 // Dropdowns
 $dropdown-color: $body-color;
 $dropdown-color: $body-color;

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

@@ -37,9 +37,7 @@
                         </tr>
                         </tr>
                         <tr>
                         <tr>
                             <th scope="row">Serial Number</th>
                             <th scope="row">Serial Number</th>
-                            <td>
-                                <code id="serial_number"></code>
-                            </td>
+                            <td id="serial_number" class="text-monospace"></td>
                         </tr>
                         </tr>
                         <tr>
                         <tr>
                             <th scope="row">OS Version</th>
                             <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>
                 <a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
             </td>
             </td>
         </tr>
         </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>
         <tr>
             <td>Type</td>
             <td>Type</td>
             <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">
 	<div class="col col-md-6">
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/contacts.html' %}
     {% include 'inc/panels/contacts.html' %}
+    {% include 'dcim/inc/nonracked_devices.html' %}
     {% include 'inc/panels/image_attachments.html' %}
     {% include 'inc/panels/image_attachments.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
 	</div>
 	</div>

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

@@ -286,50 +286,7 @@
               </div>
               </div>
             </div>
             </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' %}
         {% include 'inc/panels/contacts.html' %}
         {% plugin_right_page object %}
         {% plugin_right_page object %}
     </div>
     </div>

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

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

+ 93 - 79
netbox/tenancy/filtersets.py

@@ -10,6 +10,7 @@ __all__ = (
     'ContactAssignmentFilterSet',
     'ContactAssignmentFilterSet',
     'ContactFilterSet',
     'ContactFilterSet',
     'ContactGroupFilterSet',
     'ContactGroupFilterSet',
+    'ContactModelFilterSet',
     'ContactRoleFilterSet',
     'ContactRoleFilterSet',
     'TenancyFilterSet',
     'TenancyFilterSet',
     'TenantFilterSet',
     'TenantFilterSet',
@@ -18,162 +19,175 @@ __all__ = (
 
 
 
 
 #
 #
-# Tenancy
+# Contacts
 #
 #
 
 
-class TenantGroupFilterSet(OrganizationalModelFilterSet):
+class ContactGroupFilterSet(OrganizationalModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     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(
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
         field_name='parent__slug',
-        queryset=TenantGroup.objects.all(),
+        queryset=ContactGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        label='Tenant group (slug)',
+        label='Contact group (slug)',
     )
     )
 
 
     class Meta:
     class Meta:
-        model = TenantGroup
+        model = ContactGroup
+        fields = ['id', 'name', 'slug', 'description']
+
+
+class ContactRoleFilterSet(OrganizationalModelFilterSet):
+
+    class Meta:
+        model = ContactRole
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class TenantFilterSet(NetBoxModelFilterSet):
+class ContactFilterSet(NetBoxModelFilterSet):
     group_id = TreeNodeMultipleChoiceFilter(
     group_id = TreeNodeMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
+        queryset=ContactGroup.objects.all(),
         field_name='group',
         field_name='group',
         lookup_expr='in',
         lookup_expr='in',
-        label='Tenant group (ID)',
+        label='Contact group (ID)',
     )
     )
     group = TreeNodeMultipleChoiceFilter(
     group = TreeNodeMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
+        queryset=ContactGroup.objects.all(),
         field_name='group',
         field_name='group',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
-        label='Tenant group (slug)',
+        label='Contact group (slug)',
     )
     )
 
 
     class Meta:
     class Meta:
-        model = Tenant
-        fields = ['id', 'name', 'slug', 'description']
+        model = Contact
+        fields = ['id', 'name', 'title', 'phone', 'email', 'address']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
             Q(name__icontains=value) |
             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)
             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',
         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(
     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(
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
         field_name='parent__slug',
-        queryset=ContactGroup.objects.all(),
+        queryset=TenantGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        label='Contact group (slug)',
+        label='Tenant group (slug)',
     )
     )
 
 
     class Meta:
     class Meta:
-        model = ContactGroup
-        fields = ['id', 'name', 'slug', 'description']
-
-
-class ContactRoleFilterSet(OrganizationalModelFilterSet):
-
-    class Meta:
-        model = ContactRole
+        model = TenantGroup
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class ContactFilterSet(NetBoxModelFilterSet):
+class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
     group_id = TreeNodeMultipleChoiceFilter(
     group_id = TreeNodeMultipleChoiceFilter(
-        queryset=ContactGroup.objects.all(),
+        queryset=TenantGroup.objects.all(),
         field_name='group',
         field_name='group',
         lookup_expr='in',
         lookup_expr='in',
-        label='Contact group (ID)',
+        label='Tenant group (ID)',
     )
     )
     group = TreeNodeMultipleChoiceFilter(
     group = TreeNodeMultipleChoiceFilter(
-        queryset=ContactGroup.objects.all(),
+        queryset=TenantGroup.objects.all(),
         field_name='group',
         field_name='group',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
-        label='Contact group (slug)',
+        label='Tenant group (slug)',
     )
     )
 
 
     class Meta:
     class Meta:
-        model = Contact
-        fields = ['id', 'name', 'title', 'phone', 'email', 'address']
+        model = Tenant
+        fields = ['id', 'name', 'slug', 'description']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
             Q(name__icontains=value) |
             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)
             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',
         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 netbox.forms import NetBoxModelFilterSetForm
 from tenancy.models import *
 from tenancy.models import *
+from tenancy.forms import ContactModelFilterForm
 from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField
 
 
 __all__ = (
 __all__ = (
@@ -27,8 +28,12 @@ class TenantGroupFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class TenantFilterForm(NetBoxModelFilterSetForm):
+class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Tenant
     model = Tenant
+    fieldsets = (
+        (None, ('q', 'tag', 'group_id')),
+        ('Contacts', ('contact', 'contact_role'))
+    )
     group_id = DynamicModelMultipleChoiceField(
     group_id = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         required=False,
         required=False,

+ 15 - 1
netbox/tenancy/forms/forms.py

@@ -1,10 +1,11 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
 from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 
 
 __all__ = (
 __all__ = (
+    'ContactModelFilterForm',
     'TenancyForm',
     'TenancyForm',
     'TenancyFilterForm',
     'TenancyFilterForm',
 )
 )
@@ -44,3 +45,16 @@ class TenancyFilterForm(forms.Form):
         },
         },
         label=_('Tenant')
         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:
         if self.priority:
             return f"{self.contact} ({self.get_priority_display()})"
             return f"{self.contact} ({self.get_priority_display()})"
         return str(self.contact)
         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
         linkify=True
     )
     )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='tenancy:tenant_list'
         url_name='tenancy:tenant_list'
     )
     )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Tenant
         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')
         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 extras.filtersets import LocalConfigContextFilterSet
 from ipam.models import VRF
 from ipam.models import VRF
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
-from tenancy.filtersets import TenancyFilterSet
+from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
 from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
 from .choices import *
 from .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -26,14 +26,14 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class ClusterGroupFilterSet(OrganizationalModelFilterSet):
+class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ClusterGroup
         model = ClusterGroup
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='site__region',
         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(
     status = django_filters.MultipleChoiceFilter(
         choices=VirtualMachineStatusChoices,
         choices=VirtualMachineStatusChoices,
         null_value=None
         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 extras.forms import LocalConfigContextFilterForm
 from ipam.models import VRF
 from ipam.models import VRF
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from utilities.forms import (
 from utilities.forms import (
     DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
     DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
@@ -26,18 +26,19 @@ class ClusterTypeFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ClusterGroupFilterForm(NetBoxModelFilterSetForm):
+class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = ClusterGroup
     model = ClusterGroup
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ClusterFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Cluster
     model = Cluster
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
         ('Attributes', ('group_id', 'type_id')),
         ('Attributes', ('group_id', 'type_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
+        ('Contacts', ('contact', 'contact_role')),
     )
     )
     type_id = DynamicModelMultipleChoiceField(
     type_id = DynamicModelMultipleChoiceField(
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
@@ -73,7 +74,12 @@ class ClusterFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
+class VirtualMachineFilterForm(
+    LocalConfigContextFilterForm,
+    TenancyFilterForm,
+    ContactModelFilterForm,
+    NetBoxModelFilterSetForm
+):
     model = VirtualMachine
     model = VirtualMachine
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
@@ -81,6 +87,7 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm,
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
         ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
+        ('Contacts', ('contact', 'contact_role')),
     )
     )
     cluster_group_id = DynamicModelMultipleChoiceField(
     cluster_group_id = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),

+ 10 - 3
netbox/virtualization/tables/clusters.py

@@ -36,6 +36,9 @@ class ClusterGroupTable(NetBoxTable):
     cluster_count = tables.Column(
     cluster_count = tables.Column(
         verbose_name='Clusters'
         verbose_name='Clusters'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='virtualization:clustergroup_list'
         url_name='virtualization:clustergroup_list'
     )
     )
@@ -43,7 +46,8 @@ class ClusterGroupTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = ClusterGroup
         model = ClusterGroup
         fields = (
         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')
         default_columns = ('pk', 'name', 'cluster_count', 'description')
 
 
@@ -75,6 +79,9 @@ class ClusterTable(NetBoxTable):
         verbose_name='VMs'
         verbose_name='VMs'
     )
     )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='virtualization:cluster_list'
         url_name='virtualization:cluster_list'
     )
     )
@@ -82,7 +89,7 @@ class ClusterTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Cluster
         model = Cluster
         fields = (
         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')
         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(
     vrf = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='virtualization:vminterface_list'
         url_name='virtualization:vminterface_list'
     )
     )
@@ -86,7 +89,8 @@ class VMInterfaceTable(BaseInterfaceTable):
         model = VMInterface
         model = VMInterface
         fields = (
         fields = (
             'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
             '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')
         default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
 
 

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff