Przeglądaj źródła

Merge pull request #8968 from netbox-community/develop

Release v3.1.10
Jeremy Stretch 3 lat temu
rodzic
commit
d50148fab7
60 zmienionych plików z 665 dodań i 431 usunięć
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 1 1
      .github/workflows/ci.yml
  4. 1 1
      base_requirements.txt
  5. 1 0
      docs/core-functionality/ipam.md
  6. 3 1
      docs/development/getting-started.md
  7. 0 6
      docs/models/ipam/fhrpgroup.md
  8. 5 0
      docs/models/ipam/fhrpgroupassignment.md
  9. 28 0
      docs/release-notes/version-3.1.md
  10. 3 3
      netbox/circuits/filtersets.py
  11. 5 3
      netbox/circuits/forms/filtersets.py
  12. 8 2
      netbox/circuits/tables.py
  13. 5 5
      netbox/dcim/api/serializers.py
  14. 10 10
      netbox/dcim/filtersets.py
  15. 31 11
      netbox/dcim/forms/filtersets.py
  16. 0 5
      netbox/dcim/forms/models.py
  17. 2 2
      netbox/dcim/models/devices.py
  18. 1 1
      netbox/dcim/models/racks.py
  19. 4 4
      netbox/dcim/svg.py
  20. 13 1
      netbox/dcim/tables/cables.py
  21. 15 3
      netbox/dcim/tables/devices.py
  22. 4 1
      netbox/dcim/tables/devicetypes.py
  23. 4 1
      netbox/dcim/tables/power.py
  24. 4 1
      netbox/dcim/tables/racks.py
  25. 17 5
      netbox/dcim/tables/sites.py
  26. 14 2
      netbox/dcim/views.py
  27. 5 1
      netbox/extras/scripts.py
  28. 1 1
      netbox/ipam/filtersets.py
  29. 1 1
      netbox/ipam/forms/bulk_import.py
  30. 1 1
      netbox/ipam/forms/models.py
  31. 3 3
      netbox/ipam/models/ip.py
  32. 2 2
      netbox/ipam/tests/test_models.py
  33. 207 163
      netbox/netbox/constants.py
  34. 17 32
      netbox/netbox/forms.py
  35. 1 1
      netbox/netbox/settings.py
  36. 4 1
      netbox/netbox/views/generic.py
  37. 0 0
      netbox/project-static/dist/netbox-dark.css
  38. 2 2
      netbox/project-static/styles/theme-dark.scss
  39. 2 2
      netbox/templates/base/layout.html
  40. 1 3
      netbox/templates/dcim/device/status.html
  41. 16 0
      netbox/templates/dcim/inc/cable_termination.html
  42. 62 0
      netbox/templates/dcim/inc/nonracked_devices.html
  43. 1 0
      netbox/templates/dcim/location.html
  44. 1 44
      netbox/templates/dcim/rack.html
  45. 1 0
      netbox/templates/dcim/site.html
  46. 1 1
      netbox/templates/extras/script_list.html
  47. 94 80
      netbox/tenancy/filtersets.py
  48. 3 1
      netbox/tenancy/forms/filtersets.py
  49. 15 1
      netbox/tenancy/forms/forms.py
  50. 3 0
      netbox/tenancy/models/contacts.py
  51. 4 1
      netbox/tenancy/tables.py
  52. 1 0
      netbox/utilities/tables.py
  53. 2 2
      netbox/utilities/templates/helpers/utilization_graph.html
  54. 1 1
      netbox/utilities/templates/search/searchbar.html
  55. 3 1
      netbox/utilities/templatetags/helpers.py
  56. 5 2
      netbox/utilities/templatetags/search.py
  57. 4 4
      netbox/virtualization/filtersets.py
  58. 6 4
      netbox/virtualization/forms/filtersets.py
  59. 12 3
      netbox/virtualization/tables.py
  60. 2 2
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.1.9
+      placeholder: v3.1.10
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.1.9
+      placeholder: v3.1.10
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/workflows/ci.yml

@@ -56,7 +56,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
         ln -s configuration.testing.py netbox/netbox/configuration.py
         ln -s configuration.testing.py netbox/netbox/configuration.py
 
 
     - name: Build documentation
     - name: Build documentation

+ 1 - 1
base_requirements.txt

@@ -83,7 +83,7 @@ markdown-include
 mkdocs-material
 mkdocs-material
 
 
 # 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

+ 1 - 0
docs/core-functionality/ipam.md

@@ -21,6 +21,7 @@
 ---
 ---
 
 
 {!models/ipam/fhrpgroup.md!}
 {!models/ipam/fhrpgroup.md!}
+{!models/ipam/fhrpgroupassignment.md!}
 
 
 ---
 ---
 
 

+ 3 - 1
docs/development/getting-started.md

@@ -124,8 +124,10 @@ The demo data is provided in JSON format and loaded into an empty database using
 
 
 Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `/netbox/` directory, not the root directory of the repository.
 Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `/netbox/` directory, not the root directory of the repository.
 
 
+When running tests, it's advised to use the special testing configuration file that ships with NetBox. This ensures that tests are run with the same configuration parameters consistently. To override your local configuration when running tests, set the `NETBOX_CONFIGURATION` environment variable to `netbox.configuration_testing`.
+
 ```no-highlight
 ```no-highlight
-$ python manage.py test
+$ NETBOX_CONFIGURATION=netbox.configuration_testing python manage.py test
 ```
 ```
 
 
 In cases where you haven't made any changes to the database (which is most of the time), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.)
 In cases where you haven't made any changes to the database (which is most of the time), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.)

+ 0 - 6
docs/models/ipam/fhrpgroup.md

@@ -8,9 +8,3 @@ A first-hop redundancy protocol (FHRP) enables multiple physical interfaces to p
 * Gateway Load Balancing Protocol (GLBP)
 * Gateway Load Balancing Protocol (GLBP)
 
 
 NetBox models these redundancy groups by protocol and group ID. Each group may optionally be assigned an authentication type and key. (Note that the authentication key is stored as a plaintext value in NetBox.) Each group may be assigned or more virtual IPv4 and/or IPv6 addresses.
 NetBox models these redundancy groups by protocol and group ID. Each group may optionally be assigned an authentication type and key. (Note that the authentication key is stored as a plaintext value in NetBox.) Each group may be assigned or more virtual IPv4 and/or IPv6 addresses.
-
-## FHRP Group Assignments
-
-Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority.
-
-Interfaces are assigned to FHRP groups under the interface detail view.

+ 5 - 0
docs/models/ipam/fhrpgroupassignment.md

@@ -0,0 +1,5 @@
+# FHRP Group Assignments
+
+Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority.
+
+Interfaces are assigned to FHRP groups under the interface detail view.

+ 28 - 0
docs/release-notes/version-3.1.md

@@ -1,5 +1,33 @@
 # NetBox v3.1
 # NetBox v3.1
 
 
+## v3.1.10 (2022-03-25)
+
+### Enhancements
+
+* [#8232](https://github.com/netbox-community/netbox/issues/8232) - Use a different color for 100% utilization bars
+* [#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
+* [#8926](https://github.com/netbox-community/netbox/issues/8926) - Add device type, role columns to device bay table
+
+### Bug Fixes
+
+* [#8696](https://github.com/netbox-community/netbox/issues/8696) - Fix help link under FHRP group assigment creation view
+* [#8813](https://github.com/netbox-community/netbox/issues/8813) - Retain global search bar query after submitting
+* [#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
+* [#8905](https://github.com/netbox-community/netbox/issues/8905) - Disable ordering by assigned tags to prevent erroneous results
+* [#8919](https://github.com/netbox-community/netbox/issues/8919) - Fix filtering of VLAN groups by site under prefix edit form
+* [#8924](https://github.com/netbox-community/netbox/issues/8924) - Improve load time of custom script list
+* [#8932](https://github.com/netbox-community/netbox/issues/8932) - Fix error when setting null value for interface `rf_role` via REST API
+* [#8935](https://github.com/netbox-community/netbox/issues/8935) - Correct ordering of next/previous racks to use naturalized names
+* [#8947](https://github.com/netbox-community/netbox/issues/8947) - Retain filter parameters when handling an export template exception
+* [#8951](https://github.com/netbox-community/netbox/issues/8951) - Allow changing device type & platform to different manufacturer simultaneously
+* [#8952](https://github.com/netbox-community/netbox/issues/8952) - Device images in rear rack elevations should be hyperlinked
+
+---
+
 ## v3.1.9 (2022-03-07)
 ## v3.1.9 (2022-03-07)
 
 
 ### Enhancements
 ### Enhancements

+ 3 - 3
netbox/circuits/filtersets.py

@@ -5,7 +5,7 @@ from dcim.filtersets import CableTerminationFilterSet
 from dcim.models import Region, Site, SiteGroup
 from dcim.models import Region, Site, SiteGroup
 from extras.filters import TagFilter
 from extras.filters import TagFilter
 from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
 from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
-from tenancy.filtersets import TenancyFilterSet
+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 *
@@ -19,7 +19,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ProviderFilterSet(PrimaryModelFilterSet):
+class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -118,7 +118,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

+ 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 extras.forms import CustomFieldModelFilterForm
 from extras.forms import CustomFieldModelFilterForm
-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(CustomFieldModelFilterForm):
+class ProviderFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Provider
     model = Provider
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
         ['region_id', 'site_group_id', 'site_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['asn'],
         ['asn'],
+        ['contact', 'contact_role']
     ]
     ]
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -68,7 +69,7 @@ class CircuitTypeFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Circuit
     model = Circuit
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
@@ -76,6 +77,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         ['type_id', 'status', 'commit_rate'],
         ['type_id', 'status', 'commit_rate'],
         ['region_id', 'site_group_id', 'site_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['tenant_group_id', 'tenant_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role']
     ]
     ]
     type_id = DynamicModelMultipleChoiceField(
     type_id = DynamicModelMultipleChoiceField(
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),

+ 8 - 2
netbox/circuits/tables.py

@@ -58,6 +58,9 @@ class ProviderTable(BaseTable):
         verbose_name='Circuits'
         verbose_name='Circuits'
     )
     )
     comments = MarkdownColumn()
     comments = MarkdownColumn()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
     tags = TagColumn(
         url_name='circuits:provider_list'
         url_name='circuits:provider_list'
     )
     )
@@ -66,7 +69,7 @@ class ProviderTable(BaseTable):
         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')
 
 
@@ -142,6 +145,9 @@ class CircuitTable(BaseTable):
     )
     )
     commit_rate = CommitRateColumn()
     commit_rate = CommitRateColumn()
     comments = MarkdownColumn()
     comments = MarkdownColumn()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
     tags = TagColumn(
         url_name='circuits:circuit_list'
         url_name='circuits:circuit_list'
     )
     )
@@ -150,7 +156,7 @@ class CircuitTable(BaseTable):
         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',

+ 5 - 5
netbox/dcim/api/serializers.py

@@ -497,9 +497,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)
@@ -619,8 +619,8 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
     parent = NestedInterfaceSerializer(required=False, allow_null=True)
     parent = NestedInterfaceSerializer(required=False, allow_null=True)
     bridge = NestedInterfaceSerializer(required=False, allow_null=True)
     bridge = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
-    mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
-    rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True)
+    mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
+    rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
     rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
     rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
     tagged_vlans = SerializedPKRelatedField(

+ 10 - 10
netbox/dcim/filtersets.py

@@ -7,8 +7,8 @@ from ipam.models import ASN
 from netbox.filtersets import (
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
 )
 )
-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,
@@ -62,7 +62,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)',
@@ -80,7 +80,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)',
@@ -98,7 +98,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -167,7 +167,7 @@ class SiteFilterSet(PrimaryModelFilterSet, 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',
@@ -240,7 +240,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'color', 'description']
         fields = ['id', 'name', 'slug', 'color', 'description']
 
 
 
 
-class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -398,7 +398,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         )
         )
 
 
 
 
-class ManufacturerFilterSet(OrganizationalModelFilterSet):
+class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
@@ -608,7 +608,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
         fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
 
 
 
 
-class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
+class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -1289,7 +1289,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
         return queryset
         return queryset
 
 
 
 
-class PowerPanelFilterSet(PrimaryModelFilterSet):
+class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

+ 31 - 11
netbox/dcim/forms/filtersets.py

@@ -5,9 +5,10 @@ from django.utils.translation import gettext as _
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
+from tenancy.models import *
 from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
 from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
 from ipam.models import ASN
 from ipam.models import ASN
-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,
     StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
@@ -98,8 +99,13 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
     )
     )
 
 
 
 
-class RegionFilterForm(CustomFieldModelFilterForm):
+class RegionFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Region
     model = Region
+    field_groups = [
+        ['q', 'tag'],
+        ['parent_id'],
+        ['contact', 'contact_role'],
+    ]
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -108,8 +114,13 @@ class RegionFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class SiteGroupFilterForm(CustomFieldModelFilterForm):
+class SiteGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = SiteGroup
     model = SiteGroup
+    field_groups = [
+        ['q', 'tag'],
+        ['parent_id'],
+        ['contact', 'contact_role'],
+    ]
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         required=False,
         required=False,
@@ -118,13 +129,14 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Site
     model = Site
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
         ['status', 'region_id', 'group_id'],
         ['status', 'region_id', 'group_id'],
         ['tenant_group_id', 'tenant_id'],
         ['tenant_group_id', 'tenant_id'],
-        ['asn_id']
+        ['asn_id'],
+        ['contact', 'contact_role'],
     ]
     ]
     status = forms.MultipleChoiceField(
     status = forms.MultipleChoiceField(
         choices=SiteStatusChoices,
         choices=SiteStatusChoices,
@@ -149,12 +161,13 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Location
     model = Location
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
         ['region_id', 'site_group_id', 'site_id', 'parent_id'],
         ['region_id', 'site_group_id', 'site_id', 'parent_id'],
         ['tenant_group_id', 'tenant_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role'],
     ]
     ]
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -192,7 +205,7 @@ class RackRoleFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Rack
     model = Rack
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
@@ -200,6 +213,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         ['status', 'role_id'],
         ['status', 'role_id'],
         ['type', 'width', 'serial', 'asset_tag'],
         ['type', 'width', 'serial', 'asset_tag'],
         ['tenant_group_id', 'tenant_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role']
     ]
     ]
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -303,8 +317,12 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ManufacturerFilterForm(CustomFieldModelFilterForm):
+class ManufacturerFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Manufacturer
     model = Manufacturer
+    field_groups = [
+        ['q', 'tag'],
+        ['contact', 'contact_role'],
+    ]
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
@@ -390,7 +408,7 @@ class PlatformFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Device
     model = Device
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
@@ -402,6 +420,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
             'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports',
             'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports',
             'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
             'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
         ],
         ],
+        ['contact', 'contact_role'],
     ]
     ]
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -636,11 +655,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class PowerPanelFilterForm(CustomFieldModelFilterForm):
+class PowerPanelFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = PowerPanel
     model = PowerPanel
     field_groups = (
     field_groups = (
         ('q', 'tag'),
         ('q', 'tag'),
-        ('region_id', 'site_group_id', 'site_id', 'location_id')
+        ('region_id', 'site_group_id', 'site_id', 'location_id'),
+        ('contact', 'contact_role')
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),

+ 0 - 5
netbox/dcim/forms/models.py

@@ -605,11 +605,6 @@ class DeviceForm(TenancyForm, CustomFieldModelForm):
             # can be flipped from one face to another.
             # can be flipped from one face to another.
             self.fields['position'].widget.add_query_param('exclude', self.instance.pk)
             self.fields['position'].widget.add_query_param('exclude', self.instance.pk)
 
 
-            # Limit platform by manufacturer
-            self.fields['platform'].queryset = Platform.objects.filter(
-                Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
-            )
-
             # Disable rack assignment if this is a child device installed in a parent device
             # Disable rack assignment if this is a child device installed in a parent device
             if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
             if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
                 self.fields['site'].disabled = True
                 self.fields['site'].disabled = True

+ 2 - 2
netbox/dcim/models/devices.py

@@ -739,8 +739,8 @@ class Device(PrimaryModel, ConfigContextModel):
         if hasattr(self, 'device_type') and self.platform:
         if hasattr(self, 'device_type') and self.platform:
             if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
             if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
                 raise ValidationError({
                 raise ValidationError({
-                    'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
-                                "to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
+                    'platform': f"The assigned platform is limited to {self.platform.manufacturer} device types, but "
+                                f"this device's type belongs to {self.device_type.manufacturer}."
                 })
                 })
 
 
         # A Device can only be assigned to a Cluster in the same Site (or no Site)
         # A Device can only be assigned to a Cluster in the same Site (or no Site)

+ 1 - 1
netbox/dcim/models/racks.py

@@ -412,7 +412,7 @@ class Rack(PrimaryModel):
                 available_units.remove(u)
                 available_units.remove(u)
 
 
         occupied_unit_count = self.u_height - len(available_units)
         occupied_unit_count = self.u_height - len(available_units)
-        percentage = int(float(occupied_unit_count) / self.u_height * 100)
+        percentage = float(occupied_unit_count) / self.u_height * 100
 
 
         return percentage
         return percentage
 
 

+ 4 - 4
netbox/dcim/svg.py

@@ -146,10 +146,10 @@ class RackElevationSVG:
                 class_='device-image'
                 class_='device-image'
             )
             )
             image.fit(scale='slice')
             image.fit(scale='slice')
-            drawing.add(image)
-            drawing.add(drawing.text(get_device_name(device), insert=text, stroke='black',
-                        stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
-            drawing.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
+            link.add(image)
+            link.add(drawing.text(get_device_name(device), insert=text, stroke='black',
+                     stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
+            link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
 
 
     @staticmethod
     @staticmethod
     def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
     def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):

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

@@ -23,6 +23,12 @@ class CableTable(BaseTable):
         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,
@@ -35,6 +41,12 @@ class CableTable(BaseTable):
         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,
@@ -55,7 +67,7 @@ class CableTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.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 = (

+ 15 - 3
netbox/dcim/tables/devices.py

@@ -194,6 +194,9 @@ class DeviceTable(BaseTable):
     vc_priority = tables.Column(
     vc_priority = tables.Column(
         verbose_name='VC Priority'
         verbose_name='VC Priority'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     comments = MarkdownColumn()
     comments = MarkdownColumn()
     tags = TagColumn(
     tags = TagColumn(
         url_name='dcim:device_list'
         url_name='dcim:device_list'
@@ -204,8 +207,8 @@ class DeviceTable(BaseTable):
         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',
@@ -677,6 +680,15 @@ class DeviceBayTable(DeviceComponentTable):
             'args': [Accessor('device_id')],
             'args': [Accessor('device_id')],
         }
         }
     )
     )
+    device_role = ColoredLabelColumn(
+        accessor=Accessor('installed_device__device_role'),
+        verbose_name='Role'
+    )
+    device_type = tables.Column(
+        accessor=Accessor('installed_device__device_type'),
+        linkify=True,
+        verbose_name='Type'
+    )
     status = tables.TemplateColumn(
     status = tables.TemplateColumn(
         template_code=DEVICEBAY_STATUS,
         template_code=DEVICEBAY_STATUS,
         order_by=Accessor('installed_device__status')
         order_by=Accessor('installed_device__status')
@@ -691,7 +703,7 @@ class DeviceBayTable(DeviceComponentTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = DeviceBay
         model = DeviceBay
         fields = (
         fields = (
-            'pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags',
+            'pk', 'id', 'name', 'device', 'label', 'status', 'device_role', 'device_type', 'installed_device', 'description', 'tags',
             'created', 'last_updated',
             'created', 'last_updated',
         )
         )
 
 

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

@@ -41,6 +41,9 @@ class ManufacturerTable(BaseTable):
         verbose_name='Platforms'
         verbose_name='Platforms'
     )
     )
     slug = tables.Column()
     slug = tables.Column()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
     tags = TagColumn(
         url_name='dcim:manufacturer_list'
         url_name='dcim:manufacturer_list'
     )
     )
@@ -50,7 +53,7 @@ class ManufacturerTable(BaseTable):
         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', 'actions',
             'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',

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

@@ -27,13 +27,16 @@ class PowerPanelTable(BaseTable):
         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 = TagColumn(
     tags = TagColumn(
         url_name='dcim:powerpanel_list'
         url_name='dcim:powerpanel_list'
     )
     )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.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

@@ -75,6 +75,9 @@ class RackTable(BaseTable):
         orderable=False,
         orderable=False,
         verbose_name='Power'
         verbose_name='Power'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
     tags = TagColumn(
         url_name='dcim:rack_list'
         url_name='dcim:rack_list'
     )
     )
@@ -92,7 +95,7 @@ class RackTable(BaseTable):
         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',

+ 17 - 5
netbox/dcim/tables/sites.py

@@ -29,6 +29,9 @@ class RegionTable(BaseTable):
         url_params={'region_id': 'pk'},
         url_params={'region_id': 'pk'},
         verbose_name='Sites'
         verbose_name='Sites'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
     tags = TagColumn(
         url_name='dcim:region_list'
         url_name='dcim:region_list'
     )
     )
@@ -36,7 +39,7 @@ class RegionTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Region
         model = Region
-        fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated')
+        fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
 
 
 
 
@@ -54,6 +57,9 @@ class SiteGroupTable(BaseTable):
         url_params={'group_id': 'pk'},
         url_params={'group_id': 'pk'},
         verbose_name='Sites'
         verbose_name='Sites'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
     tags = TagColumn(
         url_name='dcim:sitegroup_list'
         url_name='dcim:sitegroup_list'
     )
     )
@@ -61,7 +67,7 @@ class SiteGroupTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = SiteGroup
         model = SiteGroup
-        fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated')
+        fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
 
 
 
 
@@ -92,6 +98,9 @@ class SiteTable(BaseTable):
         verbose_name='ASNs'
         verbose_name='ASNs'
     )
     )
     tenant = TenantColumn()
     tenant = TenantColumn()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     comments = MarkdownColumn()
     comments = MarkdownColumn()
     tags = TagColumn(
     tags = TagColumn(
         url_name='dcim:site_list'
         url_name='dcim:site_list'
@@ -102,7 +111,7 @@ class SiteTable(BaseTable):
         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', 'contact_name',
             'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
-            'contact_phone', 'contact_email', 'comments', 'tags', 'created', 'last_updated',
+            'contact_phone', 'contact_email', 'contacts', 'comments', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
         default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
 
 
@@ -130,6 +139,9 @@ class LocationTable(BaseTable):
         url_params={'location_id': 'pk'},
         url_params={'location_id': 'pk'},
         verbose_name='Devices'
         verbose_name='Devices'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
     tags = TagColumn(
         url_name='dcim:location_list'
         url_name='dcim:location_list'
     )
     )
@@ -141,7 +153,7 @@ class LocationTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.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', 'actions')
         default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')

+ 14 - 2
netbox/dcim/views.py

@@ -328,6 +328,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()
@@ -338,6 +343,7 @@ class SiteView(generic.ObjectView):
             'stats': stats,
             'stats': stats,
             'locations': locations,
             'locations': locations,
             'asns': asns,
             'asns': asns,
+            'nonracked_devices': nonracked_devices,
         }
         }
 
 
 
 
@@ -415,11 +421,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)
         paginate_table(child_locations_table, request)
         paginate_table(child_locations_table, 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,
         }
         }
 
 
 
 
@@ -597,8 +609,8 @@ class RackView(generic.ObjectView):
             peer_racks = peer_racks.filter(location=instance.location)
             peer_racks = peer_racks.filter(location=instance.location)
         else:
         else:
             peer_racks = peer_racks.filter(location__isnull=True)
             peer_racks = peer_racks.filter(location__isnull=True)
-        next_rack = peer_racks.filter(name__gt=instance.name).order_by('name').first()
-        prev_rack = peer_racks.filter(name__lt=instance.name).order_by('-name').first()
+        next_rack = peer_racks.filter(_name__gt=instance._name).first()
+        prev_rack = peer_racks.filter(_name__lt=instance._name).reverse().first()
 
 
         reservations = RackReservation.objects.restrict(request.user, 'view').filter(rack=instance)
         reservations = RackReservation.objects.restrict(request.user, 'view').filter(rack=instance)
         power_feeds = PowerFeed.objects.restrict(request.user, 'view').filter(rack=instance).prefetch_related(
         power_feeds = PowerFeed.objects.restrict(request.user, 'view').filter(rack=instance).prefetch_related(

+ 5 - 1
netbox/extras/scripts.py

@@ -259,6 +259,10 @@ class BaseScript:
     Base model for custom scripts. User classes should inherit from this model if they want to extend Script
     Base model for custom scripts. User classes should inherit from this model if they want to extend Script
     functionality for use in other subclasses.
     functionality for use in other subclasses.
     """
     """
+
+    # Prevent django from instantiating the class on all accesses
+    do_not_call_in_templates = True
+
     class Meta:
     class Meta:
         pass
         pass
 
 
@@ -280,7 +284,7 @@ class BaseScript:
 
 
     @classproperty
     @classproperty
     def name(self):
     def name(self):
-        return getattr(self.Meta, 'name', self.__class__.__name__)
+        return getattr(self.Meta, 'name', self.__name__)
 
 
     @classproperty
     @classproperty
     def full_name(self):
     def full_name(self):

+ 1 - 1
netbox/ipam/filtersets.py

@@ -334,7 +334,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, 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

@@ -375,7 +375,7 @@ class VLANCSVForm(CustomFieldModelCSVForm):
         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',
         }
         }
 
 

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

@@ -222,7 +222,7 @@ class PrefixForm(TenancyForm, CustomFieldModelForm):
         label='VLAN group',
         label='VLAN group',
         null_option='None',
         null_option='None',
         query_params={
         query_params={
-            'site_id': '$site'
+            'site': '$site'
         },
         },
         initial_params={
         initial_params={
             'vlans': '$vlan'
             'vlans': '$vlan'

+ 3 - 3
netbox/ipam/models/ip.py

@@ -248,7 +248,7 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
         """
         """
         queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
         queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
         child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
         child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
-        utilization = int(float(child_prefixes.size) / self.prefix.size * 100)
+        utilization = float(child_prefixes.size) / self.prefix.size * 100
 
 
         return min(utilization, 100)
         return min(utilization, 100)
 
 
@@ -548,7 +548,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
                 vrf=self.vrf
                 vrf=self.vrf
             )
             )
             child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
             child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
-            utilization = int(float(child_prefixes.size) / self.prefix.size * 100)
+            utilization = float(child_prefixes.size) / self.prefix.size * 100
         else:
         else:
             # Compile an IPSet to avoid counting duplicate IPs
             # Compile an IPSet to avoid counting duplicate IPs
             child_ips = netaddr.IPSet(
             child_ips = netaddr.IPSet(
@@ -558,7 +558,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
             prefix_size = self.prefix.size
             prefix_size = self.prefix.size
             if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
             if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
                 prefix_size -= 2
                 prefix_size -= 2
-            utilization = int(float(child_ips.size) / prefix_size * 100)
+            utilization = float(child_ips.size) / prefix_size * 100
 
 
         return min(utilization, 100)
         return min(utilization, 100)
 
 

+ 2 - 2
netbox/ipam/tests/test_models.py

@@ -204,11 +204,11 @@ class TestPrefix(TestCase):
         IPAddress.objects.bulk_create([
         IPAddress.objects.bulk_create([
             IPAddress(address=IPNetwork(f'10.0.0.{i}/24')) for i in range(1, 33)
             IPAddress(address=IPNetwork(f'10.0.0.{i}/24')) for i in range(1, 33)
         ])
         ])
-        self.assertEqual(prefix.get_utilization(), 12)  # 12.5% utilization
+        self.assertEqual(prefix.get_utilization(), 32 / 254 * 100)  # ~12.5% utilization
 
 
         # Create a child range with 32 additional IPs
         # Create a child range with 32 additional IPs
         IPRange.objects.create(start_address=IPNetwork('10.0.0.33/24'), end_address=IPNetwork('10.0.0.64/24'))
         IPRange.objects.create(start_address=IPNetwork('10.0.0.33/24'), end_address=IPNetwork('10.0.0.64/24'))
-        self.assertEqual(prefix.get_utilization(), 25)  # 25% utilization
+        self.assertEqual(prefix.get_utilization(), 64 / 254 * 100)  # ~25% utilization
 
 
     #
     #
     # Uniqueness enforcement tests
     # Uniqueness enforcement tests

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

@@ -1,39 +1,24 @@
 from django import forms
 from django import forms
 
 
 from utilities.forms import BootstrapMixin
 from utilities.forms import BootstrapMixin
+from netbox.constants import SEARCH_TYPE_HIERARCHY
 
 
-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():

+ 1 - 1
netbox/netbox/settings.py

@@ -19,7 +19,7 @@ from netbox.config import PARAMS
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.1.9'
+VERSION = '3.1.10'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 4 - 1
netbox/netbox/views/generic.py

@@ -212,7 +212,10 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
             return template.render_to_response(self.queryset)
             return template.render_to_response(self.queryset)
         except Exception as e:
         except Exception as e:
             messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
             messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
-            return redirect(request.path)
+            # Strip the `export` param and redirect user to the filtered objects list
+            query_params = request.GET.copy()
+            query_params.pop('export')
+            return redirect(f'{request.path}?{query_params.urlencode()}')
 
 
     def get(self, request):
     def get(self, request):
         model = self.queryset.model
         model = self.queryset.model

Plik diff jest za duży
+ 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;

+ 2 - 2
netbox/templates/base/layout.html

@@ -33,7 +33,7 @@
                 </button>
                 </button>
               </div>
               </div>
               <div class="d-flex my-1 flex-grow-1 justify-content-center w-100">
               <div class="d-flex my-1 flex-grow-1 justify-content-center w-100">
-                {% search_options %}
+                {% search_options request %}
               </div>
               </div>
             </div>
             </div>
 
 
@@ -45,7 +45,7 @@
 
 
               {# Search bar #}
               {# Search bar #}
               <div class="col-6 d-flex flex-grow-1 justify-content-center">
               <div class="col-6 d-flex flex-grow-1 justify-content-center">
-                {% search_options %}
+                {% search_options request %}
               </div>
               </div>
 
 
               {# Proflie/login button #}
               {# Proflie/login button #}

+ 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

@@ -288,50 +288,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

@@ -277,6 +277,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>

+ 1 - 1
netbox/templates/extras/script_list.html

@@ -34,7 +34,7 @@
                 {% for class_name, script in module_scripts.items %}
                 {% for class_name, script in module_scripts.items %}
                   <tr>
                   <tr>
                     <td>
                     <td>
-                      <a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script }}</a>
+                      <a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script.name }}</a>
                     </td>
                     </td>
                     <td>
                     <td>
                       {% include 'extras/inc/job_label.html' with result=script.result %}
                       {% include 'extras/inc/job_label.html' with result=script.result %}

+ 94 - 80
netbox/tenancy/filtersets.py

@@ -11,6 +11,7 @@ __all__ = (
     'ContactAssignmentFilterSet',
     'ContactAssignmentFilterSet',
     'ContactFilterSet',
     'ContactFilterSet',
     'ContactGroupFilterSet',
     'ContactGroupFilterSet',
+    'ContactModelFilterSet',
     'ContactRoleFilterSet',
     'ContactRoleFilterSet',
     'TenancyFilterSet',
     'TenancyFilterSet',
     'TenantFilterSet',
     'TenantFilterSet',
@@ -19,175 +20,188 @@ __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)',
     )
     )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
-        model = TenantGroup
+        model = ContactGroup
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class TenantFilterSet(PrimaryModelFilterSet):
+class ContactRoleFilterSet(OrganizationalModelFilterSet):
+    tag = TagFilter()
+
+    class Meta:
+        model = ContactRole
+        fields = ['id', 'name', 'slug', 'description']
+
+
+class ContactFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
     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)',
     )
     )
     tag = TagFilter()
     tag = TagFilter()
 
 
     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)',
     )
     )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
-        model = ContactGroup
-        fields = ['id', 'name', 'slug', 'description']
-
-
-class ContactRoleFilterSet(OrganizationalModelFilterSet):
-    tag = TagFilter()
-
-    class Meta:
-        model = ContactRole
+        model = TenantGroup
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class ContactFilterSet(PrimaryModelFilterSet):
+class TenantFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
     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)',
     )
     )
     tag = TagFilter()
     tag = TagFilter()
 
 
     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']

+ 3 - 1
netbox/tenancy/forms/filtersets.py

@@ -2,6 +2,7 @@ from django.utils.translation import gettext as _
 
 
 from extras.forms import CustomFieldModelFilterForm
 from extras.forms import CustomFieldModelFilterForm
 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,11 +28,12 @@ class TenantGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class TenantFilterForm(CustomFieldModelFilterForm):
+class TenantFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Tenant
     model = Tenant
     field_groups = (
     field_groups = (
         ('q', 'tag'),
         ('q', 'tag'),
         ('group_id',),
         ('group_id',),
+        ('contact', 'contact_role')
     )
     )
     group_id = DynamicModelMultipleChoiceField(
     group_id = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),

+ 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

@@ -166,3 +166,6 @@ class ContactAssignment(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])

+ 4 - 1
netbox/tenancy/tables.py

@@ -77,6 +77,9 @@ class TenantTable(BaseTable):
     group = tables.Column(
     group = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     comments = MarkdownColumn()
     comments = MarkdownColumn()
     tags = TagColumn(
     tags = TagColumn(
         url_name='tenancy:tenant_list'
         url_name='tenancy:tenant_list'
@@ -84,7 +87,7 @@ class TenantTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.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')
 
 
 
 

+ 1 - 0
netbox/utilities/tables.py

@@ -430,6 +430,7 @@ class TagColumn(tables.TemplateColumn):
 
 
     def __init__(self, url_name=None):
     def __init__(self, url_name=None):
         super().__init__(
         super().__init__(
+            orderable=False,
             template_code=self.template_code,
             template_code=self.template_code,
             extra_context={'url_name': url_name}
             extra_context={'url_name': url_name}
         )
         )

+ 2 - 2
netbox/utilities/templates/helpers/utilization_graph.html

@@ -12,10 +12,10 @@
       class="progress-bar {{ bar_class }}"
       class="progress-bar {{ bar_class }}"
       style="width: {{ utilization }}%;"
       style="width: {{ utilization }}%;"
     >
     >
-      {% if utilization >= 25 %}{{ utilization }}%{% endif %}
+      {% if utilization >= 25 %}{{ utilization|floatformat:0 }}%{% endif %}
     </div>
     </div>
     {% if utilization < 25 %}
     {% if utilization < 25 %}
-      <span class="ps-1">{{ utilization }}%</span>
+      <span class="ps-1">{{ utilization|floatformat:0 }}%</span>
     {% endif %}
     {% endif %}
   </div>
   </div>
 {% endif %}
 {% endif %}

+ 1 - 1
netbox/utilities/templates/search/searchbar.html

@@ -5,7 +5,7 @@
     aria-label="Search"
     aria-label="Search"
     placeholder="Search"
     placeholder="Search"
     class="form-control"
     class="form-control"
-    value="{{ request.GET.q }}"
+    value="{{ request.GET.q|escape }}"
   />
   />
 
 
   <input name="obj_type" hidden type="text" class="search-obj-type" />
   <input name="obj_type" hidden type="text" class="search-obj-type" />

+ 3 - 1
netbox/utilities/templatetags/helpers.py

@@ -389,7 +389,9 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
     """
     """
     Display a horizontal bar graph indicating a percentage of utilization.
     Display a horizontal bar graph indicating a percentage of utilization.
     """
     """
-    if danger_threshold and utilization >= danger_threshold:
+    if utilization == 100:
+        bar_class = 'bg-secondary'
+    elif danger_threshold and utilization >= danger_threshold:
         bar_class = 'bg-danger'
         bar_class = 'bg-danger'
     elif warning_threshold and utilization >= warning_threshold:
     elif warning_threshold and utilization >= warning_threshold:
         bar_class = 'bg-warning'
         bar_class = 'bg-warning'

+ 5 - 2
netbox/utilities/templatetags/search.py

@@ -8,6 +8,9 @@ search_form = SearchForm()
 
 
 
 
 @register.inclusion_tag("search/searchbar.html")
 @register.inclusion_tag("search/searchbar.html")
-def search_options() -> Dict:
+def search_options(request) -> Dict:
     """Provide search options to template."""
     """Provide search options to template."""
-    return {"options": search_form.options}
+    return {
+        'options': search_form.options,
+        'request': request,
+    }

+ 4 - 4
netbox/virtualization/filtersets.py

@@ -5,7 +5,7 @@ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from extras.filters import TagFilter
 from extras.filters import TagFilter
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.filtersets import LocalConfigContextFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
-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
@@ -27,7 +27,7 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class ClusterGroupFilterSet(OrganizationalModelFilterSet):
+class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
@@ -35,7 +35,7 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -111,7 +111,7 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         )
         )
 
 
 
 
-class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
+class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

+ 6 - 4
netbox/virtualization/forms/filtersets.py

@@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
 
 
 from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
 from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
 from utilities.forms import (
 from utilities.forms import (
     DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
     DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
@@ -24,18 +24,19 @@ class ClusterTypeFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ClusterGroupFilterForm(CustomFieldModelFilterForm):
+class ClusterGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = ClusterGroup
     model = ClusterGroup
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Cluster
     model = Cluster
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
         ['group_id', 'type_id'],
         ['group_id', 'type_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['tenant_group_id', 'tenant_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role'],
     ]
     ]
     type_id = DynamicModelMultipleChoiceField(
     type_id = DynamicModelMultipleChoiceField(
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
@@ -71,7 +72,7 @@ class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = VirtualMachine
     model = VirtualMachine
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
@@ -79,6 +80,7 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm,
         ['region_id', 'site_group_id', 'site_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'],
         ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'],
         ['tenant_group_id', 'tenant_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role'],
     ]
     ]
     cluster_group_id = DynamicModelMultipleChoiceField(
     cluster_group_id = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),

+ 12 - 3
netbox/virtualization/tables.py

@@ -62,6 +62,9 @@ class ClusterGroupTable(BaseTable):
     cluster_count = tables.Column(
     cluster_count = tables.Column(
         verbose_name='Clusters'
         verbose_name='Clusters'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
     tags = TagColumn(
         url_name='virtualization:clustergroup_list'
         url_name='virtualization:clustergroup_list'
     )
     )
@@ -70,7 +73,7 @@ class ClusterGroupTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ClusterGroup
         model = ClusterGroup
         fields = (
         fields = (
-            'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions', 'created', 'last_updated',
+            'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated',
         )
         )
         default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
 
 
@@ -106,6 +109,9 @@ class ClusterTable(BaseTable):
         url_params={'cluster_id': 'pk'},
         url_params={'cluster_id': 'pk'},
         verbose_name='VMs'
         verbose_name='VMs'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     comments = MarkdownColumn()
     comments = MarkdownColumn()
     tags = TagColumn(
     tags = TagColumn(
         url_name='virtualization:cluster_list'
         url_name='virtualization:cluster_list'
@@ -114,7 +120,7 @@ class ClusterTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Cluster
         model = Cluster
         fields = (
         fields = (
-            'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags',
+            'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts', 'tags',
             'created', 'last_updated',
             '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')
@@ -150,6 +156,9 @@ class VirtualMachineTable(BaseTable):
         order_by=('primary_ip4', 'primary_ip6'),
         order_by=('primary_ip4', 'primary_ip6'),
         verbose_name='IP Address'
         verbose_name='IP Address'
     )
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
     tags = TagColumn(
         url_name='virtualization:virtualmachine_list'
         url_name='virtualization:virtualmachine_list'
     )
     )
@@ -158,7 +167,7 @@ class VirtualMachineTable(BaseTable):
         model = VirtualMachine
         model = VirtualMachine
         fields = (
         fields = (
             'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
             'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
-            'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
+            'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
             'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',

+ 2 - 2
requirements.txt

@@ -18,14 +18,14 @@ gunicorn==20.1.0
 Jinja2==3.0.3
 Jinja2==3.0.3
 Markdown==3.3.6
 Markdown==3.3.6
 markdown-include==0.6.0
 markdown-include==0.6.0
-mkdocs-material==8.2.5
+mkdocs-material==8.2.7
 netaddr==0.8.0
 netaddr==0.8.0
 Pillow==9.0.1
 Pillow==9.0.1
 psycopg2-binary==2.9.3
 psycopg2-binary==2.9.3
 PyYAML==6.0
 PyYAML==6.0
 social-auth-app-django==5.0.0
 social-auth-app-django==5.0.0
 social-auth-core==4.2.0
 social-auth-core==4.2.0
-svgwrite==1.4.1
+svgwrite==1.4.2
 tablib==3.2.0
 tablib==3.2.0
 tzdata==2021.5
 tzdata==2021.5
 
 

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików