mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-24 08:25:17 -06:00
Merge remote-tracking branch 'origin/develop' into 10356-backplane-connections
This commit is contained in:
commit
9c4bd28c1f
@ -58,7 +58,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
|
|||||||
|
|
||||||
Default: None
|
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
|
```python
|
||||||
HTTP_PROXIES = {
|
HTTP_PROXIES = {
|
||||||
|
@ -2,13 +2,27 @@
|
|||||||
|
|
||||||
## v3.3.5 (FUTURE)
|
## 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)
|
## v3.3.4 (2022-09-16)
|
||||||
|
|
||||||
### Bug Fixes
|
### 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
|
* [#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
|
* [#10387](https://github.com/netbox-community/netbox/issues/10387) - Fix `MultiValueDictKeyError` exception when editing a device interface
|
||||||
|
|
||||||
|
@ -373,6 +373,7 @@ class DeviceTypeForm(NetBoxModelForm):
|
|||||||
'front_image', 'rear_image', 'comments', 'tags',
|
'front_image', 'rear_image', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
|
'airflow': StaticSelect(),
|
||||||
'subdevice_role': StaticSelect(),
|
'subdevice_role': StaticSelect(),
|
||||||
'front_image': ClearableFileInput(attrs={
|
'front_image': ClearableFileInput(attrs={
|
||||||
'accept': DEVICETYPE_IMAGE_FORMATS
|
'accept': DEVICETYPE_IMAGE_FORMATS
|
||||||
|
@ -35,7 +35,7 @@ class Node(Hyperlink):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, position, width, url, color, labels, radius=10, **extra):
|
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
|
x, y = position
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ from svgwrite.text import Text
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import FieldError
|
from django.core.exceptions import FieldError
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.template.defaultfilters import floatformat
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.http import urlencode
|
from django.utils.http import urlencode
|
||||||
|
|
||||||
@ -41,7 +42,7 @@ def get_device_description(device):
|
|||||||
device.device_role,
|
device.device_role,
|
||||||
device.device_type.manufacturer.name,
|
device.device_type.manufacturer.name,
|
||||||
device.device_type.model,
|
device.device_type.model,
|
||||||
device.device_type.u_height,
|
floatformat(device.device_type.u_height),
|
||||||
device.asset_tag or '',
|
device.asset_tag or '',
|
||||||
device.serial or ''
|
device.serial or ''
|
||||||
)
|
)
|
||||||
|
@ -85,6 +85,9 @@ class DeviceTypeTable(NetBoxTable):
|
|||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:devicetype_list'
|
url_name='dcim:devicetype_list'
|
||||||
)
|
)
|
||||||
|
u_height = columns.TemplateColumn(
|
||||||
|
template_code='{{ value|floatformat }}'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
|
@ -34,7 +34,9 @@ class CustomFieldsMixin:
|
|||||||
return ContentType.objects.get_for_model(self.model)
|
return ContentType.objects.get_for_model(self.model)
|
||||||
|
|
||||||
def _get_custom_fields(self, content_type):
|
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):
|
def _get_form_field(self, customfield):
|
||||||
return customfield.to_form_field()
|
return customfield.to_form_field()
|
||||||
@ -50,13 +52,6 @@ class CustomFieldsMixin:
|
|||||||
field_name = f'cf_{customfield.name}'
|
field_name = f'cf_{customfield.name}'
|
||||||
self.fields[field_name] = self._get_form_field(customfield)
|
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 += '<br />'
|
|
||||||
self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
|
|
||||||
'Field is set to read-only.'
|
|
||||||
|
|
||||||
# Annotate the field in the list of CustomField form fields
|
# Annotate the field in the list of CustomField form fields
|
||||||
self.custom_fields[field_name] = customfield
|
self.custom_fields[field_name] = customfield
|
||||||
if customfield.group_name not in self.custom_field_groups:
|
if customfield.group_name not in self.custom_field_groups:
|
||||||
|
@ -297,12 +297,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
|||||||
return model.objects.filter(pk__in=value)
|
return model.objects.filter(pk__in=value)
|
||||||
return 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.
|
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.
|
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_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.
|
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
|
||||||
"""
|
"""
|
||||||
initial = self.default if set_initial else None
|
initial = self.default if set_initial else None
|
||||||
@ -398,6 +399,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
|||||||
if self.description:
|
if self.description:
|
||||||
field.help_text = escape(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 = '<br />' if field.help_text else ''
|
||||||
|
field.help_text += f'{prepend}<i class="mdi mdi-alert-circle-outline"></i> Field is set to read-only.'
|
||||||
|
|
||||||
return field
|
return field
|
||||||
|
|
||||||
def to_filter(self, lookup_expr=None):
|
def to_filter(self, lookup_expr=None):
|
||||||
|
@ -81,30 +81,34 @@ class VLANQuerySet(RestrictedQuerySet):
|
|||||||
|
|
||||||
# Find all relevant VLANGroups
|
# Find all relevant VLANGroups
|
||||||
q = Q()
|
q = Q()
|
||||||
if vm.cluster.site:
|
site = vm.site or vm.cluster.site
|
||||||
if vm.cluster.site.region:
|
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(
|
q |= Q(
|
||||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
|
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
|
||||||
scope_id__in=vm.cluster.site.region.get_ancestors(include_self=True)
|
scope_id=vm.cluster.group_id
|
||||||
)
|
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
if site:
|
||||||
|
# Add VLANGroups scoped to the assigned site (or its group or region)
|
||||||
q |= Q(
|
q |= Q(
|
||||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
|
scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
|
||||||
scope_id=vm.cluster.site_id
|
scope_id=site.pk
|
||||||
)
|
)
|
||||||
if vm.cluster.group:
|
if site.region:
|
||||||
q |= Q(
|
q |= Q(
|
||||||
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
|
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
|
||||||
scope_id=vm.cluster.group_id
|
scope_id__in=site.region.get_ancestors(include_self=True)
|
||||||
)
|
)
|
||||||
q |= Q(
|
if site.group:
|
||||||
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
|
q |= Q(
|
||||||
scope_id=vm.cluster_id
|
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)
|
vlan_groups = VLANGroup.objects.filter(q)
|
||||||
|
|
||||||
# Return all applicable VLANs
|
# 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__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
|
||||||
Q(group__isnull=True, site__isnull=True) # Global VLANs
|
Q(group__isnull=True, site__isnull=True) # Global VLANs
|
||||||
)
|
)
|
||||||
if vm.cluster.site:
|
if site:
|
||||||
q |= Q(site=vm.cluster.site)
|
q |= Q(site=site)
|
||||||
|
|
||||||
return self.filter(q)
|
return self.filter(q)
|
||||||
|
@ -2,7 +2,7 @@ from django import forms
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Q
|
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.forms.customfields import CustomFieldsMixin
|
||||||
from extras.models import CustomField, Tag
|
from extras.models import CustomField, Tag
|
||||||
from utilities.forms import BootstrapMixin, CSVModelForm
|
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)
|
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):
|
def _get_form_field(self, customfield):
|
||||||
return customfield.to_form_field(for_csv_import=True)
|
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):
|
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(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
|
||||||
Q(type=CustomFieldTypeChoices.TYPE_JSON)
|
Q(type=CustomFieldTypeChoices.TYPE_JSON)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_form_field(self, customfield):
|
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)
|
||||||
|
@ -66,7 +66,7 @@
|
|||||||
{% with object.parent_bay.device as parent %}
|
{% with object.parent_bay.device as parent %}
|
||||||
{{ parent|linkify }} / {{ object.parent_bay }}
|
{{ parent|linkify }} / {{ object.parent_bay }}
|
||||||
{% if parent.position %}
|
{% if parent.position %}
|
||||||
(U{{ parent.position }} / {{ parent.get_face_display }})
|
(U{{ parent.position|floatformat }} / {{ parent.get_face_display }})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% elif object.rack and object.position %}
|
{% elif object.rack and object.position %}
|
||||||
@ -90,7 +90,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Device Type</th>
|
<th scope="row">Device Type</th>
|
||||||
<td>
|
<td>
|
||||||
{{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height }}U)
|
{{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height|floatformat }}U)
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -29,7 +29,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Height (U)</td>
|
<td>Height (U)</td>
|
||||||
<td>{{ object.u_height }}</td>
|
<td>{{ object.u_height|floatformat }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Full Depth</td>
|
<td>Full Depth</td>
|
||||||
|
@ -3,6 +3,9 @@
|
|||||||
{% load form_helpers %}
|
{% load form_helpers %}
|
||||||
|
|
||||||
{% block form %}
|
{% block form %}
|
||||||
|
{% for field in form.hidden_fields %}
|
||||||
|
{{ field }}
|
||||||
|
{% endfor %}
|
||||||
<div class="field-group my-5">
|
<div class="field-group my-5">
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<h5 class="offset-sm-3">Contact Assignment</h5>
|
<h5 class="offset-sm-3">Contact Assignment</h5>
|
||||||
|
@ -119,8 +119,10 @@ class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ContactAssignment
|
model = ContactAssignment
|
||||||
fields = (
|
fields = (
|
||||||
'group', 'contact', 'role', 'priority',
|
'content_type', 'object_id', 'group', 'contact', 'role', 'priority',
|
||||||
)
|
)
|
||||||
widgets = {
|
widgets = {
|
||||||
|
'content_type': forms.HiddenInput(),
|
||||||
|
'object_id': forms.HiddenInput(),
|
||||||
'priority': StaticSelect(),
|
'priority': StaticSelect(),
|
||||||
}
|
}
|
||||||
|
@ -73,9 +73,9 @@ def humanize_megabytes(mb):
|
|||||||
"""
|
"""
|
||||||
if not mb:
|
if not mb:
|
||||||
return ''
|
return ''
|
||||||
if mb >= 1048576:
|
if not mb % 1048576: # 1024^2
|
||||||
return f'{int(mb / 1048576)} TB'
|
return f'{int(mb / 1048576)} TB'
|
||||||
if mb >= 1024:
|
if not mb % 1024:
|
||||||
return f'{int(mb / 1024)} GB'
|
return f'{int(mb / 1024)} GB'
|
||||||
return f'{mb} MB'
|
return f'{mb} MB'
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user