diff --git a/docs/configuration/system.md b/docs/configuration/system.md
index 21607e566..93f8fa902 100644
--- a/docs/configuration/system.md
+++ b/docs/configuration/system.md
@@ -58,7 +58,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
Default: None
-A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example:
+A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example:
```python
HTTP_PROXIES = {
diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md
index 71f5605f9..e91e825f5 100644
--- a/docs/release-notes/version-3.3.md
+++ b/docs/release-notes/version-3.3.md
@@ -2,13 +2,27 @@
## v3.3.5 (FUTURE)
+### Enhancements
+
+* [#10465](https://github.com/netbox-community/netbox/issues/10465) - Improve formatting of device heights and rack positions
+
+### Bug Fixes
+
+* [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view
+* [#10408](https://github.com/netbox-community/netbox/issues/10408) - Fix validation when attempting to add redundant contact assignments
+* [#10435](https://github.com/netbox-community/netbox/issues/10435) - Fix exception when filtering VLANs by virtual machine with no cluster assigned
+* [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field
+* [#10445](https://github.com/netbox-community/netbox/issues/10445) - Avoid rounding virtual machine memory values
+* [#10461](https://github.com/netbox-community/netbox/issues/10461) - Enable filtering by read-only custom fields in the UI
+* [#10470](https://github.com/netbox-community/netbox/issues/10470) - Omit read-only custom fields from CSV import forms
+* [#10480](https://github.com/netbox-community/netbox/issues/10480) - Cable trace SVG links should not force a new window
+
---
## v3.3.4 (2022-09-16)
### Bug Fixes
-* [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view
* [#10383](https://github.com/netbox-community/netbox/issues/10383) - Fix assignment of component templates to module types via web UI
* [#10387](https://github.com/netbox-community/netbox/issues/10387) - Fix `MultiValueDictKeyError` exception when editing a device interface
diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py
index 5728e7f2d..b33023ece 100644
--- a/netbox/dcim/forms/models.py
+++ b/netbox/dcim/forms/models.py
@@ -373,6 +373,7 @@ class DeviceTypeForm(NetBoxModelForm):
'front_image', 'rear_image', 'comments', 'tags',
]
widgets = {
+ 'airflow': StaticSelect(),
'subdevice_role': StaticSelect(),
'front_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS
diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py
index 3872bc4fe..9a847acc9 100644
--- a/netbox/dcim/svg/cables.py
+++ b/netbox/dcim/svg/cables.py
@@ -35,7 +35,7 @@ class Node(Hyperlink):
"""
def __init__(self, position, width, url, color, labels, radius=10, **extra):
- super(Node, self).__init__(href=url, target='_blank', **extra)
+ super(Node, self).__init__(href=url, target='_parent', **extra)
x, y = position
diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py
index 573fc966c..6c57e6023 100644
--- a/netbox/dcim/svg/racks.py
+++ b/netbox/dcim/svg/racks.py
@@ -9,6 +9,7 @@ from svgwrite.text import Text
from django.conf import settings
from django.core.exceptions import FieldError
from django.db.models import Q
+from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils.http import urlencode
@@ -41,7 +42,7 @@ def get_device_description(device):
device.device_role,
device.device_type.manufacturer.name,
device.device_type.model,
- device.device_type.u_height,
+ floatformat(device.device_type.u_height),
device.asset_tag or '',
device.serial or ''
)
diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py
index 3ed4d8c08..ec71245f7 100644
--- a/netbox/dcim/tables/devicetypes.py
+++ b/netbox/dcim/tables/devicetypes.py
@@ -85,6 +85,9 @@ class DeviceTypeTable(NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:devicetype_list'
)
+ u_height = columns.TemplateColumn(
+ template_code='{{ value|floatformat }}'
+ )
class Meta(NetBoxTable.Meta):
model = DeviceType
diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py
index 7574f4f2b..40d068450 100644
--- a/netbox/extras/forms/customfields.py
+++ b/netbox/extras/forms/customfields.py
@@ -34,7 +34,9 @@ class CustomFieldsMixin:
return ContentType.objects.get_for_model(self.model)
def _get_custom_fields(self, content_type):
- return CustomField.objects.filter(content_types=content_type)
+ return CustomField.objects.filter(content_types=content_type).exclude(
+ ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
+ )
def _get_form_field(self, customfield):
return customfield.to_form_field()
@@ -50,13 +52,6 @@ class CustomFieldsMixin:
field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield)
- if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
- self.fields[field_name].disabled = True
- if self.fields[field_name].help_text:
- self.fields[field_name].help_text += '
'
- self.fields[field_name].help_text += ' ' \
- 'Field is set to read-only.'
-
# Annotate the field in the list of CustomField form fields
self.custom_fields[field_name] = customfield
if customfield.group_name not in self.custom_field_groups:
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index 43c4f9671..d52d73848 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -297,12 +297,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
return model.objects.filter(pk__in=value)
return value
- def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
+ def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False):
"""
Return a form field suitable for setting a CustomField's value for an object.
set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
+ enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
"""
initial = self.default if set_initial else None
@@ -398,6 +399,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
if self.description:
field.help_text = escape(self.description)
+ # Annotate read-only fields
+ if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
+ field.disabled = True
+ prepend = '
' if field.help_text else ''
+ field.help_text += f'{prepend} Field is set to read-only.'
+
return field
def to_filter(self, lookup_expr=None):
diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py
index 7edac2eff..9f4463f61 100644
--- a/netbox/ipam/querysets.py
+++ b/netbox/ipam/querysets.py
@@ -81,30 +81,34 @@ class VLANQuerySet(RestrictedQuerySet):
# Find all relevant VLANGroups
q = Q()
- if vm.cluster.site:
- if vm.cluster.site.region:
+ site = vm.site or vm.cluster.site
+ if vm.cluster:
+ # Add VLANGroups scoped to the assigned cluster (or its group)
+ q |= Q(
+ scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
+ scope_id=vm.cluster_id
+ )
+ if vm.cluster.group:
q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
- scope_id__in=vm.cluster.site.region.get_ancestors(include_self=True)
- )
- if vm.cluster.site.group:
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
- scope_id__in=vm.cluster.site.group.get_ancestors(include_self=True)
+ scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
+ scope_id=vm.cluster.group_id
)
+ if site:
+ # Add VLANGroups scoped to the assigned site (or its group or region)
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
- scope_id=vm.cluster.site_id
+ scope_id=site.pk
)
- if vm.cluster.group:
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
- scope_id=vm.cluster.group_id
- )
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
- scope_id=vm.cluster_id
- )
+ if site.region:
+ q |= Q(
+ scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
+ scope_id__in=site.region.get_ancestors(include_self=True)
+ )
+ if site.group:
+ q |= Q(
+ scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
+ scope_id__in=site.group.get_ancestors(include_self=True)
+ )
vlan_groups = VLANGroup.objects.filter(q)
# Return all applicable VLANs
@@ -113,7 +117,7 @@ class VLANQuerySet(RestrictedQuerySet):
Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
Q(group__isnull=True, site__isnull=True) # Global VLANs
)
- if vm.cluster.site:
- q |= Q(site=vm.cluster.site)
+ if site:
+ q |= Q(site=site)
return self.filter(q)
diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py
index 2676e4cde..2cbc67971 100644
--- a/netbox/netbox/forms/base.py
+++ b/netbox/netbox/forms/base.py
@@ -2,7 +2,7 @@ from django import forms
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
-from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices
+from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
from extras.forms.customfields import CustomFieldsMixin
from extras.models import CustomField, Tag
from utilities.forms import BootstrapMixin, CSVModelForm
@@ -63,6 +63,11 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm):
"""
tags = None # Temporary fix in lieu of tag import support (see #9158)
+ def _get_custom_fields(self, content_type):
+ return CustomField.objects.filter(content_types=content_type).filter(
+ ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE
+ )
+
def _get_form_field(self, customfield):
return customfield.to_form_field(for_csv_import=True)
@@ -125,10 +130,10 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
)
def _get_custom_fields(self, content_type):
- return CustomField.objects.filter(content_types=content_type).exclude(
+ return super()._get_custom_fields(content_type).exclude(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
Q(type=CustomFieldTypeChoices.TYPE_JSON)
)
def _get_form_field(self, customfield):
- return customfield.to_form_field(set_initial=False, enforce_required=False)
+ return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False)
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html
index 6cc859749..253d905f2 100644
--- a/netbox/templates/dcim/device.html
+++ b/netbox/templates/dcim/device.html
@@ -66,7 +66,7 @@
{% with object.parent_bay.device as parent %}
{{ parent|linkify }} / {{ object.parent_bay }}
{% if parent.position %}
- (U{{ parent.position }} / {{ parent.get_face_display }})
+ (U{{ parent.position|floatformat }} / {{ parent.get_face_display }})
{% endif %}
{% endwith %}
{% elif object.rack and object.position %}
@@ -90,7 +90,7 @@