diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index be2aacff5..1a0e68929 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index fcb3516b4..526057454 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.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 diff --git a/README.md b/README.md index 480f0f856..81217797f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@
+ The :ballot_box_with_check: 2023 NetBox Community Survey is now open! +

Please take a few minutes to tell us about your NetBox deployment.

+ NetBox logo - - The premiere source of truth powering network automation +

The premiere source of truth powering network automation

+ CI status +

-![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, diff --git a/docs/administration/netbox-shell.md b/docs/administration/netbox-shell.md index 1a10c5c3e..21cef01b2 100644 --- a/docs/administration/netbox-shell.md +++ b/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' diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index 14f0b9151..fd410a9d4 100644 --- a/docs/configuration/miscellaneous.md +++ b/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. diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index fd95adef5..fb789bd98 100644 --- a/docs/configuration/remote-authentication.md +++ b/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` diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 074da34cd..e20b09ae6 100644 --- a/docs/customization/custom-scripts.md +++ b/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}") diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index dc6c38977..e0e656ce9 100644 --- a/docs/installation/3-netbox.md +++ b/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 diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index ffba6889b..1cd3e1f0a 100644 --- a/docs/installation/6-ldap.md +++ b/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 diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index af2f86e4c..cf3d11126 100644 --- a/docs/integrations/rest-api.md +++ b/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 diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 203c439c2..24d1ce2dc 100644 --- a/docs/release-notes/version-3.5.md +++ b/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 diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index e5f4faee1..f1cfdd1d5 100644 --- a/netbox/circuits/views.py +++ b/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 # diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 1d0eecd21..a91e75e61 100644 --- a/netbox/core/models/jobs.py +++ b/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() ) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 0d9fcdc5f..a62315b57 100644 --- a/netbox/dcim/api/views.py +++ b/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 diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 8ad8a0eb7..cc388b750 100644 --- a/netbox/dcim/choices.py +++ b/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'), diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 6ed483c79..bc9693afb 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/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 diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 8b7bd47ea..de7575acb 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/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: diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index d756036f4..219216045 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/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): diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 236077421..d4c9e6ec3 100644 --- a/netbox/dcim/forms/object_create.py +++ b/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 diff --git a/netbox/dcim/migrations/0172_larger_power_draw_values.py b/netbox/dcim/migrations/0172_larger_power_draw_values.py new file mode 100644 index 000000000..729daf836 --- /dev/null +++ b/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)] + ), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b0dc1677f..6a89655b2 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/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)], diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 645fc5c5c..9f6837b92 100644 --- a/netbox/dcim/models/device_components.py +++ b/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)], diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 02c68c10a..85a5d6870 100644 --- a/netbox/dcim/models/devices.py +++ b/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 diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 62878cef9..9c317ea16 100644 --- a/netbox/dcim/svg/racks.py +++ b/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) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 056d05c9a..db2655d27 100644 --- a/netbox/dcim/tables/devices.py +++ b/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', diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index bae5a8e0b..c0cfca2e7 100644 --- a/netbox/dcim/tests/test_views.py +++ b/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 = ( diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index bcbbf1739..0def4f4a8 100644 --- a/netbox/dcim/views.py +++ b/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 # diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 04a67b521..6d1b14370 100644 --- a/netbox/extras/admin.py +++ b/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', { diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 69d1cc36d..dc68e1388 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/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') ] diff --git a/netbox/extras/migrations/0066_customfield_name_validation.py b/netbox/extras/migrations/0066_customfield_name_validation.py index 7a768c10c..3d2c51399 100644 --- a/netbox/extras/migrations/0066_customfield_name_validation.py +++ b/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'__', + ), + ], + ), ), ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 439d15edc..be3540f08 100644 --- a/netbox/extras/models/customfields.py +++ b/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( diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index e6d014302..8d046b85d 100644 --- a/netbox/extras/tables/tables.py +++ b/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( diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 6a3a3d074..3fd0dc83e 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/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!' diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 23702949a..1fc869ee8 100644 --- a/netbox/extras/webhooks.py +++ b/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() ) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index cf8117bf7..ac75e2cc3 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/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) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 28901ab8e..015f9220c 100644 --- a/netbox/ipam/models/ip.py +++ b/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() diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 93d0dc8bb..6b73a061b 100644 --- a/netbox/ipam/views.py +++ b/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 # diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 798cb80e2..61dfe2fdb 100644 --- a/netbox/netbox/authentication.py +++ b/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( diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 2bfa234f0..9c613217c 100644 --- a/netbox/netbox/config/parameters.py +++ b/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'), diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index f9faa9c5d..76b3e42a8 100644 --- a/netbox/netbox/middleware.py +++ b/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) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index dddceca9b..a5923c9f0 100644 --- a/netbox/netbox/settings.py +++ b/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', } diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 790cb4bd8..4e46996b5 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/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, diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 6b247d81a..9e8b7d7bf 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -77,10 +77,10 @@ Blocks: {% endif %} - {% if config.MAINTENANCE_MODE %} + {% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %} {% endif %} diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index ee994e959..a5913e2ad 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -70,7 +70,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 %}
diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 22c204afc..b26a09205 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -132,9 +132,16 @@ {% for field, value in fields.items %} - - {{ field }} - + {{ field }} + {% if field.description %} + + {% endif %} + {% customfield_value field value %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 5a565ea29..c721d5a58 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -43,7 +43,6 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/circuits/provideraccount.html b/netbox/templates/circuits/provideraccount.html index 63344ada1..c55663b4a 100644 --- a/netbox/templates/circuits/provideraccount.html +++ b/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 %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 2c79ab006..b0e67269c 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -298,8 +298,30 @@
{% endif %} - {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} +
+
Dimensions
+
+ + + + + + + + + +
Height + {{ object.device_type.u_height }}U +
Weight + {% if object.total_weight %} + {{ object.total_weight|floatformat }} Kilograms + {% else %} + {{ ''|placeholder }} + {% endif %} +
+
+
{% if object.rack and object.position %}
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index db0fd7dfd..11f262eeb 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -123,11 +123,11 @@ - + - + diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 193d93f9a..795aeb35f 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -65,7 +65,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 %} diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index a60b3503c..8233b6fc8 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -51,7 +51,6 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index af08f3023..ea9210ba7 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -40,7 +40,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 %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 9cb046b4e..52b5d4bfe 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -191,7 +191,6 @@ {% include 'inc/panels/related_objects.html' %} {% include 'dcim/inc/nonracked_devices.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index 85587e4b5..05cc424d7 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -46,7 +46,6 @@
{% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index d6de8f3cb..697737ceb 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -87,11 +87,13 @@
MAC Address{{ object.mac_address|placeholder }}{{ object.mac_address|placeholder }}
WWN{{ object.wwn|placeholder }}{{ object.wwn|placeholder }}
VRF Physical Address {% if object.physical_address %} - + {% if config.MAPS_URL %} + + {% endif %} {{ object.physical_address|linebreaksbr }} {% else %} {{ ''|placeholder }} @@ -106,11 +108,13 @@ GPS Coordinates {% if object.latitude and object.longitude %} - + {% if config.MAPS_URL %} + + {% endif %} {{ object.latitude }}, {{ object.longitude }} {% else %} {{ ''|placeholder }} @@ -127,7 +131,6 @@
{% include 'inc/panels/related_objects.html' with filter_name='site_id' %} - {% include 'inc/panels/contacts.html' %}
Locations
diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 2cf8e7168..819022a34 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -42,7 +42,6 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/inc/panels/contacts.html b/netbox/templates/inc/panels/contacts.html deleted file mode 100644 index 359ad8d7e..000000000 --- a/netbox/templates/inc/panels/contacts.html +++ /dev/null @@ -1,63 +0,0 @@ -{% load helpers %} - -
-
Contacts
-
- {% with contacts=object.contacts.all %} - {% if contacts.exists %} - - - - - - - - - - {% for contact in contacts %} - - - - - - - - - {% endfor %} -
NameRolePriorityPhoneEmail
{{ contact.contact|linkify }}{{ contact.role|placeholder }}{{ contact.get_priority_display|placeholder }} - {% if contact.contact.phone %} - {{ contact.contact.phone }} - {% else %} - {{ ''|placeholder }} - {% endif %} - - {% if contact.contact.email %} - {{ contact.contact.email }} - {% else %} - {{ ''|placeholder }} - {% endif %} - - {% if perms.tenancy.change_contactassignment %} - - - - {% endif %} - {% if perms.tenancy.delete_contactassignment %} - - - - {% endif %} -
- {% else %} -
None
- {% endif %} - {% endwith %} -
- {% if perms.tenancy.add_contactassignment %} - - {% endif %} -
diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 45843eea5..7f5f4cc27 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -12,8 +12,15 @@ {% for field, value in fields.items %} - - + diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index b9ada8640..5f8a7e314 100644 --- a/netbox/tenancy/views.py +++ b/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 diff --git a/netbox/utilities/forms/mixins.py b/netbox/utilities/forms/mixins.py index dc9c3eb80..7cdb9731e 100644 --- a/netbox/utilities/forms/mixins.py +++ b/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
- {{ field }} + {{ field }} + {% if field.description %} + + {% endif %} {% customfield_value field value %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index c649f1dad..e58ac736f 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -3,13 +3,6 @@ {% load plugins %} {% load render_table from django_tables2 %} -{% block breadcrumbs %} - {{ block.super }} - {% if object.vrf %} - - {% endif %} -{% endblock %} - {% block content %}
@@ -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' %}
Services
{{ object.vrf }} + {% endif %} +{% endblock %} diff --git a/netbox/templates/ipam/ipaddress/ip_addresses.html b/netbox/templates/ipam/ipaddress/ip_addresses.html new file mode 100644 index 000000000..7034329aa --- /dev/null +++ b/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" %} +
+ {% csrf_token %} +
+
+ {% include 'htmx/table.html' %} +
+
+
+{% endblock content %} + +{% block modals %} + {{ block.super }} + {% table_config_form table %} +{% endblock modals %} diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html index 87050eb26..8896dd6c2 100644 --- a/netbox/templates/ipam/l2vpn.html +++ b/netbox/templates/ipam/l2vpn.html @@ -37,7 +37,6 @@ {% plugin_left_page object %}
- {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/tenancy/object_contacts.html b/netbox/templates/tenancy/object_contacts.html new file mode 100644 index 000000000..aca63a379 --- /dev/null +++ b/netbox/templates/tenancy/object_contacts.html @@ -0,0 +1,27 @@ +{% extends base_template %} +{% load helpers %} + +{% block extra_controls %} + {% if perms.tenancy.add_contactassignment %} + + Add a contact + + {% endif %} +{% endblock %} + +{% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="ContactTable_config" %} +
+ {% csrf_token %} +
+
+ {% include 'htmx/table.html' %} +
+
+
+{% endblock content %} + +{% block modals %} + {{ block.super }} + {% table_config_form table %} +{% endblock modals %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index da48f1ef5..34abe5c01 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/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 %}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 3dfef108b..508bca547 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -84,7 +84,6 @@
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html index 510433068..2496ad085 100644 --- a/netbox/templates/virtualization/clustergroup.html +++ b/netbox/templates/virtualization/clustergroup.html @@ -37,7 +37,6 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 5098a2f8f..51fd8aa80 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -158,7 +158,6 @@ {% endif %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index a7d4d92ba..82071bdaa 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -59,7 +59,7 @@
MAC Address{{ object.mac_address|placeholder }}{{ object.mac_address|placeholder }}
802.1Q Mode