Przeglądaj źródła

Merge pull request #12681 from netbox-community/develop

Release v3.5.2
Jeremy Stretch 2 lat temu
rodzic
commit
c9b79ca579
74 zmienionych plików z 708 dodań i 183 usunięć
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 6 4
      README.md
  4. 2 6
      docs/administration/netbox-shell.md
  5. 34 1
      docs/configuration/miscellaneous.md
  6. 8 0
      docs/configuration/remote-authentication.md
  7. 2 0
      docs/customization/custom-scripts.md
  8. 4 0
      docs/installation/3-netbox.md
  9. 1 1
      docs/installation/6-ldap.md
  10. 1 1
      docs/integrations/rest-api.md
  11. 36 0
      docs/release-notes/version-3.5.md
  12. 16 1
      netbox/circuits/views.py
  13. 3 2
      netbox/core/models/jobs.py
  14. 2 1
      netbox/dcim/api/views.py
  15. 16 0
      netbox/dcim/choices.py
  16. 8 2
      netbox/dcim/forms/bulk_edit.py
  17. 25 3
      netbox/dcim/forms/bulk_import.py
  18. 1 1
      netbox/dcim/forms/model_forms.py
  19. 1 0
      netbox/dcim/forms/object_create.py
  20. 42 0
      netbox/dcim/migrations/0172_larger_power_draw_values.py
  21. 2 2
      netbox/dcim/models/device_component_templates.py
  22. 2 2
      netbox/dcim/models/device_components.py
  23. 4 0
      netbox/dcim/models/devices.py
  24. 10 5
      netbox/dcim/svg/racks.py
  25. 14 3
      netbox/dcim/tables/devices.py
  26. 32 3
      netbox/dcim/tests/test_views.py
  27. 41 0
      netbox/dcim/views.py
  28. 1 1
      netbox/extras/admin.py
  29. 2 1
      netbox/extras/dashboard/widgets.py
  30. 17 1
      netbox/extras/migrations/0066_customfield_name_validation.py
  31. 6 0
      netbox/extras/models/customfields.py
  32. 3 0
      netbox/extras/tables/tables.py
  33. 11 0
      netbox/extras/tests/test_customfields.py
  34. 3 1
      netbox/extras/webhooks.py
  35. 12 0
      netbox/ipam/forms/model_forms.py
  36. 8 0
      netbox/ipam/models/ip.py
  37. 24 10
      netbox/ipam/views.py
  38. 5 2
      netbox/netbox/authentication.py
  39. 11 0
      netbox/netbox/config/parameters.py
  40. 49 3
      netbox/netbox/middleware.py
  41. 10 1
      netbox/netbox/settings.py
  42. 44 0
      netbox/netbox/tests/test_authentication.py
  43. 2 2
      netbox/templates/base/layout.html
  44. 0 1
      netbox/templates/circuits/circuit.html
  45. 10 3
      netbox/templates/circuits/inc/circuit_termination.html
  46. 0 1
      netbox/templates/circuits/provider.html
  47. 0 1
      netbox/templates/circuits/provideraccount.html
  48. 23 1
      netbox/templates/dcim/device.html
  49. 2 2
      netbox/templates/dcim/interface.html
  50. 0 1
      netbox/templates/dcim/location.html
  51. 0 1
      netbox/templates/dcim/manufacturer.html
  52. 0 1
      netbox/templates/dcim/powerpanel.html
  53. 0 1
      netbox/templates/dcim/rack.html
  54. 0 1
      netbox/templates/dcim/region.html
  55. 14 11
      netbox/templates/dcim/site.html
  56. 0 1
      netbox/templates/dcim/sitegroup.html
  57. 0 63
      netbox/templates/inc/panels/contacts.html
  58. 9 2
      netbox/templates/inc/panels/custom_fields.html
  59. 0 8
      netbox/templates/ipam/ipaddress.html
  60. 8 0
      netbox/templates/ipam/ipaddress/base.html
  61. 19 0
      netbox/templates/ipam/ipaddress/ip_addresses.html
  62. 0 1
      netbox/templates/ipam/l2vpn.html
  63. 27 0
      netbox/templates/tenancy/object_contacts.html
  64. 0 1
      netbox/templates/tenancy/tenant.html
  65. 0 1
      netbox/templates/virtualization/cluster.html
  66. 0 1
      netbox/templates/virtualization/clustergroup.html
  67. 0 1
      netbox/templates/virtualization/virtualmachine.html
  68. 1 1
      netbox/templates/virtualization/vminterface.html
  69. 30 2
      netbox/tenancy/views.py
  70. 4 4
      netbox/utilities/forms/mixins.py
  71. 13 1
      netbox/utilities/rqworker.py
  72. 1 1
      netbox/virtualization/forms/bulk_import.py
  73. 17 1
      netbox/virtualization/views.py
  74. 7 7
      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.5.1
+      placeholder: v3.5.2
     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.5.1
+      placeholder: v3.5.2
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 6 - 4
README.md

@@ -1,11 +1,13 @@
 <div align="center">
 <div align="center">
-  <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
+  <strong>The :ballot_box_with_check: <a href="https://forms.gle/zUHrrPo7K34yKaqC9">2023 NetBox Community Survey</a> is now open!</strong>
+  <p>Please take a few minutes to tell us about your NetBox deployment.</p>
 
 
-  The premiere source of truth powering network automation
+  <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
+  <p>The premiere source of truth powering network automation</p>
+  <img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
+  <p></p>
 </div>
 </div>
 
 
-![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
-
 NetBox is the leading solution for modeling and documenting modern networks. By
 NetBox is the leading solution for modeling and documenting modern networks. By
 combining the traditional disciplines of IP address management (IPAM) and
 combining the traditional disciplines of IP address management (IPAM) and
 datacenter infrastructure management (DCIM) with powerful APIs and extensions,
 datacenter infrastructure management (DCIM) with powerful APIs and extensions,

+ 2 - 6
docs/administration/netbox-shell.md

@@ -153,15 +153,10 @@ New objects can be created by instantiating the desired model, defining values f
 ```
 ```
 >>> lab1 = Site.objects.get(pk=7)
 >>> lab1 = Site.objects.get(pk=7)
 >>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1)
 >>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1)
+>>> myvlan.full_clean()
 >>> myvlan.save()
 >>> myvlan.save()
 ```
 ```
 
 
-Alternatively, the above can be performed as a single operation. (Note, however, that `save()` does _not_ return the new instance for reuse.)
-
-```
->>> VLAN(vid=123, name='MyNewVLAN', site=Site.objects.get(pk=7)).save()
-```
-
 To modify an existing object, we retrieve it, update the desired field(s), and call `save()` again.
 To modify an existing object, we retrieve it, update the desired field(s), and call `save()` again.
 
 
 ```
 ```
@@ -169,6 +164,7 @@ To modify an existing object, we retrieve it, update the desired field(s), and c
 >>> vlan.name
 >>> vlan.name
 'MyNewVLAN'
 'MyNewVLAN'
 >>> vlan.name = 'BetterName'
 >>> vlan.name = 'BetterName'
+>>> vlan.full_clean()
 >>> vlan.save()
 >>> vlan.save()
 >>> VLAN.objects.get(pk=1280).name
 >>> VLAN.objects.get(pk=1280).name
 'BetterName'
 'BetterName'

+ 34 - 1
docs/configuration/miscellaneous.md

@@ -29,6 +29,17 @@ This defines custom content to be displayed on the login page above the login fo
 
 
 ---
 ---
 
 
+## BANNER_MAINTENANCE
+
+!!! tip "Dynamic Configuration Parameter"
+
+!!! note
+    This parameter was added in NetBox v3.5.
+
+This adds a banner to the top of every page when maintenance mode is enabled. HTML is allowed.
+
+---
+
 ## BANNER_TOP
 ## BANNER_TOP
 
 
 !!! tip "Dynamic Configuration Parameter"
 !!! tip "Dynamic Configuration Parameter"
@@ -129,7 +140,7 @@ Setting this to True will display a "maintenance mode" banner at the top of ever
 
 
 Default: `https://maps.google.com/?q=` (Google Maps)
 Default: `https://maps.google.com/?q=` (Google Maps)
 
 
-This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
+This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. Set this to `None` to disable the "map it" button within the UI.
 
 
 ---
 ---
 
 
@@ -193,3 +204,25 @@ This parameter defines the URL of the repository that will be checked for new Ne
 Default: `300`
 Default: `300`
 
 
 The maximum execution time of a background task (such as running a custom script), in seconds.
 The maximum execution time of a background task (such as running a custom script), in seconds.
+
+---
+
+## RQ_RETRY_INTERVAL
+
+!!! note
+    This parameter was added in NetBox v3.5.
+
+Default: `60`
+
+This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour.
+
+---
+
+## RQ_RETRY_MAX
+
+!!! note
+    This parameter was added in NetBox v3.5.
+
+Default: `0` (retries disabled)
+
+The maximum number of times a background task will be retried before being marked as failed.

+ 8 - 0
docs/configuration/remote-authentication.md

@@ -4,6 +4,14 @@ The configuration parameters listed here control remote authentication for NetBo
 
 
 ---
 ---
 
 
+## REMOTE_AUTH_AUTO_CREATE_GROUPS
+
+Default: `False`
+
+If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
 ## REMOTE_AUTH_AUTO_CREATE_USER
 ## REMOTE_AUTH_AUTO_CREATE_USER
 
 
 Default: `False`
 Default: `False`

+ 2 - 0
docs/customization/custom-scripts.md

@@ -378,6 +378,7 @@ class NewBranchScript(Script):
             slug=slugify(data['site_name']),
             slug=slugify(data['site_name']),
             status=SiteStatusChoices.STATUS_PLANNED
             status=SiteStatusChoices.STATUS_PLANNED
         )
         )
+        site.full_clean()
         site.save()
         site.save()
         self.log_success(f"Created new site: {site}")
         self.log_success(f"Created new site: {site}")
 
 
@@ -391,6 +392,7 @@ class NewBranchScript(Script):
                 status=DeviceStatusChoices.STATUS_PLANNED,
                 status=DeviceStatusChoices.STATUS_PLANNED,
                 device_role=switch_role
                 device_role=switch_role
             )
             )
+            switch.full_clean()
             switch.save()
             switch.save()
             self.log_success(f"Created new switch: {switch}")
             self.log_success(f"Created new switch: {switch}")
 
 

+ 4 - 0
docs/installation/3-netbox.md

@@ -100,6 +100,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
     ```
     ```
     sudo adduser --system --group netbox
     sudo adduser --system --group netbox
     sudo chown --recursive netbox /opt/netbox/netbox/media/
     sudo chown --recursive netbox /opt/netbox/netbox/media/
+    sudo chown --recursive netbox /opt/netbox/netbox/reports/
+    sudo chown --recursive netbox /opt/netbox/netbox/scripts/
     ```
     ```
 
 
 === "CentOS"
 === "CentOS"
@@ -108,6 +110,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
     sudo groupadd --system netbox
     sudo groupadd --system netbox
     sudo adduser --system -g netbox netbox
     sudo adduser --system -g netbox netbox
     sudo chown --recursive netbox /opt/netbox/netbox/media/
     sudo chown --recursive netbox /opt/netbox/netbox/media/
+    sudo chown --recursive netbox /opt/netbox/netbox/reports/
+    sudo chown --recursive netbox /opt/netbox/netbox/scripts/
     ```
     ```
 
 
 ## Configuration
 ## Configuration

+ 1 - 1
docs/installation/6-ldap.md

@@ -15,7 +15,7 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
 On CentOS:
 On CentOS:
 
 
 ```no-highlight
 ```no-highlight
-sudo yum install -y openldap-devel
+sudo yum install -y openldap-devel python3-devel
 ```
 ```
 
 
 ### Install django-auth-ldap
 ### Install django-auth-ldap

+ 1 - 1
docs/integrations/rest-api.md

@@ -63,7 +63,7 @@ Each attribute of the IP address is expressed as an attribute of the JSON object
 
 
 ## Interactive Documentation
 ## Interactive Documentation
 
 
-Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
+Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/schema/swagger-ui/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
 
 
 ## Endpoint Hierarchy
 ## Endpoint Hierarchy
 
 

+ 36 - 0
docs/release-notes/version-3.5.md

@@ -1,5 +1,41 @@
 # NetBox v3.5
 # NetBox v3.5
 
 
+## v3.5.2 (2023-05-22)
+
+### Enhancements
+
+* [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use
+* [#9068](https://github.com/netbox-community/netbox/issues/9068) - Disallow the assignment of network/broadcast IP addresses to interfaces
+* [#11017](https://github.com/netbox-community/netbox/issues/11017) - Increase the maximum values for allocated and maximum power draws
+* [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled
+* [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views
+* [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import
+* [#11900](https://github.com/netbox-community/netbox/issues/11900) - Add an outline to the reservation markers on rack elevations
+* [#12131](https://github.com/netbox-community/netbox/issues/12131) - Show custom field description as an icon tooltip under object views
+* [#12223](https://github.com/netbox-community/netbox/issues/12223) - Add columns for parent device bay and position to devices list
+* [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab
+* [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view
+* [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type
+* [#12327](https://github.com/netbox-community/netbox/issues/12327) - Introduce the ability to automatically retry failed background jobs
+* [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty
+* [#12548](https://github.com/netbox-community/netbox/issues/12548) - Optimize REST API performance when retrieving interfaces with L2VPN assignments
+* [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner
+* [#12605](https://github.com/netbox-community/netbox/issues/12605) - Add LX.5 port types
+* [#12629](https://github.com/netbox-community/netbox/issues/12629) - Add 400GE CDFP and CFP8 interface types
+* [#12678](https://github.com/netbox-community/netbox/issues/12678) - Add 200GE QSFP-DD interface type
+
+### Bug Fixes
+
+* [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables
+* [#11619](https://github.com/netbox-community/netbox/issues/11619) - Enable assigning VLANs without a site to interfaces during bulk edit
+* [#12468](https://github.com/netbox-community/netbox/issues/12468) - Custom field names should not permit double underscores
+* [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form
+* [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute
+* [#12594](https://github.com/netbox-community/netbox/issues/12594) - Enable selecting config context as object type in object counts dashboard widget
+* [#12642](https://github.com/netbox-community/netbox/issues/12642) - Fix bulk tenant assignment via cluster import form
+
+---
+
 ## v3.5.1 (2023-05-05)
 ## v3.5.1 (2023-05-05)
 
 
 ### Enhancements
 ### Enhancements

+ 16 - 1
netbox/circuits/views.py

@@ -1,10 +1,10 @@
 from django.contrib import messages
 from django.contrib import messages
 from django.db import transaction
 from django.db import transaction
-from django.db.models import Q
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 
 
 from dcim.views import PathTraceView
 from dcim.views import PathTraceView
 from netbox.views import generic
 from netbox.views import generic
+from tenancy.views import ObjectContactsView
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.utils import count_related
 from utilities.utils import count_related
 from utilities.views import register_model_view
 from utilities.views import register_model_view
@@ -73,6 +73,11 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
     table = tables.ProviderTable
     table = tables.ProviderTable
 
 
 
 
+@register_model_view(Provider, 'contacts')
+class ProviderContactsView(ObjectContactsView):
+    queryset = Provider.objects.all()
+
+
 #
 #
 # ProviderAccounts
 # ProviderAccounts
 #
 #
@@ -134,6 +139,11 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
     table = tables.ProviderAccountTable
     table = tables.ProviderAccountTable
 
 
 
 
+@register_model_view(ProviderAccount, 'contacts')
+class ProviderAccountContactsView(ObjectContactsView):
+    queryset = ProviderAccount.objects.all()
+
+
 #
 #
 # Provider networks
 # Provider networks
 #
 #
@@ -389,6 +399,11 @@ class CircuitSwapTerminations(generic.ObjectEditView):
         })
         })
 
 
 
 
+@register_model_view(Circuit, 'contacts')
+class CircuitContactsView(ObjectContactsView):
+    queryset = Circuit.objects.all()
+
+
 #
 #
 # Circuit terminations
 # Circuit terminations
 #
 #

+ 3 - 2
netbox/core/models/jobs.py

@@ -16,7 +16,7 @@ from extras.utils import FeatureQuery
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.constants import RQ_QUEUE_DEFAULT
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
-from utilities.rqworker import get_queue_for_model
+from utilities.rqworker import get_queue_for_model, get_rq_retry
 
 
 __all__ = (
 __all__ = (
     'Job',
     'Job',
@@ -219,5 +219,6 @@ class Job(models.Model):
                 event=event,
                 event=event,
                 data=self.data,
                 data=self.data,
                 timestamp=str(timezone.now()),
                 timestamp=str(timezone.now()),
-                username=self.user.username
+                username=self.user.username,
+                retry=get_rq_retry()
             )
             )

+ 2 - 1
netbox/dcim/api/views.py

@@ -493,7 +493,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = Interface.objects.prefetch_related(
     queryset = Interface.objects.prefetch_related(
         'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
         'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
-        'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
+        'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations',
+        'vdcs',
     )
     )
     serializer_class = serializers.InterfaceSerializer
     serializer_class = serializers.InterfaceSerializer
     filterset_class = filtersets.InterfaceFilterSet
     filterset_class = filtersets.InterfaceFilterSet

+ 16 - 0
netbox/dcim/choices.py

@@ -807,12 +807,16 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_100GE_CFP = '100gbase-x-cfp'
     TYPE_100GE_CFP = '100gbase-x-cfp'
     TYPE_100GE_CFP2 = '100gbase-x-cfp2'
     TYPE_100GE_CFP2 = '100gbase-x-cfp2'
     TYPE_100GE_CFP4 = '100gbase-x-cfp4'
     TYPE_100GE_CFP4 = '100gbase-x-cfp4'
+    TYPE_100GE_CXP = '100gbase-x-cxp'
     TYPE_100GE_CPAK = '100gbase-x-cpak'
     TYPE_100GE_CPAK = '100gbase-x-cpak'
     TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
     TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
     TYPE_200GE_CFP2 = '200gbase-x-cfp2'
     TYPE_200GE_CFP2 = '200gbase-x-cfp2'
     TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
     TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
+    TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
     TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
     TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
     TYPE_400GE_OSFP = '400gbase-x-osfp'
     TYPE_400GE_OSFP = '400gbase-x-osfp'
+    TYPE_400GE_CDFP = '400gbase-x-cdfp'
+    TYPE_400GE_CFP8 = '400gbase-x-cfp8'
     TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
     TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
     TYPE_800GE_OSFP = '800gbase-x-osfp'
     TYPE_800GE_OSFP = '800gbase-x-osfp'
 
 
@@ -952,11 +956,15 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_100GE_CFP2, 'CFP2 (100GE)'),
                 (TYPE_100GE_CFP2, 'CFP2 (100GE)'),
                 (TYPE_200GE_CFP2, 'CFP2 (200GE)'),
                 (TYPE_200GE_CFP2, 'CFP2 (200GE)'),
                 (TYPE_100GE_CFP4, 'CFP4 (100GE)'),
                 (TYPE_100GE_CFP4, 'CFP4 (100GE)'),
+                (TYPE_100GE_CXP, 'CXP (100GE)'),
                 (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
                 (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
                 (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
                 (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
                 (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
                 (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
+                (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
                 (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
                 (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
                 (TYPE_400GE_OSFP, 'OSFP (400GE)'),
                 (TYPE_400GE_OSFP, 'OSFP (400GE)'),
+                (TYPE_400GE_CDFP, 'CDFP (400GE)'),
+                (TYPE_400GE_CFP8, 'CPF8 (400GE)'),
                 (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
                 (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
                 (TYPE_800GE_OSFP, 'OSFP (800GE)'),
                 (TYPE_800GE_OSFP, 'OSFP (800GE)'),
             )
             )
@@ -1221,6 +1229,10 @@ class PortTypeChoices(ChoiceSet):
     TYPE_LSH_PC = 'lsh-pc'
     TYPE_LSH_PC = 'lsh-pc'
     TYPE_LSH_UPC = 'lsh-upc'
     TYPE_LSH_UPC = 'lsh-upc'
     TYPE_LSH_APC = 'lsh-apc'
     TYPE_LSH_APC = 'lsh-apc'
+    TYPE_LX5 = 'lx5'
+    TYPE_LX5_PC = 'lx5-pc'
+    TYPE_LX5_UPC = 'lx5-upc'
+    TYPE_LX5_APC = 'lx5-apc'
     TYPE_SPLICE = 'splice'
     TYPE_SPLICE = 'splice'
     TYPE_CS = 'cs'
     TYPE_CS = 'cs'
     TYPE_SN = 'sn'
     TYPE_SN = 'sn'
@@ -1267,6 +1279,10 @@ class PortTypeChoices(ChoiceSet):
                 (TYPE_LSH_PC, 'LSH/PC'),
                 (TYPE_LSH_PC, 'LSH/PC'),
                 (TYPE_LSH_UPC, 'LSH/UPC'),
                 (TYPE_LSH_UPC, 'LSH/UPC'),
                 (TYPE_LSH_APC, 'LSH/APC'),
                 (TYPE_LSH_APC, 'LSH/APC'),
+                (TYPE_LX5, 'LX.5'),
+                (TYPE_LX5_PC, 'LX.5/PC'),
+                (TYPE_LX5_UPC, 'LX.5/UPC'),
+                (TYPE_LX5_APC, 'LX.5/APC'),
                 (TYPE_MPO, 'MPO'),
                 (TYPE_MPO, 'MPO'),
                 (TYPE_MTRJ, 'MTRJ'),
                 (TYPE_MTRJ, 'MTRJ'),
                 (TYPE_SC, 'SC'),
                 (TYPE_SC, 'SC'),

+ 8 - 2
netbox/dcim/forms/bulk_edit.py

@@ -1,4 +1,5 @@
 from django import forms
 from django import forms
+from django.conf import settings
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from timezone_field import TimeZoneFormField
 from timezone_field import TimeZoneFormField
@@ -1292,8 +1293,13 @@ class InterfaceBulkEditForm(
                         break
                         break
 
 
                 if site is not None:
                 if site is not None:
-                    self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
-                    self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
+                    # Query for VLANs assigned to the same site and VLANs with no site assigned (null).
+                    self.fields['untagged_vlan'].widget.add_query_param(
+                        'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
+                    )
+                    self.fields['tagged_vlans'].widget.add_query_param(
+                        'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
+                    )
 
 
             self.fields['parent'].choices = ()
             self.fields['parent'].choices = ()
             self.fields['parent'].widget.attrs['disabled'] = True
             self.fields['parent'].widget.attrs['disabled'] = True

+ 25 - 3
netbox/dcim/forms/bulk_import.py

@@ -292,12 +292,21 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
         required=False,
         required=False,
         help_text=_('The default platform for devices of this type (optional)')
         help_text=_('The default platform for devices of this type (optional)')
     )
     )
+    weight = forms.DecimalField(
+        required=False,
+        help_text=_('Device weight'),
+    )
+    weight_unit = CSVChoiceField(
+        choices=WeightUnitChoices,
+        required=False,
+        help_text=_('Unit for device weight')
+    )
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
             'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
             'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
-            'subdevice_role', 'airflow', 'description', 'comments',
+            'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments',
         ]
         ]
 
 
 
 
@@ -306,10 +315,19 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         to_field_name='name'
         to_field_name='name'
     )
     )
+    weight = forms.DecimalField(
+        required=False,
+        help_text=_('Module weight'),
+    )
+    weight_unit = CSVChoiceField(
+        choices=WeightUnitChoices,
+        required=False,
+        help_text=_('Unit for module weight')
+    )
 
 
     class Meta:
     class Meta:
         model = ModuleType
         model = ModuleType
-        fields = ['manufacturer', 'model', 'part_number', 'description', 'comments']
+        fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments']
 
 
 
 
 class DeviceRoleImportForm(NetBoxModelImportForm):
 class DeviceRoleImportForm(NetBoxModelImportForm):
@@ -1060,7 +1078,11 @@ class CableImportForm(NetBoxModelImportForm):
 
 
         model = content_type.model_class()
         model = content_type.model_class()
         try:
         try:
-            termination_object = model.objects.get(device=device, name=name)
+            if device.virtual_chassis and device.virtual_chassis.master == device and \
+                    model.objects.filter(device=device, name=name).count() == 0:
+                termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
+            else:
+                termination_object = model.objects.get(device=device, name=name)
             if termination_object.cable is not None:
             if termination_object.cable is not None:
                 raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
                 raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:

+ 1 - 1
netbox/dcim/forms/model_forms.py

@@ -1214,7 +1214,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
     installed_device = forms.ModelChoiceField(
     installed_device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         label=_('Child Device'),
         label=_('Child Device'),
-        help_text=_("Child devices must first be created and assigned to the site/rack of the parent device.")
+        help_text=_("Child devices must first be created and assigned to the site and rack of the parent device.")
     )
     )
 
 
     def __init__(self, device_bay, *args, **kwargs):
     def __init__(self, device_bay, *args, **kwargs):

+ 1 - 0
netbox/dcim/forms/object_create.py

@@ -242,6 +242,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
         choices=[],
         choices=[],
         label=_('Rear ports'),
         label=_('Rear ports'),
         help_text=_('Select one rear port assignment for each front port being created.'),
         help_text=_('Select one rear port assignment for each front port being created.'),
+        widget=forms.SelectMultiple(attrs={'size': 6})
     )
     )
 
 
     # Override fieldsets from FrontPortForm to omit rear_port_position
     # Override fieldsets from FrontPortForm to omit rear_port_position

+ 42 - 0
netbox/dcim/migrations/0172_larger_power_draw_values.py

@@ -0,0 +1,42 @@
+# Generated by Django 4.1.9 on 2023-05-12 18:46
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0171_cabletermination_change_logging'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='powerport',
+            name='allocated_draw',
+            field=models.PositiveIntegerField(
+                blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
+            ),
+        ),
+        migrations.AlterField(
+            model_name='powerport',
+            name='maximum_draw',
+            field=models.PositiveIntegerField(
+                blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
+            ),
+        ),
+        migrations.AlterField(
+            model_name='powerporttemplate',
+            name='allocated_draw',
+            field=models.PositiveIntegerField(
+                blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
+            ),
+        ),
+        migrations.AlterField(
+            model_name='powerporttemplate',
+            name='maximum_draw',
+            field=models.PositiveIntegerField(
+                blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
+            ),
+        ),
+    ]

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

@@ -232,13 +232,13 @@ class PowerPortTemplate(ModularComponentTemplateModel):
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
         blank=True
         blank=True
     )
     )
-    maximum_draw = models.PositiveSmallIntegerField(
+    maximum_draw = models.PositiveIntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=[MinValueValidator(1)],
         validators=[MinValueValidator(1)],
         help_text=_("Maximum power draw (watts)")
         help_text=_("Maximum power draw (watts)")
     )
     )
-    allocated_draw = models.PositiveSmallIntegerField(
+    allocated_draw = models.PositiveIntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=[MinValueValidator(1)],
         validators=[MinValueValidator(1)],

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

@@ -329,13 +329,13 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
         blank=True,
         blank=True,
         help_text=_('Physical port type')
         help_text=_('Physical port type')
     )
     )
-    maximum_draw = models.PositiveSmallIntegerField(
+    maximum_draw = models.PositiveIntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=[MinValueValidator(1)],
         validators=[MinValueValidator(1)],
         help_text=_("Maximum power draw (watts)")
         help_text=_("Maximum power draw (watts)")
     )
     )
-    allocated_draw = models.PositiveSmallIntegerField(
+    allocated_draw = models.PositiveIntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=[MinValueValidator(1)],
         validators=[MinValueValidator(1)],

+ 4 - 0
netbox/dcim/models/devices.py

@@ -184,6 +184,8 @@ class DeviceType(PrimaryModel, WeightMixin):
             'subdevice_role': self.subdevice_role,
             'subdevice_role': self.subdevice_role,
             'airflow': self.airflow,
             'airflow': self.airflow,
             'comments': self.comments,
             'comments': self.comments,
+            'weight': float(self.weight) if self.weight is not None else None,
+            'weight_unit': self.weight_unit,
         }
         }
 
 
         # Component templates
         # Component templates
@@ -361,6 +363,8 @@ class ModuleType(PrimaryModel, WeightMixin):
             'model': self.model,
             'model': self.model,
             'part_number': self.part_number,
             'part_number': self.part_number,
             'comments': self.comments,
             'comments': self.comments,
+            'weight': float(self.weight) if self.weight is not None else None,
+            'weight_unit': self.weight_unit,
         }
         }
 
 
         # Component templates
         # Component templates

+ 10 - 5
netbox/dcim/svg/racks.py

@@ -22,6 +22,11 @@ __all__ = (
     'RackElevationSVG',
     'RackElevationSVG',
 )
 )
 
 
+GRADIENT_RESERVED = '#b0b0ff'
+GRADIENT_OCCUPIED = '#d7d7d7'
+GRADIENT_BLOCKED = '#ffc0c0'
+STROKE_RESERVED = '#4d4dff'
+
 
 
 def get_device_name(device):
 def get_device_name(device):
     if device.virtual_chassis:
     if device.virtual_chassis:
@@ -132,9 +137,9 @@ class RackElevationSVG:
             drawing.defs.add(drawing.style(css_file.read()))
             drawing.defs.add(drawing.style(css_file.read()))
 
 
         # Add gradients
         # Add gradients
-        RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff')
-        RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
-        RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
+        RackElevationSVG._add_gradient(drawing, 'reserved', GRADIENT_RESERVED)
+        RackElevationSVG._add_gradient(drawing, 'occupied', GRADIENT_OCCUPIED)
+        RackElevationSVG._add_gradient(drawing, 'blocked', GRADIENT_BLOCKED)
 
 
         return drawing
         return drawing
 
 
@@ -246,13 +251,13 @@ class RackElevationSVG:
                 coords = self._get_device_coords(segment[0], u_height)
                 coords = self._get_device_coords(segment[0], u_height)
                 coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
                 coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
                 size = (
                 size = (
-                    self.margin_width,
+                    self.margin_width - 3,
                     u_height * self.unit_height
                     u_height * self.unit_height
                 )
                 )
                 link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent')
                 link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent')
                 link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
                 link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
                 link.add(
                 link.add(
-                    Rect(coords, size, class_='reservation')
+                    Rect(coords, size, class_='reservation', stroke=STROKE_RESERVED, stroke_width=2)
                 )
                 )
                 self.drawing.add(link)
                 self.drawing.add(link)
 
 

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

@@ -216,6 +216,16 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     config_template = tables.Column(
     config_template = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    parent_device = tables.Column(
+        verbose_name='Parent Device',
+        linkify=True,
+        accessor='parent_bay__device'
+    )
+    device_bay_position = tables.Column(
+        verbose_name='Position (Device Bay)',
+        accessor='parent_bay',
+        linkify=True
+    )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:device_list'
         url_name='dcim:device_list'
@@ -225,9 +235,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         model = models.Device
         model = models.Device
         fields = (
         fields = (
             'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
             'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
-            'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
-            'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
-            'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
+            'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
+            'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster',
+            'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', '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',

+ 32 - 3
netbox/dcim/tests/test_views.py

@@ -681,11 +681,15 @@ class DeviceTypeTestCase(
         """
         """
         IMPORT_DATA = """
         IMPORT_DATA = """
 manufacturer: Generic
 manufacturer: Generic
-default_platform: Platform
 model: TEST-1000
 model: TEST-1000
 slug: test-1000
 slug: test-1000
+default_platform: Platform
 u_height: 2
 u_height: 2
+is_full_depth: false
+airflow: front-to-rear
 subdevice_role: parent
 subdevice_role: parent
+weight: 10
+weight_unit: kg
 comments: Test comment
 comments: Test comment
 console-ports:
 console-ports:
   - name: Console Port 1
   - name: Console Port 1
@@ -794,8 +798,16 @@ inventory-items:
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
 
 
         device_type = DeviceType.objects.get(model='TEST-1000')
         device_type = DeviceType.objects.get(model='TEST-1000')
-        self.assertEqual(device_type.comments, 'Test comment')
+        self.assertEqual(device_type.manufacturer.pk, manufacturer.pk)
         self.assertEqual(device_type.default_platform.pk, platform.pk)
         self.assertEqual(device_type.default_platform.pk, platform.pk)
+        self.assertEqual(device_type.slug, 'test-1000')
+        self.assertEqual(device_type.u_height, 2)
+        self.assertFalse(device_type.is_full_depth)
+        self.assertEqual(device_type.airflow, DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR)
+        self.assertEqual(device_type.subdevice_role, SubdeviceRoleChoices.ROLE_PARENT)
+        self.assertEqual(device_type.weight, 10)
+        self.assertEqual(device_type.weight_unit, WeightUnitChoices.UNIT_KILOGRAM)
+        self.assertEqual(device_type.comments, 'Test comment')
 
 
         # Verify all of the components were created
         # Verify all of the components were created
         self.assertEqual(device_type.consoleporttemplates.count(), 3)
         self.assertEqual(device_type.consoleporttemplates.count(), 3)
@@ -1019,6 +1031,8 @@ class ModuleTypeTestCase(
         IMPORT_DATA = """
         IMPORT_DATA = """
 manufacturer: Generic
 manufacturer: Generic
 model: TEST-1000
 model: TEST-1000
+weight: 10
+weight_unit: lb
 comments: Test comment
 comments: Test comment
 console-ports:
 console-ports:
   - name: Console Port 1
   - name: Console Port 1
@@ -1082,7 +1096,8 @@ front-ports:
 """
 """
 
 
         # Create the manufacturer
         # Create the manufacturer
-        Manufacturer(name='Generic', slug='generic').save()
+        manufacturer = Manufacturer(name='Generic', slug='generic')
+        manufacturer.save()
 
 
         # Add all required permissions to the test user
         # Add all required permissions to the test user
         self.add_permissions(
         self.add_permissions(
@@ -1105,6 +1120,9 @@ front-ports:
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
 
 
         module_type = ModuleType.objects.get(model='TEST-1000')
         module_type = ModuleType.objects.get(model='TEST-1000')
+        self.assertEqual(module_type.manufacturer.pk, manufacturer.pk)
+        self.assertEqual(module_type.weight, 10)
+        self.assertEqual(module_type.weight_unit, WeightUnitChoices.UNIT_POUND)
         self.assertEqual(module_type.comments, 'Test comment')
         self.assertEqual(module_type.comments, 'Test comment')
 
 
         # Verify all the components were created
         # Verify all the components were created
@@ -2889,6 +2907,7 @@ class CableTestCase(
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
         devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
         devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
         devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        vc = VirtualChassis.objects.create(name='Virtual Chassis')
 
 
         devices = (
         devices = (
             Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
             Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
@@ -2898,6 +2917,10 @@ class CableTestCase(
         )
         )
         Device.objects.bulk_create(devices)
         Device.objects.bulk_create(devices)
 
 
+        vc.members.set((devices[0], devices[1], devices[2]))
+        vc.master = devices[0]
+        vc.save()
+
         interfaces = (
         interfaces = (
             Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
@@ -2911,6 +2934,10 @@ class CableTestCase(
             Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[1], name='Device 2 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[2], name='Device 3 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
         )
         )
         Interface.objects.bulk_create(interfaces)
         Interface.objects.bulk_create(interfaces)
 
 
@@ -2943,6 +2970,8 @@ class CableTestCase(
             "Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
             "Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
             "Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
             "Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
             "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
             "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
+            "Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
+            "Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
         )
         )
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (

+ 41 - 0
netbox/dcim/views.py

@@ -20,6 +20,7 @@ from extras.views import ObjectConfigContextView
 from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
 from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
 from ipam.tables import InterfaceVLANTable
 from ipam.tables import InterfaceVLANTable
 from netbox.views import generic
 from netbox.views import generic
+from tenancy.views import ObjectContactsView
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
@@ -267,6 +268,11 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
     table = tables.RegionTable
     table = tables.RegionTable
 
 
 
 
+@register_model_view(Region, 'contacts')
+class RegionContactsView(ObjectContactsView):
+    queryset = Region.objects.all()
+
+
 #
 #
 # Site groups
 # Site groups
 #
 #
@@ -342,6 +348,11 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
     table = tables.SiteGroupTable
     table = tables.SiteGroupTable
 
 
 
 
+@register_model_view(SiteGroup, 'contacts')
+class SiteGroupContactsView(ObjectContactsView):
+    queryset = SiteGroup.objects.all()
+
+
 #
 #
 # Sites
 # Sites
 #
 #
@@ -435,6 +446,11 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
     table = tables.SiteTable
     table = tables.SiteTable
 
 
 
 
+@register_model_view(Site, 'contacts')
+class SiteContactsView(ObjectContactsView):
+    queryset = Site.objects.all()
+
+
 #
 #
 # Locations
 # Locations
 #
 #
@@ -523,6 +539,11 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
     table = tables.LocationTable
     table = tables.LocationTable
 
 
 
 
+@register_model_view(Location, 'contacts')
+class LocationContactsView(ObjectContactsView):
+    queryset = Location.objects.all()
+
+
 #
 #
 # Rack roles
 # Rack roles
 #
 #
@@ -740,6 +761,11 @@ class RackBulkDeleteView(generic.BulkDeleteView):
     table = tables.RackTable
     table = tables.RackTable
 
 
 
 
+@register_model_view(Rack, 'contacts')
+class RackContactsView(ObjectContactsView):
+    queryset = Rack.objects.all()
+
+
 #
 #
 # Rack reservations
 # Rack reservations
 #
 #
@@ -874,6 +900,11 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
     table = tables.ManufacturerTable
     table = tables.ManufacturerTable
 
 
 
 
+@register_model_view(Manufacturer, 'contacts')
+class ManufacturerContactsView(ObjectContactsView):
+    queryset = Manufacturer.objects.all()
+
+
 #
 #
 # Device types
 # Device types
 #
 #
@@ -2088,6 +2119,11 @@ class DeviceBulkRenameView(generic.BulkRenameView):
     table = tables.DeviceTable
     table = tables.DeviceTable
 
 
 
 
+@register_model_view(Device, 'contacts')
+class DeviceContactsView(ObjectContactsView):
+    queryset = Device.objects.all()
+
+
 #
 #
 # Modules
 # Modules
 #
 #
@@ -3469,6 +3505,11 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
     table = tables.PowerPanelTable
     table = tables.PowerPanelTable
 
 
 
 
+@register_model_view(PowerPanel, 'contacts')
+class PowerPanelContactsView(ObjectContactsView):
+    queryset = PowerPanel.objects.all()
+
+
 #
 #
 # Power feeds
 # Power feeds
 #
 #

+ 1 - 1
netbox/extras/admin.py

@@ -25,7 +25,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
             'fields': ('ALLOWED_URL_SCHEMES',),
             'fields': ('ALLOWED_URL_SCHEMES',),
         }),
         }),
         ('Banners', {
         ('Banners', {
-            'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'),
+            'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'),
             'classes': ('monospace',),
             'classes': ('monospace',),
         }),
         }),
         ('Pagination', {
         ('Pagination', {

+ 2 - 1
netbox/extras/dashboard/widgets.py

@@ -35,7 +35,8 @@ def get_content_type_labels():
     return [
     return [
         (content_type_identifier(ct), content_type_name(ct))
         (content_type_identifier(ct), content_type_name(ct))
         for ct in ContentType.objects.filter(
         for ct in ContentType.objects.filter(
-            FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange')
+            FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') |
+            Q(app_label='extras', model='configcontext')
         ).order_by('app_label', 'model')
         ).order_by('app_label', 'model')
     ]
     ]
 
 

+ 17 - 1
netbox/extras/migrations/0066_customfield_name_validation.py

@@ -13,6 +13,22 @@ class Migration(migrations.Migration):
         migrations.AlterField(
         migrations.AlterField(
             model_name='customfield',
             model_name='customfield',
             name='name',
             name='name',
-            field=models.CharField(max_length=50, unique=True, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['IGNORECASE'], message='Only alphanumeric characters and underscores are allowed.', regex='^[a-z0-9_]+$')]),
+            field=models.CharField(
+                max_length=50,
+                unique=True,
+                validators=[
+                    django.core.validators.RegexValidator(
+                        flags=re.RegexFlag['IGNORECASE'],
+                        message='Only alphanumeric characters and underscores are allowed.',
+                        regex='^[a-z0-9_]+$',
+                    ),
+                    django.core.validators.RegexValidator(
+                        flags=re.RegexFlag['IGNORECASE'],
+                        inverse_match=True,
+                        message='Double underscores are not permitted in custom field names.',
+                        regex=r'__',
+                    ),
+                ],
+            ),
         ),
         ),
     ]
     ]

+ 6 - 0
netbox/extras/models/customfields.py

@@ -85,6 +85,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
                 message="Only alphanumeric characters and underscores are allowed.",
                 message="Only alphanumeric characters and underscores are allowed.",
                 flags=re.IGNORECASE
                 flags=re.IGNORECASE
             ),
             ),
+            RegexValidator(
+                regex=r'__',
+                message="Double underscores are not permitted in custom field names.",
+                flags=re.IGNORECASE,
+                inverse_match=True
+            ),
         )
         )
     )
     )
     label = models.CharField(
     label = models.CharField(

+ 3 - 0
netbox/extras/tables/tables.py

@@ -73,6 +73,7 @@ class ExportTemplateTable(NetBoxTable):
         linkify=True
         linkify=True
     )
     )
     is_synced = columns.BooleanColumn(
     is_synced = columns.BooleanColumn(
+        orderable=False,
         verbose_name='Synced'
         verbose_name='Synced'
     )
     )
 
 
@@ -218,6 +219,7 @@ class ConfigContextTable(NetBoxTable):
         verbose_name='Active'
         verbose_name='Active'
     )
     )
     is_synced = columns.BooleanColumn(
     is_synced = columns.BooleanColumn(
+        orderable=False,
         verbose_name='Synced'
         verbose_name='Synced'
     )
     )
 
 
@@ -242,6 +244,7 @@ class ConfigTemplateTable(NetBoxTable):
         linkify=True
         linkify=True
     )
     )
     is_synced = columns.BooleanColumn(
     is_synced = columns.BooleanColumn(
+        orderable=False,
         verbose_name='Synced'
         verbose_name='Synced'
     )
     )
     tags = columns.TagColumn(
     tags = columns.TagColumn(

+ 11 - 0
netbox/extras/tests/test_customfields.py

@@ -29,6 +29,17 @@ class CustomFieldTest(TestCase):
 
 
         cls.object_type = ContentType.objects.get_for_model(Site)
         cls.object_type = ContentType.objects.get_for_model(Site)
 
 
+    def test_invalid_name(self):
+        """
+        Try creating a CustomField with an invalid name.
+        """
+        with self.assertRaises(ValidationError):
+            # Invalid character
+            CustomField(name='?', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
+        with self.assertRaises(ValidationError):
+            # Double underscores not permitted
+            CustomField(name='foo__bar', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
+
     def test_text_field(self):
     def test_text_field(self):
         value = 'Foobar!'
         value = 'Foobar!'
 
 

+ 3 - 1
netbox/extras/webhooks.py

@@ -9,6 +9,7 @@ from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.registry import registry
 from netbox.registry import registry
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
+from utilities.rqworker import get_rq_retry
 from utilities.utils import serialize_object
 from utilities.utils import serialize_object
 from .choices import *
 from .choices import *
 from .models import Webhook
 from .models import Webhook
@@ -116,5 +117,6 @@ def flush_webhooks(queue):
                 snapshots=data['snapshots'],
                 snapshots=data['snapshots'],
                 timestamp=str(timezone.now()),
                 timestamp=str(timezone.now()),
                 username=data['username'],
                 username=data['username'],
-                request_id=data['request_id']
+                request_id=data['request_id'],
+                retry=get_rq_retry()
             )
             )

+ 12 - 0
netbox/ipam/forms/model_forms.py

@@ -351,6 +351,18 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
                 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
                 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
             )
             )
 
 
+        # Do not allow assigning a network ID or broadcast address to an interface.
+        if interface and (address := self.cleaned_data.get('address')):
+            if address.ip == address.network:
+                msg = f"{address} is a network ID, which may not be assigned to an interface."
+                if address.version == 4 and address.prefixlen not in (31, 32):
+                    raise ValidationError(msg)
+                if address.version == 6 and address.prefixlen not in (127, 128):
+                    raise ValidationError(msg)
+            if address.ip == address.broadcast:
+                msg = f"{address} is a broadcast address, which may not be assigned to an interface."
+                raise ValidationError(msg)
+
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         ipaddress = super().save(*args, **kwargs)
         ipaddress = super().save(*args, **kwargs)
 
 

+ 8 - 0
netbox/ipam/models/ip.py

@@ -783,6 +783,14 @@ class IPAddress(PrimaryModel):
                 if available_ips:
                 if available_ips:
                     return next(iter(available_ips))
                     return next(iter(available_ips))
 
 
+    def get_related_ips(self):
+        """
+        Return all IPAddresses belonging to the same VRF.
+        """
+        return IPAddress.objects.exclude(address=str(self.address)).filter(
+            vrf=self.vrf, address__net_contained_or_equal=str(self.address)
+        )
+
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 

+ 24 - 10
netbox/ipam/views.py

@@ -9,6 +9,7 @@ from circuits.models import Provider
 from dcim.filtersets import InterfaceFilterSet
 from dcim.filtersets import InterfaceFilterSet
 from dcim.models import Interface, Site
 from dcim.models import Interface, Site
 from netbox.views import generic
 from netbox.views import generic
+from tenancy.views import ObjectContactsView
 from utilities.utils import count_related
 from utilities.utils import count_related
 from utilities.views import ViewTab, register_model_view
 from utilities.views import ViewTab, register_model_view
 from virtualization.filtersets import VMInterfaceFilterSet
 from virtualization.filtersets import VMInterfaceFilterSet
@@ -755,19 +756,9 @@ class IPAddressView(generic.ObjectView):
         # Limit to a maximum of 10 duplicates displayed here
         # Limit to a maximum of 10 duplicates displayed here
         duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
         duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
 
 
-        # Related IP table
-        related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
-            address=str(instance.address)
-        ).filter(
-            vrf=instance.vrf, address__net_contained_or_equal=str(instance.address)
-        )
-        related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
-        related_ips_table.configure(request)
-
         return {
         return {
             'parent_prefixes_table': parent_prefixes_table,
             'parent_prefixes_table': parent_prefixes_table,
             'duplicate_ips_table': duplicate_ips_table,
             'duplicate_ips_table': duplicate_ips_table,
-            'related_ips_table': related_ips_table,
         }
         }
 
 
 
 
@@ -872,6 +863,24 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
     table = tables.IPAddressTable
     table = tables.IPAddressTable
 
 
 
 
+@register_model_view(IPAddress, 'related_ips', path='related-ip-addresses')
+class IPAddressRelatedIPsView(generic.ObjectChildrenView):
+    queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
+    child_model = IPAddress
+    table = tables.IPAddressTable
+    filterset = filtersets.IPAddressFilterSet
+    template_name = 'ipam/ipaddress/ip_addresses.html'
+    tab = ViewTab(
+        label=_('Related IPs'),
+        badge=lambda x: x.get_related_ips().count(),
+        weight=500,
+        hide_if_empty=True,
+    )
+
+    def get_children(self, request, parent):
+        return parent.get_related_ips().restrict(request.user, 'view')
+
+
 #
 #
 # VLAN groups
 # VLAN groups
 #
 #
@@ -1292,6 +1301,11 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView):
     table = tables.L2VPNTable
     table = tables.L2VPNTable
 
 
 
 
+@register_model_view(L2VPN, 'contacts')
+class L2VPNContactsView(ObjectContactsView):
+    queryset = L2VPN.objects.all()
+
+
 #
 #
 # L2VPN terminations
 # L2VPN terminations
 #
 #

+ 5 - 2
netbox/netbox/authentication.py

@@ -156,8 +156,11 @@ class RemoteUserBackend(_RemoteUserBackend):
             try:
             try:
                 group_list.append(Group.objects.get(name=name))
                 group_list.append(Group.objects.get(name=name))
             except Group.DoesNotExist:
             except Group.DoesNotExist:
-                logging.error(
-                    f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
+                if settings.REMOTE_AUTH_AUTO_CREATE_GROUPS:
+                    group_list.append(Group.objects.create(name=name))
+                else:
+                    logging.error(
+                        f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
         if group_list:
         if group_list:
             user.groups.set(group_list)
             user.groups.set(group_list)
             logger.debug(
             logger.debug(

+ 11 - 0
netbox/netbox/config/parameters.py

@@ -28,6 +28,17 @@ PARAMS = (
             ),
             ),
         },
         },
     ),
     ),
+    ConfigParam(
+        name='BANNER_MAINTENANCE',
+        label=_('Maintenance banner'),
+        default='NetBox is currently in maintenance mode. Functionality may be limited.',
+        description=_('Additional content to display when in maintenance mode'),
+        field_kwargs={
+            'widget': forms.Textarea(
+                attrs={'class': 'vLargeTextField'}
+            ),
+        },
+    ),
     ConfigParam(
     ConfigParam(
         name='BANNER_TOP',
         name='BANNER_TOP',
         label=_('Top banner'),
         label=_('Top banner'),

+ 49 - 3
netbox/netbox/middleware.py

@@ -3,19 +3,21 @@ import uuid
 from urllib import parse
 from urllib import parse
 
 
 from django.conf import settings
 from django.conf import settings
-from django.contrib import auth
+from django.contrib import auth, messages
 from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
 from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
-from django.db import ProgrammingError
+from django.db import connection, ProgrammingError
+from django.db.utils import InternalError
 from django.http import Http404, HttpResponseRedirect
 from django.http import Http404, HttpResponseRedirect
 
 
 from extras.context_managers import change_logging
 from extras.context_managers import change_logging
-from netbox.config import clear_config
+from netbox.config import clear_config, get_config
 from netbox.views import handler_500
 from netbox.views import handler_500
 from utilities.api import is_api_request, rest_api_server_error
 from utilities.api import is_api_request, rest_api_server_error
 
 
 __all__ = (
 __all__ = (
     'CoreMiddleware',
     'CoreMiddleware',
+    'MaintenanceModeMiddleware',
     'RemoteUserMiddleware',
     'RemoteUserMiddleware',
 )
 )
 
 
@@ -166,3 +168,47 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
             groups = []
             groups = []
         logger.debug(f"Groups are {groups}")
         logger.debug(f"Groups are {groups}")
         return groups
         return groups
+
+
+class MaintenanceModeMiddleware:
+    """
+    Middleware that checks if the application is in maintenance mode
+    and restricts write-related operations to the database.
+    """
+
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        if get_config().MAINTENANCE_MODE:
+            self._set_session_type(
+                allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS)
+            )
+
+        return self.get_response(request)
+
+    @staticmethod
+    def _set_session_type(allow_write):
+        """
+        Prevent any write-related database operations.
+
+        Args:
+            allow_write (bool): If True, write operations will be permitted.
+        """
+        with connection.cursor() as cursor:
+            mode = 'READ WRITE' if allow_write else 'READ ONLY'
+            cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};')
+
+    def process_exception(self, request, exception):
+        """
+        Prevent any write-related database operations if an exception is raised.
+        """
+        if isinstance(exception, InternalError):
+            error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
+                            'operations. Please try again later.'
+
+            if is_api_request(request):
+                return rest_api_server_error(request, error=error_message)
+
+            messages.error(request, error_message)
+            return HttpResponseRedirect(request.path_info)

+ 10 - 1
netbox/netbox/settings.py

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.5.1'
+VERSION = '3.5.2'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()
@@ -122,6 +122,7 @@ PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
 QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {})
 QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {})
 RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
 RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
 REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
 REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
+REMOTE_AUTH_AUTO_CREATE_GROUPS = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_GROUPS', False)
 REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
 REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
 REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
 REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
 REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
 REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
@@ -139,6 +140,8 @@ REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
 REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
 REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
+RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
+RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
 SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
 SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
 SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
 SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
@@ -382,6 +385,7 @@ MIDDLEWARE = [
     'django.middleware.security.SecurityMiddleware',
     'django.middleware.security.SecurityMiddleware',
     'netbox.middleware.RemoteUserMiddleware',
     'netbox.middleware.RemoteUserMiddleware',
     'netbox.middleware.CoreMiddleware',
     'netbox.middleware.CoreMiddleware',
+    'netbox.middleware.MaintenanceModeMiddleware',
     'django_prometheus.middleware.PrometheusAfterMiddleware',
     'django_prometheus.middleware.PrometheusAfterMiddleware',
 ]
 ]
 
 
@@ -476,6 +480,11 @@ AUTH_EXEMPT_PATHS = (
     f'/{BASE_PATH}metrics',
     f'/{BASE_PATH}metrics',
 )
 )
 
 
+# All URLs starting with a string listed here are exempt from maintenance mode enforcement
+MAINTENANCE_EXEMPT_PATHS = (
+    f'/{BASE_PATH}admin/',
+)
+
 SERIALIZATION_MODULES = {
 SERIALIZATION_MODULES = {
     'json': 'utilities.serializers.json',
     'json': 'utilities.serializers.json',
 }
 }

+ 44 - 0
netbox/netbox/tests/test_authentication.py

@@ -310,6 +310,50 @@ class ExternalAuthenticationTestCase(TestCase):
             list(new_user.groups.all())
             list(new_user.groups.all())
         )
         )
 
 
+    @override_settings(
+        REMOTE_AUTH_ENABLED=True,
+        REMOTE_AUTH_AUTO_CREATE_USER=True,
+        REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
+        REMOTE_AUTH_AUTO_CREATE_GROUPS=True,
+        LOGIN_REQUIRED=True,
+    )
+    def test_remote_auth_remote_groups_autocreate(self):
+        """
+        Test enabling remote authentication with group sync and autocreate
+        enabled with the default configuration.
+        """
+        headers = {
+            "HTTP_REMOTE_USER": "remoteuser2",
+            "HTTP_REMOTE_USER_GROUP": "Group 1|Group 2",
+        }
+
+        self.assertTrue(settings.REMOTE_AUTH_ENABLED)
+        self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
+        self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_GROUPS)
+        self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
+        self.assertEqual(settings.REMOTE_AUTH_HEADER, "HTTP_REMOTE_USER")
+        self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER, "HTTP_REMOTE_USER_GROUP")
+        self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, "|")
+
+        groups = (
+            Group(name="Group 1"),
+            Group(name="Group 2"),
+        )
+
+        response = self.client.get(reverse("home"), follow=True, **headers)
+        self.assertEqual(response.status_code, 200)
+
+        new_user = User.objects.get(username="remoteuser2")
+        self.assertEqual(
+            int(self.client.session.get("_auth_user_id")),
+            new_user.pk,
+            msg="Authentication failed",
+        )
+        self.assertListEqual(
+            [group.name for group in groups],
+            [group.name for group in list(new_user.groups.all())],
+        )
+
     @override_settings(
     @override_settings(
         REMOTE_AUTH_ENABLED=True,
         REMOTE_AUTH_ENABLED=True,
         REMOTE_AUTH_AUTO_CREATE_USER=True,
         REMOTE_AUTH_AUTO_CREATE_USER=True,

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

@@ -77,10 +77,10 @@ Blocks:
           </div>
           </div>
         {% endif %}
         {% endif %}
 
 
-        {% if config.MAINTENANCE_MODE %}
+        {% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %}
           <div class="alert alert-warning text-center mx-3" role="alert">
           <div class="alert alert-warning text-center mx-3" role="alert">
             <h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5>
             <h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5>
-            NetBox is currently in maintenance mode. Functionality may be limited.
+            {{ config.BANNER_MAINTENANCE|escape }}
           </div>
           </div>
         {% endif %}
         {% endif %}
 
 

+ 0 - 1
netbox/templates/circuits/circuit.html

@@ -70,7 +70,6 @@
     <div class="col col-md-6">
     <div class="col col-md-6">
       {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
       {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
       {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
       {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
-      {% include 'inc/panels/contacts.html' %}
       {% include 'inc/panels/image_attachments.html' %}
       {% include 'inc/panels/image_attachments.html' %}
       {% plugin_right_page object %}
       {% plugin_right_page object %}
     </div>
     </div>

+ 10 - 3
netbox/templates/circuits/inc/circuit_termination.html

@@ -132,9 +132,16 @@
             </tr>
             </tr>
             {% for field, value in fields.items %}
             {% for field, value in fields.items %}
               <tr>
               <tr>
-                <td>
-                  <span title="{{ field.description|escape }}">{{ field }}</span>
-                </td>
+                <th scope="row">{{ field }}
+                  {% if field.description %}
+                    <i
+                      class="mdi mdi-information text-primary"
+                      data-bs-toggle="tooltip"
+                      data-bs-placement="right"
+                      title="{{ field.description|escape }}"
+                    ></i>
+                 {% endif %}
+                </th>
                 <td>
                 <td>
                   {% customfield_value field value %}
                   {% customfield_value field value %}
                 </td>
                 </td>

+ 0 - 1
netbox/templates/circuits/provider.html

@@ -43,7 +43,6 @@
     <div class="col col-md-6">
     <div class="col col-md-6">
         {% include 'inc/panels/related_objects.html' %}
         {% include 'inc/panels/related_objects.html' %}
         {% include 'inc/panels/custom_fields.html' %}
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/contacts.html' %}
         {% plugin_right_page object %}
         {% plugin_right_page object %}
     </div>
     </div>
 </div>
 </div>

+ 0 - 1
netbox/templates/circuits/provideraccount.html

@@ -38,7 +38,6 @@
       {% include 'inc/panels/related_objects.html' %}
       {% include 'inc/panels/related_objects.html' %}
       {% include 'inc/panels/comments.html' %}
       {% include 'inc/panels/comments.html' %}
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/contacts.html' %}
       {% plugin_right_page object %}
       {% plugin_right_page object %}
     </div>
     </div>
     <div class="col col-md-12">
     <div class="col col-md-12">

+ 23 - 1
netbox/templates/dcim/device.html

@@ -298,8 +298,30 @@
                 </div>
                 </div>
               {% endif %}
               {% endif %}
             </div>
             </div>
-            {% include 'inc/panels/contacts.html' %}
             {% include 'inc/panels/image_attachments.html' %}
             {% include 'inc/panels/image_attachments.html' %}
+            <div class="card">
+                <h5 class="card-header">Dimensions</h5>
+                <div class="card-body table-responsive">
+                    <table class="table table-hover attr-table">
+                        <tr>
+                            <th scope="row">Height</th>
+                            <td>
+                                {{ object.device_type.u_height }}U
+                            </td>
+                        </tr>
+                        <tr>
+                            <th scope="row">Weight</th>
+                            <td>
+                                {% if object.total_weight %}
+                                    {{ object.total_weight|floatformat }} Kilograms
+                                {% else %}
+                                    {{ ''|placeholder }}
+                                {% endif %}
+                            </td>
+                        </tr>
+                    </table>
+                </div>
+            </div>
             {% if object.rack and object.position %}
             {% if object.rack and object.position %}
               <div class="row" style="margin-bottom: 20px">
               <div class="row" style="margin-bottom: 20px">
                 <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
                 <div class="col col-md-6 col-sm-6 col-xs-12 text-center">

+ 2 - 2
netbox/templates/dcim/interface.html

@@ -123,11 +123,11 @@
           <table class="table table-hover">
           <table class="table table-hover">
             <tr>
             <tr>
               <th scope="row">MAC Address</th>
               <th scope="row">MAC Address</th>
-              <td><span class="text-monospace">{{ object.mac_address|placeholder }}</span></td>
+              <td><span class="font-monospace">{{ object.mac_address|placeholder }}</span></td>
             </tr>
             </tr>
             <tr>
             <tr>
               <th scope="row">WWN</th>
               <th scope="row">WWN</th>
-              <td><span class="text-monospace">{{ object.wwn|placeholder }}</span></td>
+              <td><span class="font-monospace">{{ object.wwn|placeholder }}</span></td>
             </tr>
             </tr>
             <tr>
             <tr>
               <th scope="row">VRF</th>
               <th scope="row">VRF</th>

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

@@ -65,7 +65,6 @@
   </div>
   </div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% include 'dcim/inc/nonracked_devices.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 %}

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

@@ -51,7 +51,6 @@
 	<div class="col col-md-6">
 	<div class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
   </div>
   </div>
 </div>
 </div>

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

@@ -40,7 +40,6 @@
 	<div class="col col-md-6">
 	<div class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% include 'inc/panels/image_attachments.html' %}
     {% include 'inc/panels/image_attachments.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
   </div>
   </div>

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

@@ -191,7 +191,6 @@
         </div>
         </div>
         {% include 'inc/panels/related_objects.html' %}
         {% include 'inc/panels/related_objects.html' %}
         {% include 'dcim/inc/nonracked_devices.html' %}
         {% include 'dcim/inc/nonracked_devices.html' %}
-        {% include 'inc/panels/contacts.html' %}
         {% plugin_right_page object %}
         {% plugin_right_page object %}
     </div>
     </div>
   </div>
   </div>

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

@@ -46,7 +46,6 @@
   </div>
   </div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
 	</div>
 	</div>
 </div>
 </div>

+ 14 - 11
netbox/templates/dcim/site.html

@@ -87,11 +87,13 @@
             <th scope="row">Physical Address</th>
             <th scope="row">Physical Address</th>
             <td class="position-relative">
             <td class="position-relative">
               {% if object.physical_address %}
               {% if object.physical_address %}
-                <div class="position-absolute top-50 end-0 translate-middle-y noprint">
-                  <a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
-                    <i class="mdi mdi-map-marker"></i> Map
-                  </a>
-                </div>
+                {% if config.MAPS_URL %}
+                  <div class="position-absolute top-50 end-0 translate-middle-y noprint">
+                    <a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
+                      <i class="mdi mdi-map-marker"></i> Map
+                    </a>
+                  </div>
+                {% endif %}
                 <span>{{ object.physical_address|linebreaksbr }}</span>
                 <span>{{ object.physical_address|linebreaksbr }}</span>
               {% else %}
               {% else %}
                 {{ ''|placeholder }}
                 {{ ''|placeholder }}
@@ -106,11 +108,13 @@
             <th scope="row">GPS Coordinates</th>
             <th scope="row">GPS Coordinates</th>
             <td class="position-relative">
             <td class="position-relative">
               {% if object.latitude and object.longitude %}
               {% if object.latitude and object.longitude %}
-                <div class="position-absolute top-50 end-0 translate-middle-y noprint">
-                  <a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
-                    <i class="mdi mdi-map-marker"></i> Map It
-                  </a>
-                </div>
+                {% if config.MAPS_URL %}
+                  <div class="position-absolute top-50 end-0 translate-middle-y noprint">
+                    <a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
+                      <i class="mdi mdi-map-marker"></i> Map It
+                    </a>
+                  </div>
+                  {% endif %}
                 <span>{{ object.latitude }}, {{ object.longitude }}</span>
                 <span>{{ object.latitude }}, {{ object.longitude }}</span>
               {% else %}
               {% else %}
                 {{ ''|placeholder }}
                 {{ ''|placeholder }}
@@ -127,7 +131,6 @@
     </div>
     </div>
     <div class="col col-md-6">
     <div class="col col-md-6">
       {% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
       {% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
-      {% include 'inc/panels/contacts.html' %}
       <div class="card">
       <div class="card">
         <h5 class="card-header">Locations</h5>
         <h5 class="card-header">Locations</h5>
         <div class='card-body'>
         <div class='card-body'>

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

@@ -42,7 +42,6 @@
     </div>
     </div>
     {% include 'inc/panels/tags.html' %}
     {% include 'inc/panels/tags.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% plugin_left_page object %}
     {% plugin_left_page object %}
   </div>
   </div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">

+ 0 - 63
netbox/templates/inc/panels/contacts.html

@@ -1,63 +0,0 @@
-{% load helpers %}
-
-<div class="card">
-  <h5 class="card-header">Contacts</h5>
-  <div class="card-body">
-    {% with contacts=object.contacts.all %}
-      {% if contacts.exists %}
-        <table class="table table-hover">
-          <tr>
-            <th>Name</th>
-            <th>Role</th>
-            <th>Priority</th>
-            <th>Phone</th>
-            <th>Email</th>
-            <th></th>
-          </tr>
-          {% for contact in contacts %}
-            <tr>
-              <td>{{ contact.contact|linkify }}</td>
-              <td>{{ contact.role|placeholder }}</td>
-              <td>{{ contact.get_priority_display|placeholder }}</td>
-              <td>
-                {% if contact.contact.phone %}
-                  <a href="tel:{{ contact.contact.phone }}">{{ contact.contact.phone }}</a>
-                {% else %}
-                  {{ ''|placeholder }}
-                {% endif %}
-              </td>
-              <td>
-                {% if contact.contact.email %}
-                  <a href="mailto:{{ contact.contact.email }}">{{ contact.contact.email }}</a>
-                {% else %}
-                  {{ ''|placeholder }}
-                {% endif %}
-              </td>
-              <td class="text-end noprint">
-                {% if perms.tenancy.change_contactassignment %}
-                  <a href="{% url 'tenancy:contactassignment_edit' pk=contact.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning btn-sm lh-1" title="Edit">
-                    <i class="mdi mdi-pencil" aria-hidden="true"></i>
-                  </a>
-                {% endif %}
-                {% if perms.tenancy.delete_contactassignment %}
-                  <a href="{% url 'tenancy:contactassignment_delete' pk=contact.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger btn-sm lh-1" title="Delete">
-                    <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
-                  </a>
-                {% endif %}
-              </td>
-            </tr>
-          {% endfor %}
-        </table>
-      {% else %}
-        <div class="text-muted">None</div>
-      {% endif %}
-    {% endwith %}
-  </div>
-  {% if perms.tenancy.add_contactassignment %}
-    <div class="card-footer text-end noprint">
-      <a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
-        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
-      </a>
-    </div>
-  {% endif %}
-</div>

+ 9 - 2
netbox/templates/inc/panels/custom_fields.html

@@ -12,8 +12,15 @@
           <table class="table table-hover attr-table">
           <table class="table table-hover attr-table">
             {% for field, value in fields.items %}
             {% for field, value in fields.items %}
               <tr>
               <tr>
-                <th scope="row">
-                  <span title="{{ field.description|escape }}">{{ field }}</span>
+                <th scope="row">{{ field }}
+                  {% if field.description %}
+                    <i
+                      class="mdi mdi-information text-primary"
+                      data-bs-toggle="tooltip"
+                      data-bs-placement="right"
+                      title="{{ field.description|escape }}"
+                    ></i>
+                  {% endif %}
                 </th>
                 </th>
                 <td>
                 <td>
                   {% customfield_value field value %}
                   {% customfield_value field value %}

+ 0 - 8
netbox/templates/ipam/ipaddress.html

@@ -3,13 +3,6 @@
 {% load plugins %}
 {% load plugins %}
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}
 
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% if object.vrf %}
-    <li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
-  {% endif %}
-{% endblock %}
-
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
 	<div class="col col-md-4">
 	<div class="col col-md-4">
@@ -116,7 +109,6 @@
     {% if duplicate_ips_table.rows %}
     {% if duplicate_ips_table.rows %}
       {% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %}
       {% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %}
     {% endif %}
     {% endif %}
-    {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IPs' %}
     <div class="card">
     <div class="card">
       <h5 class="card-header">Services</h5>
       <h5 class="card-header">Services</h5>
       <div class="card-body htmx-container table-responsive"
       <div class="card-body htmx-container table-responsive"

+ 8 - 0
netbox/templates/ipam/ipaddress/base.html

@@ -0,0 +1,8 @@
+{% extends 'generic/object.html' %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  {% if object.vrf %}
+    <li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
+  {% endif %}
+{% endblock %}

+ 19 - 0
netbox/templates/ipam/ipaddress/ip_addresses.html

@@ -0,0 +1,19 @@
+{% extends 'ipam/ipaddress/base.html' %}
+{% load helpers %}
+
+{% block content %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
+    <form method="post">
+        {% csrf_token %}
+        <div class="card">
+            <div class="card-body" id="object_list">
+                {% include 'htmx/table.html' %}
+            </div>
+        </div>
+    </form>
+{% endblock content %}
+
+{% block modals %}
+    {{ block.super }}
+    {% table_config_form table %}
+{% endblock modals %}

+ 0 - 1
netbox/templates/ipam/l2vpn.html

@@ -37,7 +37,6 @@
     {% plugin_left_page object %}
     {% plugin_left_page object %}
 	</div>
 	</div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
-      {% include 'inc/panels/contacts.html' %}
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/comments.html' %}
       {% include 'inc/panels/comments.html' %}
       {% plugin_right_page object %}
       {% plugin_right_page object %}

+ 27 - 0
netbox/templates/tenancy/object_contacts.html

@@ -0,0 +1,27 @@
+{% extends base_template %}
+{% load helpers %}
+
+{% block extra_controls %}
+    {% if perms.tenancy.add_contactassignment %}
+    <a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
+        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
+    </a>
+  {% endif %}
+{% endblock %}
+
+{% block content %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="ContactTable_config" %}
+    <form method="post">
+        {% csrf_token %}
+        <div class="card">
+            <div class="card-body" id="object_list">
+                {% include 'htmx/table.html' %}
+            </div>
+        </div>
+    </form>
+{% endblock content %}
+
+{% block modals %}
+    {{ block.super }}
+    {% table_config_form table %}
+{% endblock modals %}

+ 0 - 1
netbox/templates/tenancy/tenant.html

@@ -30,7 +30,6 @@
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/tags.html' %}
       {% include 'inc/panels/tags.html' %}
       {% include 'inc/panels/comments.html' %}
       {% include 'inc/panels/comments.html' %}
-      {% include 'inc/panels/contacts.html' %}
       {% plugin_left_page object %}
       {% plugin_left_page object %}
     </div>
     </div>
     <div class="col col-md-5">
     <div class="col col-md-5">

+ 0 - 1
netbox/templates/virtualization/cluster.html

@@ -84,7 +84,6 @@
   </div>
   </div>
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/tags.html' %}
     {% include 'inc/panels/tags.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
   </div>
   </div>
 </div>
 </div>

+ 0 - 1
netbox/templates/virtualization/clustergroup.html

@@ -37,7 +37,6 @@
 	<div class="col col-md-6">
 	<div class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
   </div>
   </div>
 </div>
 </div>

+ 0 - 1
netbox/templates/virtualization/virtualmachine.html

@@ -158,7 +158,6 @@
             </div>
             </div>
           {% endif %}
           {% endif %}
         </div>
         </div>
-        {% include 'inc/panels/contacts.html' %}
         {% plugin_right_page object %}
         {% plugin_right_page object %}
     </div>
     </div>
 </div>
 </div>

+ 1 - 1
netbox/templates/virtualization/vminterface.html

@@ -59,7 +59,7 @@
                     </tr>
                     </tr>
                     <tr>
                     <tr>
                         <th scope="row">MAC Address</th>
                         <th scope="row">MAC Address</th>
-                        <td><span class="text-monospace">{{ object.mac_address|placeholder }}</span></td>
+                        <td><span class="font-monospace">{{ object.mac_address|placeholder }}</span></td>
                     </tr>
                     </tr>
                     <tr>
                     <tr>
                         <th scope="row">802.1Q Mode</th>
                         <th scope="row">802.1Q Mode</th>

+ 30 - 2
netbox/tenancy/views.py

@@ -7,17 +7,40 @@ from dcim.models import Cable, Device, Location, Rack, RackReservation, Site, Vi
 from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF
 from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF
 from netbox.views import generic
 from netbox.views import generic
 from utilities.utils import count_related
 from utilities.utils import count_related
-from utilities.views import register_model_view
+from utilities.views import register_model_view, ViewTab
 from virtualization.models import VirtualMachine, Cluster
 from virtualization.models import VirtualMachine, Cluster
 from wireless.models import WirelessLAN, WirelessLink
 from wireless.models import WirelessLAN, WirelessLink
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .models import *
 from .models import *
 
 
 
 
+class ObjectContactsView(generic.ObjectChildrenView):
+    child_model = Contact
+    table = tables.ContactTable
+    filterset = filtersets.ContactFilterSet
+    template_name = 'tenancy/object_contacts.html'
+    tab = ViewTab(
+        label=_('Contacts'),
+        badge=lambda obj: obj.contacts.count(),
+        permission='tenancy.view_contact',
+        weight=5000
+    )
+
+    def get_children(self, request, parent):
+        return Contact.objects.annotate(
+            assignment_count=count_related(ContactAssignment, 'contact')
+        ).restrict(request.user, 'view').filter(assignments__object_id=parent.pk)
+
+    def get_extra_context(self, request, instance):
+        return {
+            'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
+        }
+
 #
 #
 # Tenant groups
 # Tenant groups
 #
 #
 
 
+
 class TenantGroupListView(generic.ObjectListView):
 class TenantGroupListView(generic.ObjectListView):
     queryset = TenantGroup.objects.add_related_count(
     queryset = TenantGroup.objects.add_related_count(
         TenantGroup.objects.all(),
         TenantGroup.objects.all(),
@@ -165,6 +188,11 @@ class TenantBulkDeleteView(generic.BulkDeleteView):
     table = tables.TenantTable
     table = tables.TenantTable
 
 
 
 
+@register_model_view(Tenant, 'contacts')
+class TenantContactsView(ObjectContactsView):
+    queryset = Tenant.objects.all()
+
+
 #
 #
 # Contact groups
 # Contact groups
 #
 #
@@ -342,11 +370,11 @@ class ContactBulkDeleteView(generic.BulkDeleteView):
     filterset = filtersets.ContactFilterSet
     filterset = filtersets.ContactFilterSet
     table = tables.ContactTable
     table = tables.ContactTable
 
 
-
 #
 #
 # Contact assignments
 # Contact assignments
 #
 #
 
 
+
 class ContactAssignmentListView(generic.ObjectListView):
 class ContactAssignmentListView(generic.ObjectListView):
     queryset = ContactAssignment.objects.all()
     queryset = ContactAssignment.objects.all()
     filterset = filtersets.ContactAssignmentFilterSet
     filterset = filtersets.ContactAssignmentFilterSet

+ 4 - 4
netbox/utilities/forms/mixins.py

@@ -32,11 +32,11 @@ class BootstrapMixin:
             elif isinstance(field.widget, forms.CheckboxInput):
             elif isinstance(field.widget, forms.CheckboxInput):
                 field.widget.attrs['class'] = f'{css} form-check-input'
                 field.widget.attrs['class'] = f'{css} form-check-input'
 
 
-            elif isinstance(field.widget, forms.SelectMultiple):
-                if 'size' not in field.widget.attrs:
-                    field.widget.attrs['class'] = f'{css} netbox-static-select'
+            elif isinstance(field.widget, forms.SelectMultiple) and 'size' in field.widget.attrs:
+                # Use native Bootstrap class for multi-line <select> widgets
+                field.widget.attrs['class'] = f'{css} form-select form-select-sm'
 
 
-            elif isinstance(field.widget, forms.Select):
+            elif isinstance(field.widget, (forms.Select, forms.SelectMultiple)):
                 field.widget.attrs['class'] = f'{css} netbox-static-select'
                 field.widget.attrs['class'] = f'{css} netbox-static-select'
 
 
             else:
             else:

+ 13 - 1
netbox/utilities/rqworker.py

@@ -1,11 +1,12 @@
 from django_rq.queues import get_connection
 from django_rq.queues import get_connection
-from rq import Worker
+from rq import Retry, Worker
 
 
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.constants import RQ_QUEUE_DEFAULT
 
 
 __all__ = (
 __all__ = (
     'get_queue_for_model',
     'get_queue_for_model',
+    'get_rq_retry',
     'get_workers_for_queue',
     'get_workers_for_queue',
 )
 )
 
 
@@ -22,3 +23,14 @@ def get_workers_for_queue(queue_name):
     Returns True if a worker process is currently servicing the specified queue.
     Returns True if a worker process is currently servicing the specified queue.
     """
     """
     return Worker.count(get_connection(queue_name))
     return Worker.count(get_connection(queue_name))
+
+
+def get_rq_retry():
+    """
+    If RQ_RETRY_MAX is defined and greater than zero, instantiate and return a Retry object to be
+    used when queuing a job. Otherwise, return None.
+    """
+    retry_max = get_config().RQ_RETRY_MAX
+    retry_interval = get_config().RQ_RETRY_INTERVAL
+    if retry_max:
+        return Retry(max=retry_max, interval=retry_interval)

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

@@ -65,7 +65,7 @@ class ClusterImportForm(NetBoxModelImportForm):
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments', 'tags')
+        fields = ('name', 'type', 'group', 'status', 'site', 'tenant', 'description', 'comments', 'tags')
 
 
 
 
 class VirtualMachineImportForm(NetBoxModelImportForm):
 class VirtualMachineImportForm(NetBoxModelImportForm):

+ 17 - 1
netbox/virtualization/views.py

@@ -9,9 +9,10 @@ from dcim.filtersets import DeviceFilterSet
 from dcim.models import Device
 from dcim.models import Device
 from dcim.tables import DeviceTable
 from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
-from ipam.models import IPAddress, Service
+from ipam.models import IPAddress
 from ipam.tables import InterfaceVLANTable
 from ipam.tables import InterfaceVLANTable
 from netbox.views import generic
 from netbox.views import generic
+from tenancy.views import ObjectContactsView
 from utilities.utils import count_related
 from utilities.utils import count_related
 from utilities.views import ViewTab, register_model_view
 from utilities.views import ViewTab, register_model_view
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
@@ -140,6 +141,11 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
     table = tables.ClusterGroupTable
     table = tables.ClusterGroupTable
 
 
 
 
+@register_model_view(ClusterGroup, 'contacts')
+class ClusterGroupContactsView(ObjectContactsView):
+    queryset = ClusterGroup.objects.all()
+
+
 #
 #
 # Clusters
 # Clusters
 #
 #
@@ -312,6 +318,11 @@ class ClusterRemoveDevicesView(generic.ObjectEditView):
         })
         })
 
 
 
 
+@register_model_view(Cluster, 'contacts')
+class ClusterContactsView(ObjectContactsView):
+    queryset = Cluster.objects.all()
+
+
 #
 #
 # Virtual machines
 # Virtual machines
 #
 #
@@ -390,6 +401,11 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
     table = tables.VirtualMachineTable
     table = tables.VirtualMachineTable
 
 
 
 
+@register_model_view(VirtualMachine, 'contacts')
+class VirtualMachineContactsView(ObjectContactsView):
+    queryset = VirtualMachine.objects.all()
+
+
 #
 #
 # VM interfaces
 # VM interfaces
 #
 #

+ 7 - 7
requirements.txt

@@ -1,8 +1,8 @@
 bleach==6.0.0
 bleach==6.0.0
-boto3==1.26.127
+boto3==1.26.138
 Django==4.1.9
 Django==4.1.9
-django-cors-headers==3.14.0
-django-debug-toolbar==4.0.0
+django-cors-headers==4.0.0
+django-debug-toolbar==4.1.0
 django-filter==23.2
 django-filter==23.2
 django-graphiql-debug-toolbar==0.2.0
 django-graphiql-debug-toolbar==0.2.0
 django-mptt==0.14
 django-mptt==0.14
@@ -10,7 +10,7 @@ django-pglocks==1.0.4
 django-prometheus==2.3.1
 django-prometheus==2.3.1
 django-redis==5.2.0
 django-redis==5.2.0
 django-rich==1.5.0
 django-rich==1.5.0
-django-rq==2.8.0
+django-rq==2.8.1
 django-tables2==2.5.3
 django-tables2==2.5.3
 django-taggit==4.0.0
 django-taggit==4.0.0
 django-timezone-field==5.0
 django-timezone-field==5.0
@@ -19,17 +19,17 @@ drf-spectacular==0.26.2
 drf-spectacular-sidecar==2023.5.1
 drf-spectacular-sidecar==2023.5.1
 dulwich==0.21.5
 dulwich==0.21.5
 feedparser==6.0.10
 feedparser==6.0.10
-graphene-django==3.0.0
+graphene-django==3.0.2
 gunicorn==20.1.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Jinja2==3.1.2
 Markdown==3.3.7
 Markdown==3.3.7
-mkdocs-material==9.1.9
+mkdocs-material==9.1.14
 mkdocstrings[python-legacy]==0.21.2
 mkdocstrings[python-legacy]==0.21.2
 netaddr==0.8.0
 netaddr==0.8.0
 Pillow==9.5.0
 Pillow==9.5.0
 psycopg2-binary==2.9.6
 psycopg2-binary==2.9.6
 PyYAML==6.0
 PyYAML==6.0
-sentry-sdk==1.22.1
+sentry-sdk==1.23.1
 social-auth-app-django==5.2.0
 social-auth-app-django==5.2.0
 social-auth-core[openidconnect]==4.4.2
 social-auth-core[openidconnect]==4.4.2
 svgwrite==1.4.3
 svgwrite==1.4.3