mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-26 09:16:10 -06:00
Merge branch 'develop' into 12468-custom-field-name
This commit is contained in:
commit
8b2f0cdc1e
@ -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'
|
||||
|
@ -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}")
|
||||
|
||||
|
@ -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
|
||||
|
@ -4,9 +4,19 @@
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#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
|
||||
* [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form
|
||||
|
||||
---
|
||||
|
||||
## v3.5.1 (2023-05-05)
|
||||
|
@ -807,6 +807,7 @@ 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'
|
||||
@ -952,6 +953,7 @@ 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)'),
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
||||
|
@ -755,19 +755,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 +862,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
|
||||
#
|
||||
|
@ -132,9 +132,16 @@
|
||||
</tr>
|
||||
{% for field, value in fields.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<span title="{{ field.description|escape }}">{{ field }}</span>
|
||||
</td>
|
||||
<th scope="row">{{ field }}
|
||||
{% if field.description %}
|
||||
<i
|
||||
class="mdi mdi-information text-primary"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="right"
|
||||
title="{{ field.description|escape }}"
|
||||
></i>
|
||||
{% endif %}
|
||||
</th>
|
||||
<td>
|
||||
{% customfield_value field value %}
|
||||
</td>
|
||||
|
@ -313,8 +313,8 @@
|
||||
<tr>
|
||||
<th scope="row">Weight</th>
|
||||
<td>
|
||||
{% if object.device_type.weight %}
|
||||
{{ object.device_type.weight|floatformat }} {{ object.device_type.get_weight_unit_display }}
|
||||
{% if object.total_weight %}
|
||||
{{ object.total_weight|floatformat }} Kilograms
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
|
@ -12,8 +12,15 @@
|
||||
<table class="table table-hover attr-table">
|
||||
{% for field, value in fields.items %}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<span title="{{ field.description|escape }}">{{ field }}</span>
|
||||
<th scope="row">{{ field }}
|
||||
{% if field.description %}
|
||||
<i
|
||||
class="mdi mdi-information text-primary"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="right"
|
||||
title="{{ field.description|escape }}"
|
||||
></i>
|
||||
{% endif %}
|
||||
</th>
|
||||
<td>
|
||||
{% customfield_value field value %}
|
||||
|
@ -3,13 +3,6 @@
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
{% if object.vrf %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-4">
|
||||
@ -116,7 +109,6 @@
|
||||
{% if duplicate_ips_table.rows %}
|
||||
{% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %}
|
||||
{% endif %}
|
||||
{% include 'inc/panel_table.html' with table=related_ips_table heading='Related IPs' %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">Services</h5>
|
||||
<div class="card-body htmx-container table-responsive"
|
||||
|
8
netbox/templates/ipam/ipaddress/base.html
Normal file
8
netbox/templates/ipam/ipaddress/base.html
Normal file
@ -0,0 +1,8 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
{% if object.vrf %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
|
||||
{% endif %}
|
||||
{% endblock %}
|
19
netbox/templates/ipam/ipaddress/ip_addresses.html
Normal file
19
netbox/templates/ipam/ipaddress/ip_addresses.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% extends 'ipam/ipaddress/base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="card">
|
||||
<div class="card-body" id="object_list">
|
||||
{% include 'htmx/table.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
@ -32,11 +32,11 @@ class BootstrapMixin:
|
||||
elif isinstance(field.widget, forms.CheckboxInput):
|
||||
field.widget.attrs['class'] = f'{css} form-check-input'
|
||||
|
||||
elif isinstance(field.widget, forms.SelectMultiple):
|
||||
if 'size' not in field.widget.attrs:
|
||||
field.widget.attrs['class'] = f'{css} netbox-static-select'
|
||||
elif isinstance(field.widget, forms.SelectMultiple) and 'size' in field.widget.attrs:
|
||||
# Use native Bootstrap class for multi-line <select> widgets
|
||||
field.widget.attrs['class'] = f'{css} form-select form-select-sm'
|
||||
|
||||
elif isinstance(field.widget, forms.Select):
|
||||
elif isinstance(field.widget, (forms.Select, forms.SelectMultiple)):
|
||||
field.widget.attrs['class'] = f'{css} netbox-static-select'
|
||||
|
||||
else:
|
||||
|
Loading…
Reference in New Issue
Block a user