Sfoglia il codice sorgente

Merge pull request #12681 from netbox-community/develop

Release v3.5.2
Jeremy Stretch 2 anni fa
parent
commit
c9b79ca579
74 ha cambiato i file con 708 aggiunte e 183 eliminazioni
  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:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.5.1
+      placeholder: v3.5.2
     validations:
       required: true
   - type: dropdown

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

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

+ 6 - 4
README.md

@@ -1,11 +1,13 @@
 <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>
 
-![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
 combining the traditional disciplines of IP address management (IPAM) and
 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)
 >>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1)
+>>> myvlan.full_clean()
 >>> 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.
 
 ```
@@ -169,6 +164,7 @@ To modify an existing object, we retrieve it, update the desired field(s), and c
 >>> vlan.name
 'MyNewVLAN'
 >>> vlan.name = 'BetterName'
+>>> vlan.full_clean()
 >>> vlan.save()
 >>> VLAN.objects.get(pk=1280).name
 '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
 
 !!! 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)
 
-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`
 
 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
 
 Default: `False`

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

@@ -378,6 +378,7 @@ class NewBranchScript(Script):
             slug=slugify(data['site_name']),
             status=SiteStatusChoices.STATUS_PLANNED
         )
+        site.full_clean()
         site.save()
         self.log_success(f"Created new site: {site}")
 
@@ -391,6 +392,7 @@ class NewBranchScript(Script):
                 status=DeviceStatusChoices.STATUS_PLANNED,
                 device_role=switch_role
             )
+            switch.full_clean()
             switch.save()
             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 chown --recursive netbox /opt/netbox/netbox/media/
+    sudo chown --recursive netbox /opt/netbox/netbox/reports/
+    sudo chown --recursive netbox /opt/netbox/netbox/scripts/
     ```
 
 === "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 adduser --system -g netbox netbox
     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

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

@@ -15,7 +15,7 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
 On CentOS:
 
 ```no-highlight
-sudo yum install -y openldap-devel
+sudo yum install -y openldap-devel python3-devel
 ```
 
 ### 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
 
-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
 

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

@@ -1,5 +1,41 @@
 # 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)
 
 ### Enhancements

+ 16 - 1
netbox/circuits/views.py

@@ -1,10 +1,10 @@
 from django.contrib import messages
 from django.db import transaction
-from django.db.models import Q
 from django.shortcuts import get_object_or_404, redirect, render
 
 from dcim.views import PathTraceView
 from netbox.views import generic
+from tenancy.views import ObjectContactsView
 from utilities.forms import ConfirmationForm
 from utilities.utils import count_related
 from utilities.views import register_model_view
@@ -73,6 +73,11 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
     table = tables.ProviderTable
 
 
+@register_model_view(Provider, 'contacts')
+class ProviderContactsView(ObjectContactsView):
+    queryset = Provider.objects.all()
+
+
 #
 # ProviderAccounts
 #
@@ -134,6 +139,11 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
     table = tables.ProviderAccountTable
 
 
+@register_model_view(ProviderAccount, 'contacts')
+class ProviderAccountContactsView(ObjectContactsView):
+    queryset = ProviderAccount.objects.all()
+
+
 #
 # Provider networks
 #
@@ -389,6 +399,11 @@ class CircuitSwapTerminations(generic.ObjectEditView):
         })
 
 
+@register_model_view(Circuit, 'contacts')
+class CircuitContactsView(ObjectContactsView):
+    queryset = Circuit.objects.all()
+
+
 #
 # 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.constants import RQ_QUEUE_DEFAULT
 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__ = (
     'Job',
@@ -219,5 +219,6 @@ class Job(models.Model):
                 event=event,
                 data=self.data,
                 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):
     queryset = Interface.objects.prefetch_related(
         '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
     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_CFP2 = '100gbase-x-cfp2'
     TYPE_100GE_CFP4 = '100gbase-x-cfp4'
+    TYPE_100GE_CXP = '100gbase-x-cxp'
     TYPE_100GE_CPAK = '100gbase-x-cpak'
     TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
     TYPE_200GE_CFP2 = '200gbase-x-cfp2'
     TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
+    TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
     TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
     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_OSFP = '800gbase-x-osfp'
 
@@ -952,11 +956,15 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_100GE_CFP2, 'CFP2 (100GE)'),
                 (TYPE_200GE_CFP2, 'CFP2 (200GE)'),
                 (TYPE_100GE_CFP4, 'CFP4 (100GE)'),
+                (TYPE_100GE_CXP, 'CXP (100GE)'),
                 (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
                 (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
                 (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
+                (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
                 (TYPE_400GE_QSFP_DD, 'QSFP-DD (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_OSFP, 'OSFP (800GE)'),
             )
@@ -1221,6 +1229,10 @@ class PortTypeChoices(ChoiceSet):
     TYPE_LSH_PC = 'lsh-pc'
     TYPE_LSH_UPC = 'lsh-upc'
     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_CS = 'cs'
     TYPE_SN = 'sn'
@@ -1267,6 +1279,10 @@ class PortTypeChoices(ChoiceSet):
                 (TYPE_LSH_PC, 'LSH/PC'),
                 (TYPE_LSH_UPC, 'LSH/UPC'),
                 (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_MTRJ, 'MTRJ'),
                 (TYPE_SC, 'SC'),

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

@@ -1,4 +1,5 @@
 from django import forms
+from django.conf import settings
 from django.contrib.auth.models import User
 from django.utils.translation import gettext as _
 from timezone_field import TimeZoneFormField
@@ -1292,8 +1293,13 @@ class InterfaceBulkEditForm(
                         break
 
                 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'].widget.attrs['disabled'] = True

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

@@ -292,12 +292,21 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
         required=False,
         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:
         model = DeviceType
         fields = [
             '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(),
         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:
         model = ModuleType
-        fields = ['manufacturer', 'model', 'part_number', 'description', 'comments']
+        fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments']
 
 
 class DeviceRoleImportForm(NetBoxModelImportForm):
@@ -1060,7 +1078,11 @@ class CableImportForm(NetBoxModelImportForm):
 
         model = content_type.model_class()
         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:
                 raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
         except ObjectDoesNotExist:

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

@@ -1214,7 +1214,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
     installed_device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         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):

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

@@ -242,6 +242,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
         choices=[],
         label=_('Rear ports'),
         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

+ 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,
         blank=True
     )
-    maximum_draw = models.PositiveSmallIntegerField(
+    maximum_draw = models.PositiveIntegerField(
         blank=True,
         null=True,
         validators=[MinValueValidator(1)],
         help_text=_("Maximum power draw (watts)")
     )
-    allocated_draw = models.PositiveSmallIntegerField(
+    allocated_draw = models.PositiveIntegerField(
         blank=True,
         null=True,
         validators=[MinValueValidator(1)],

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

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

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

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

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

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

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

@@ -216,6 +216,16 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     config_template = tables.Column(
         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()
     tags = columns.TagColumn(
         url_name='dcim:device_list'
@@ -225,9 +235,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         model = models.Device
         fields = (
             '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 = (
             '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 = """
 manufacturer: Generic
-default_platform: Platform
 model: TEST-1000
 slug: test-1000
+default_platform: Platform
 u_height: 2
+is_full_depth: false
+airflow: front-to-rear
 subdevice_role: parent
+weight: 10
+weight_unit: kg
 comments: Test comment
 console-ports:
   - name: Console Port 1
@@ -794,8 +798,16 @@ inventory-items:
         self.assertHttpStatus(response, 200)
 
         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.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
         self.assertEqual(device_type.consoleporttemplates.count(), 3)
@@ -1019,6 +1031,8 @@ class ModuleTypeTestCase(
         IMPORT_DATA = """
 manufacturer: Generic
 model: TEST-1000
+weight: 10
+weight_unit: lb
 comments: Test comment
 console-ports:
   - name: Console Port 1
@@ -1082,7 +1096,8 @@ front-ports:
 """
 
         # 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
         self.add_permissions(
@@ -1105,6 +1120,9 @@ front-ports:
         self.assertHttpStatus(response, 200)
 
         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')
 
         # Verify all the components were created
@@ -2889,6 +2907,7 @@ class CableTestCase(
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
         devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        vc = VirtualChassis.objects.create(name='Virtual Chassis')
 
         devices = (
             Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
@@ -2898,6 +2917,10 @@ class CableTestCase(
         )
         Device.objects.bulk_create(devices)
 
+        vc.members.set((devices[0], devices[1], devices[2]))
+        vc.master = devices[0]
+        vc.save()
+
         interfaces = (
             Interface(device=devices[0], name='Interface 1', 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 2', 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)
 
@@ -2943,6 +2970,8 @@ class CableTestCase(
             "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 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 = (

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

+ 1 - 1
netbox/extras/admin.py

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

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

@@ -35,7 +35,8 @@ def get_content_type_labels():
     return [
         (content_type_identifier(ct), content_type_name(ct))
         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')
     ]
 

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

@@ -13,6 +13,22 @@ class Migration(migrations.Migration):
         migrations.AlterField(
             model_name='customfield',
             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.",
                 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(

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

@@ -73,6 +73,7 @@ class ExportTemplateTable(NetBoxTable):
         linkify=True
     )
     is_synced = columns.BooleanColumn(
+        orderable=False,
         verbose_name='Synced'
     )
 
@@ -218,6 +219,7 @@ class ConfigContextTable(NetBoxTable):
         verbose_name='Active'
     )
     is_synced = columns.BooleanColumn(
+        orderable=False,
         verbose_name='Synced'
     )
 
@@ -242,6 +244,7 @@ class ConfigTemplateTable(NetBoxTable):
         linkify=True
     )
     is_synced = columns.BooleanColumn(
+        orderable=False,
         verbose_name='Synced'
     )
     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)
 
+    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):
         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.registry import registry
 from utilities.api import get_serializer_for_model
+from utilities.rqworker import get_rq_retry
 from utilities.utils import serialize_object
 from .choices import *
 from .models import Webhook
@@ -116,5 +117,6 @@ def flush_webhooks(queue):
                 snapshots=data['snapshots'],
                 timestamp=str(timezone.now()),
                 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."
             )
 
+        # 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):
         ipaddress = super().save(*args, **kwargs)
 

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

@@ -783,6 +783,14 @@ class IPAddress(PrimaryModel):
                 if 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):
         super().clean()
 

+ 24 - 10
netbox/ipam/views.py

@@ -9,6 +9,7 @@ from circuits.models import Provider
 from dcim.filtersets import InterfaceFilterSet
 from dcim.models import Interface, Site
 from netbox.views import generic
+from tenancy.views import ObjectContactsView
 from utilities.utils import count_related
 from utilities.views import ViewTab, register_model_view
 from virtualization.filtersets import VMInterfaceFilterSet
@@ -755,19 +756,9 @@ class IPAddressView(generic.ObjectView):
         # Limit to a maximum of 10 duplicates displayed here
         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 {
             'parent_prefixes_table': parent_prefixes_table,
             'duplicate_ips_table': duplicate_ips_table,
-            'related_ips_table': related_ips_table,
         }
 
 
@@ -872,6 +863,24 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
     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
 #
@@ -1292,6 +1301,11 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView):
     table = tables.L2VPNTable
 
 
+@register_model_view(L2VPN, 'contacts')
+class L2VPNContactsView(ObjectContactsView):
+    queryset = L2VPN.objects.all()
+
+
 #
 # L2VPN terminations
 #

+ 5 - 2
netbox/netbox/authentication.py

@@ -156,8 +156,11 @@ class RemoteUserBackend(_RemoteUserBackend):
             try:
                 group_list.append(Group.objects.get(name=name))
             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:
             user.groups.set(group_list)
             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(
         name='BANNER_TOP',
         label=_('Top banner'),

+ 49 - 3
netbox/netbox/middleware.py

@@ -3,19 +3,21 @@ import uuid
 from urllib import parse
 
 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.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 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 utilities.api import is_api_request, rest_api_server_error
 
 __all__ = (
     'CoreMiddleware',
+    'MaintenanceModeMiddleware',
     'RemoteUserMiddleware',
 )
 
@@ -166,3 +168,47 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
             groups = []
         logger.debug(f"Groups are {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
 #
 
-VERSION = '3.5.1'
+VERSION = '3.5.2'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -122,6 +122,7 @@ PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
 QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {})
 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_GROUPS = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_GROUPS', False)
 REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
 REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
 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', '|')
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 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('/')
 SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
 SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
@@ -382,6 +385,7 @@ MIDDLEWARE = [
     'django.middleware.security.SecurityMiddleware',
     'netbox.middleware.RemoteUserMiddleware',
     'netbox.middleware.CoreMiddleware',
+    'netbox.middleware.MaintenanceModeMiddleware',
     'django_prometheus.middleware.PrometheusAfterMiddleware',
 ]
 
@@ -476,6 +480,11 @@ AUTH_EXEMPT_PATHS = (
     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 = {
     'json': 'utilities.serializers.json',
 }

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

@@ -310,6 +310,50 @@ class ExternalAuthenticationTestCase(TestCase):
             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(
         REMOTE_AUTH_ENABLED=True,
         REMOTE_AUTH_AUTO_CREATE_USER=True,

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

@@ -77,10 +77,10 @@ Blocks:
           </div>
         {% endif %}
 
-        {% if config.MAINTENANCE_MODE %}
+        {% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %}
           <div class="alert alert-warning text-center mx-3" role="alert">
             <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>
         {% endif %}
 

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

@@ -70,7 +70,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_z side='Z' %}
-      {% include 'inc/panels/contacts.html' %}
       {% include 'inc/panels/image_attachments.html' %}
       {% plugin_right_page object %}
     </div>

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

@@ -132,9 +132,16 @@
             </tr>
             {% for field, value in fields.items %}
               <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>
                   {% customfield_value field value %}
                 </td>

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

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

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

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

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

@@ -298,8 +298,30 @@
                 </div>
               {% endif %}
             </div>
-            {% include 'inc/panels/contacts.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 %}
               <div class="row" style="margin-bottom: 20px">
                 <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">
             <tr>
               <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>
               <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>
               <th scope="row">VRF</th>

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

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

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

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

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

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

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

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

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

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

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

@@ -87,11 +87,13 @@
             <th scope="row">Physical Address</th>
             <td class="position-relative">
               {% 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>
               {% else %}
                 {{ ''|placeholder }}
@@ -106,11 +108,13 @@
             <th scope="row">GPS Coordinates</th>
             <td class="position-relative">
               {% 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>
               {% else %}
                 {{ ''|placeholder }}
@@ -127,7 +131,6 @@
     </div>
     <div class="col col-md-6">
       {% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
-      {% include 'inc/panels/contacts.html' %}
       <div class="card">
         <h5 class="card-header">Locations</h5>
         <div class='card-body'>

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

@@ -42,7 +42,6 @@
     </div>
     {% include 'inc/panels/tags.html' %}
     {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% plugin_left_page object %}
   </div>
 	<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">
             {% for field, value in fields.items %}
               <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>
                 <td>
                   {% customfield_value field value %}

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

@@ -3,13 +3,6 @@
 {% load plugins %}
 {% 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 %}
 <div class="row">
 	<div class="col col-md-4">
@@ -116,7 +109,6 @@
     {% if duplicate_ips_table.rows %}
       {% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %}
     {% endif %}
-    {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IPs' %}
     <div class="card">
       <h5 class="card-header">Services</h5>
       <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 %}
 	</div>
 	<div class="col col-md-6">
-      {% include 'inc/panels/contacts.html' %}
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/comments.html' %}
       {% 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/tags.html' %}
       {% include 'inc/panels/comments.html' %}
-      {% include 'inc/panels/contacts.html' %}
       {% plugin_left_page object %}
     </div>
     <div class="col col-md-5">

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

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

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

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

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

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

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

@@ -59,7 +59,7 @@
                     </tr>
                     <tr>
                         <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>
                         <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 netbox.views import generic
 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 wireless.models import WirelessLAN, WirelessLink
 from . import filtersets, forms, tables
 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
 #
 
+
 class TenantGroupListView(generic.ObjectListView):
     queryset = TenantGroup.objects.add_related_count(
         TenantGroup.objects.all(),
@@ -165,6 +188,11 @@ class TenantBulkDeleteView(generic.BulkDeleteView):
     table = tables.TenantTable
 
 
+@register_model_view(Tenant, 'contacts')
+class TenantContactsView(ObjectContactsView):
+    queryset = Tenant.objects.all()
+
+
 #
 # Contact groups
 #
@@ -342,11 +370,11 @@ class ContactBulkDeleteView(generic.BulkDeleteView):
     filterset = filtersets.ContactFilterSet
     table = tables.ContactTable
 
-
 #
 # Contact assignments
 #
 
+
 class ContactAssignmentListView(generic.ObjectListView):
     queryset = ContactAssignment.objects.all()
     filterset = filtersets.ContactAssignmentFilterSet

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

@@ -32,11 +32,11 @@ class BootstrapMixin:
             elif isinstance(field.widget, forms.CheckboxInput):
                 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'
 
             else:

+ 13 - 1
netbox/utilities/rqworker.py

@@ -1,11 +1,12 @@
 from django_rq.queues import get_connection
-from rq import Worker
+from rq import Retry, Worker
 
 from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
 
 __all__ = (
     'get_queue_for_model',
+    'get_rq_retry',
     '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.
     """
     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:
         model = Cluster
-        fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments', 'tags')
+        fields = ('name', 'type', 'group', 'status', 'site', 'tenant', 'description', 'comments', 'tags')
 
 
 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.tables import DeviceTable
 from extras.views import ObjectConfigContextView
-from ipam.models import IPAddress, Service
+from ipam.models import IPAddress
 from ipam.tables import InterfaceVLANTable
 from netbox.views import generic
+from tenancy.views import ObjectContactsView
 from utilities.utils import count_related
 from utilities.views import ViewTab, register_model_view
 from . import filtersets, forms, tables
@@ -140,6 +141,11 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
     table = tables.ClusterGroupTable
 
 
+@register_model_view(ClusterGroup, 'contacts')
+class ClusterGroupContactsView(ObjectContactsView):
+    queryset = ClusterGroup.objects.all()
+
+
 #
 # Clusters
 #
@@ -312,6 +318,11 @@ class ClusterRemoveDevicesView(generic.ObjectEditView):
         })
 
 
+@register_model_view(Cluster, 'contacts')
+class ClusterContactsView(ObjectContactsView):
+    queryset = Cluster.objects.all()
+
+
 #
 # Virtual machines
 #
@@ -390,6 +401,11 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
     table = tables.VirtualMachineTable
 
 
+@register_model_view(VirtualMachine, 'contacts')
+class VirtualMachineContactsView(ObjectContactsView):
+    queryset = VirtualMachine.objects.all()
+
+
 #
 # VM interfaces
 #

+ 7 - 7
requirements.txt

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