Merge branch 'develop' into 17292-detect-infinite-loop-in-cable-trace

This commit is contained in:
Brian Tiemann 2024-12-10 14:28:12 -05:00
commit b8545d2ff6
56 changed files with 5325 additions and 4729 deletions

View File

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

View File

@ -39,7 +39,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.1.6
placeholder: v4.1.7
validations:
required: true
- type: dropdown

View File

@ -15,6 +15,11 @@ on:
permissions:
contents: read
# Add concurrency group to control job running
concurrency:
group: ${{ github.event_name }}-${{ github.ref }}-${{ github.actor }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest

View File

@ -18,8 +18,17 @@ jobs:
NETBOX_CONFIGURATION: netbox.configuration_testing
steps:
- name: Create app token
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: 1076524
private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }}
- name: Check out repo
uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
- name: Set up Python
uses: actions/setup-python@v5

View File

@ -329,6 +329,7 @@
"100base-tx",
"100base-t1",
"1000base-t",
"1000base-lx",
"1000base-tx",
"2.5gbase-t",
"5gbase-t",

View File

@ -10,6 +10,15 @@ Minor releases are published in April, August, and December of each calendar yea
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 4.1](./version-4.1.md) (September 2024)
* Circuit Groups ([#7025](https://github.com/netbox-community/netbox/issues/7025))
* VLAN Group ID Ranges ([#9627](https://github.com/netbox-community/netbox/issues/9627))
* Nested Device Modules ([#10500](https://github.com/netbox-community/netbox/issues/10500))
* Rack Types ([#12826](https://github.com/netbox-community/netbox/issues/12826))
* Plugins Catalog Integration ([#14731](https://github.com/netbox-community/netbox/issues/14731))
* User Notifications ([#15621](https://github.com/netbox-community/netbox/issues/15621))
#### [Version 4.0](./version-4.0.md) (April 2024)
* Complete UI Refresh ([#12128](https://github.com/netbox-community/netbox/issues/12128))

View File

@ -6,14 +6,21 @@
* [#15239](https://github.com/netbox-community/netbox/issues/15239) - Enable adding/removing individual VLANs while bulk editing device interfaces
* [#17871](https://github.com/netbox-community/netbox/issues/17871) - Enable the assignment/removal of virtualization cluster via device bulk edit
* [#17934](https://github.com/netbox-community/netbox/issues/17934) - Add 1000Base-LX interface type
* [#18007](https://github.com/netbox-community/netbox/issues/18007) - Hide sensitive parameters under data source view (even for privileged users)
### Bug Fixes
* [#17459](https://github.com/netbox-community/netbox/issues/17459) - Correct help text on `name` field of module type component templates
* [#17901](https://github.com/netbox-community/netbox/issues/17901) - Ensure GraphiQL UI resources are served locally
* [#17921](https://github.com/netbox-community/netbox/issues/17921) - Fix scheduling of recurring custom scripts
* [#17923](https://github.com/netbox-community/netbox/issues/17923) - Fix the execution of custom scripts via REST API & management command
* [#17963](https://github.com/netbox-community/netbox/issues/17963) - Fix selection of all listed objects during bulk edit
* [#17969](https://github.com/netbox-community/netbox/issues/17969) - Fix system info export when a config revision exists
* [#17972](https://github.com/netbox-community/netbox/issues/17972) - Force evaluation of `LOGIN_REQUIRED` when requesting static media
* [#17986](https://github.com/netbox-community/netbox/issues/17986) - Correct labels for virtual machine & virtual disk size properties
* [#18037](https://github.com/netbox-community/netbox/issues/18037) - Fix validation of maximum VLAN ID value when defining VLAN groups
* [#18038](https://github.com/netbox-community/netbox/issues/18038) - The `to_grams()` utility function should always return an integer value
---

View File

@ -9,6 +9,7 @@ from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from rq.exceptions import InvalidJobOperation
from core.choices import JobStatusChoices
from core.models import ObjectType
@ -158,7 +159,11 @@ class Job(models.Model):
job = queue.fetch_job(str(self.job_id))
if job:
job.cancel()
try:
job.cancel()
except InvalidJobOperation:
# Job may raise this exception from get_status() if missing from Redis
pass
def start(self):
"""

View File

@ -871,6 +871,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100ME_T1 = '100base-t1'
TYPE_100ME_SFP = '100base-x-sfp'
TYPE_1GE_FIXED = '1000base-t'
TYPE_1GE_LX_FIXED = '1000base-lx'
TYPE_1GE_TX_FIXED = '1000base-tx'
TYPE_1GE_GBIC = '1000base-x-gbic'
TYPE_1GE_SFP = '1000base-x-sfp'
@ -1033,6 +1034,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
(TYPE_1GE_LX_FIXED, '1000BASE-LX (1GE)'),
(TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),

View File

@ -909,6 +909,13 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
if self.instance.pk:
self.fields['module_type'].disabled = True
# Components attached to a module need to present this standardized substitution help text.
self.fields['name'].help_text = _(
"Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range are not "
"supported (example: <code>[ge,xe]-0/0/[0-9]</code>). The token <code>{module}</code>, if present, will be "
"automatically replaced with the position value when creating a new module."
)
class ConsolePortTemplateForm(ModularComponentTemplateForm):
fieldsets = (

View File

@ -243,14 +243,6 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
class Meta(model_forms.InterfaceForm.Meta):
exclude = ('name', 'label')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'module' in self.fields:
self.fields['name'].help_text += _(
"The string <code>{module}</code> will be replaced with the position of the assigned module, if any."
)
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
device = DynamicModelChoiceField(

View File

@ -48,6 +48,7 @@ def get_device_description(device):
Name: <name>
Role: <role>
Status: <status>
Device Type: <manufacturer> <model> (<u_height>)
Asset tag: <asset_tag> (if defined)
Serial: <serial> (if defined)
@ -55,6 +56,7 @@ def get_device_description(device):
"""
description = f'Name: {device.name}'
description += f'\nRole: {device.role}'
description += f'\nStatus: {device.get_status_display()}'
u_height = f'{floatformat(device.device_type.u_height)}U'
description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})'
if device.asset_tag:

View File

@ -22,9 +22,7 @@ class ScriptJob(JobRunner):
"""
class Meta:
# An explicit job name is not set because it doesn't make sense in this context. Currently, there's no scenario
# where jobs other than this one are used. Therefore, it is hidden, resulting in a cleaner job table overview.
name = ''
name = 'Run Script'
def run_script(self, script, request, data, commit):
"""

View File

@ -1141,12 +1141,14 @@ class ScriptView(BaseScriptView):
script_class = self._get_script_class(script)
if not script_class:
return render(request, 'extras/script.html', {
'object': script,
'script': script,
})
form = script_class.as_form(initial=normalize_querydict(request.GET))
return render(request, 'extras/script.html', {
'object': script,
'script': script,
'script_class': script_class,
'form': form,
@ -1162,6 +1164,7 @@ class ScriptView(BaseScriptView):
script_class = self._get_script_class(script)
if not script_class:
return render(request, 'extras/script.html', {
'object': script,
'script': script,
})
@ -1181,12 +1184,12 @@ class ScriptView(BaseScriptView):
request=copy_safe_request(request),
job_timeout=script.python_class.job_timeout,
commit=form.cleaned_data.pop('_commit'),
name=script.name
)
return redirect('extras:script_result', job_pk=job.pk)
return render(request, 'extras/script.html', {
'object': script,
'script': script,
'script_class': script.python_class(),
'form': form,

View File

@ -326,12 +326,17 @@ class IPAddressImportForm(NetBoxModelImportForm):
help_text=_('Make this the primary IP for the assigned device'),
required=False
)
is_oob = forms.BooleanField(
label=_('Is out-of-band'),
help_text=_('Designate this as the out-of-band IP address for the assigned device'),
required=False
)
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
'dns_name', 'description', 'comments', 'tags',
'is_oob', 'dns_name', 'description', 'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
@ -345,7 +350,7 @@ class IPAddressImportForm(NetBoxModelImportForm):
**{f"device__{self.fields['device'].to_field_name}": data['device']}
)
# Limit interface queryset by assigned device
# Limit interface queryset by assigned VM
elif data.get('virtual_machine'):
self.fields['interface'].queryset = VMInterface.objects.filter(
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
@ -358,16 +363,29 @@ class IPAddressImportForm(NetBoxModelImportForm):
virtual_machine = self.cleaned_data.get('virtual_machine')
interface = self.cleaned_data.get('interface')
is_primary = self.cleaned_data.get('is_primary')
is_oob = self.cleaned_data.get('is_oob')
# Validate is_primary
# Validate is_primary and is_oob
if is_primary and not device and not virtual_machine:
raise forms.ValidationError({
"is_primary": _("No device or virtual machine specified; cannot set as primary IP")
})
if is_oob and not device:
raise forms.ValidationError({
"is_oob": _("No device specified; cannot set as out-of-band IP")
})
if is_oob and virtual_machine:
raise forms.ValidationError({
"is_oob": _("Cannot set out-of-band IP for virtual machines")
})
if is_primary and not interface:
raise forms.ValidationError({
"is_primary": _("No interface specified; cannot set as primary IP")
})
if is_oob and not interface:
raise forms.ValidationError({
"is_oob": _("No interface specified; cannot set as out-of-band IP")
})
def save(self, *args, **kwargs):
@ -386,6 +404,12 @@ class IPAddressImportForm(NetBoxModelImportForm):
parent.primary_ip6 = ipaddress
parent.save()
# Set as OOB for device
if self.cleaned_data.get('is_oob'):
parent = self.cleaned_data.get('device')
parent.oob_ip = ipaddress
parent.save()
return ipaddress

View File

@ -309,6 +309,10 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
required=False,
label=_('Make this the primary IP for the device/VM')
)
oob_for_parent = forms.BooleanField(
required=False,
label=_('Make this the out-of-band IP for the device')
)
comments = CommentField()
fieldsets = (
@ -320,7 +324,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
FieldSet('vminterface', name=_('Virtual Machine')),
FieldSet('fhrpgroup', name=_('FHRP Group')),
),
'primary_for_parent', name=_('Assignment')
'primary_for_parent', 'oob_for_parent', name=_('Assignment')
),
FieldSet('nat_inside', name=_('NAT IP (Inside)')),
)
@ -328,8 +332,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_inside', 'tenant_group',
'tenant', 'description', 'comments', 'tags',
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent', 'nat_inside',
'tenant_group', 'tenant', 'description', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
@ -348,7 +352,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
super().__init__(*args, **kwargs)
# Initialize primary_for_parent if IP address is already assigned
# Initialize parent object & fields if IP address is already assigned
if self.instance.pk and self.instance.assigned_object:
parent = getattr(self.instance.assigned_object, 'parent_object', None)
if parent and (
@ -357,6 +361,9 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
):
self.initial['primary_for_parent'] = True
if parent and (parent.oob_ip_id == self.instance.pk):
self.initial['oob_for_parent'] = True
if type(instance.assigned_object) is Interface:
self.fields['interface'].widget.add_query_params({
'device_id': instance.assigned_object.device.pk,
@ -385,10 +392,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
})
elif selected_objects:
assigned_object = self.cleaned_data[selected_objects[0]]
if self.instance.pk and self.instance.assigned_object and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
raise ValidationError(
_("Cannot reassign IP address while it is designated as the primary IP for the parent object")
)
if self.instance.pk and self.instance.assigned_object and assigned_object != self.instance.assigned_object:
if self.cleaned_data['primary_for_parent']:
raise ValidationError(
_("Cannot reassign primary IP address for the parent device/VM")
)
if self.cleaned_data['oob_for_parent']:
raise ValidationError(
_("Cannot reassign out-of-Band IP address for the parent device")
)
self.instance.assigned_object = assigned_object
else:
self.instance.assigned_object = None
@ -400,6 +412,16 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
)
# OOB IP assignment is only available if device interface has been assigned.
interface = self.cleaned_data.get('interface')
if self.cleaned_data.get('oob_for_parent') and not interface:
self.add_error(
'oob_for_parent', _(
"Only IP addresses assigned to a device interface can be designated as the out-of-band IP for a "
"device."
)
)
def save(self, *args, **kwargs):
ipaddress = super().save(*args, **kwargs)
@ -421,6 +443,17 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
parent.primary_ip6 = None
parent.save()
# Assign/clear this IPAddress as the OOB for the associated Device
if type(interface) is Interface:
parent = interface.parent_object
parent.snapshot()
if self.cleaned_data['oob_for_parent']:
parent.oob_ip = ipaddress
parent.save()
elif parent.oob_ip == ipaddress:
parent.oob_ip = None
parent.save()
return ipaddress

View File

@ -97,16 +97,32 @@ class VLANGroup(OrganizationalModel):
raise ValidationError(_("Cannot set scope_id without scope_type."))
# Validate VID ranges
if self.vid_ranges and check_ranges_overlap(self.vid_ranges):
raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")})
for vid_range in self.vid_ranges:
if vid_range.lower > vid_range.upper:
lower_vid = vid_range.lower if vid_range.lower_inc else vid_range.lower + 1
upper_vid = vid_range.upper if vid_range.upper_inc else vid_range.upper - 1
if lower_vid < VLAN_VID_MIN:
raise ValidationError({
'vid_ranges': _("Starting VLAN ID in range ({value}) cannot be less than {minimum}").format(
value=lower_vid, minimum=VLAN_VID_MIN
)
})
if upper_vid > VLAN_VID_MAX:
raise ValidationError({
'vid_ranges': _("Ending VLAN ID in range ({value}) cannot exceed {maximum}").format(
value=upper_vid, maximum=VLAN_VID_MAX
)
})
if lower_vid > upper_vid:
raise ValidationError({
'vid_ranges': _(
"Maximum child VID must be greater than or equal to minimum child VID ({value})"
).format(value=vid_range)
"Ending VLAN ID in range must be greater than or equal to the starting VLAN ID ({range})"
).format(range=f'{lower_vid}-{upper_vid}')
})
# Check for overlapping VID ranges
if self.vid_ranges and check_ranges_overlap(self.vid_ranges):
raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")})
def save(self, *args, **kwargs):
self._total_vlan_ids = 0
for vid_range in self.vid_ranges:

View File

@ -76,6 +76,12 @@ class ValidatedModelSerializer(BaseModelSerializer):
Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
"""
# Bypass DRF's built-in validation of unique constraints due to DRF bug #9410. Rely instead
# on our own custom model validation (below).
def get_unique_together_constraints(self, model):
return []
def validate(self, data):
# Skip validation if we're being used to represent a nested object

View File

@ -72,6 +72,7 @@ class JobRunner(ABC):
kwargs["job_timeout"] = job.object.python_class.job_timeout
cls.enqueue(
instance=job.object,
name=job.name,
user=job.user,
schedule_at=new_scheduled_time,
interval=job.interval,

View File

@ -744,7 +744,6 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
renamed_pks = []
for obj in selected_objects:
# Take a snapshot of change-logged models
if hasattr(obj, 'snapshot'):
obj.snapshot()
@ -758,7 +757,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
except re.error:
obj.new_name = obj.name
else:
obj.new_name = obj.name.replace(find, replace)
obj.new_name = (obj.name or '').replace(find, replace)
renamed_pks.append(obj.pk)
return renamed_pks
@ -793,6 +792,10 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
)
return redirect(self.get_return_url(request))
except IntegrityError as e:
messages.error(self.request, ", ".join(e.args))
clear_events.send(sender=self)
except (AbortRequest, PermissionsViolation) as e:
logger.debug(e.message)
form.add_error(None, e.message)

View File

@ -1,3 +1,3 @@
version: "4.1.6"
version: "4.1.7"
edition: "Community"
published: "2024-10-31"
published: "2024-11-21"

View File

@ -19,7 +19,7 @@ Blocks:
<div class="page">
{# Sidebar #}
<aside class="navbar navbar-vertical navbar-expand-lg">
<aside class="navbar navbar-vertical navbar-expand-lg d-print-none">
{% if 'commercial' in settings.RELEASE.features %}
<img class="motif" src="{% static 'motif.svg' %}" alt="{% trans "NetBox Motif" %}">

View File

@ -87,7 +87,7 @@
{% for name, field in backend.parameters.items %}
<tr>
<th scope="row">{{ field.label }}</th>
{% if name in backend.sensitive_parameters and not perms.core.change_datasource %}
{% if name in backend.sensitive_parameters %}
<td>********</td>
{% else %}
<td>{{ object.parameters|get_key:name|placeholder }}</td>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -10,9 +10,9 @@ __all__ = (
)
def to_grams(weight, unit):
def to_grams(weight, unit) -> int:
"""
Convert the given weight to kilograms.
Convert the given weight to integer grams.
"""
try:
if weight < 0:
@ -21,13 +21,13 @@ def to_grams(weight, unit):
raise TypeError(_("Invalid value '{weight}' for weight (must be a number)").format(weight=weight))
if unit == WeightUnitChoices.UNIT_KILOGRAM:
return weight * 1000
return int(weight * 1000)
if unit == WeightUnitChoices.UNIT_GRAM:
return weight
return int(weight)
if unit == WeightUnitChoices.UNIT_POUND:
return weight * Decimal(453.592)
return int(weight * Decimal(453.592))
if unit == WeightUnitChoices.UNIT_OUNCE:
return weight * Decimal(28.3495)
return int(weight * Decimal(28.3495))
raise ValueError(
_("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
unit=unit,

View File

@ -28,10 +28,14 @@ class DataFileLoader(BaseLoader):
raise TemplateNotFound(template)
# Find and pre-fetch referenced templates
if referenced_templates := find_referenced_templates(environment.parse(template_source)):
if referenced_templates := tuple(find_referenced_templates(environment.parse(template_source))):
related_files = DataFile.objects.filter(source=self.data_source)
# None indicates the use of dynamic resolution. If dependent files are statically
# defined, we can filter by path for optimization.
if None not in referenced_templates:
related_files = related_files.filter(path__in=referenced_templates)
self.cache_templates({
df.path: df.data_as_string for df in
DataFile.objects.filter(source=self.data_source, path__in=referenced_templates)
df.path: df.data_as_string for df in related_files
})
return template_source, template, lambda: True

View File

@ -23,15 +23,23 @@ class TunnelStatusChoices(ChoiceSet):
class TunnelEncapsulationChoices(ChoiceSet):
ENCAP_GRE = 'gre'
ENCAP_IP_IP = 'ip-ip'
ENCAP_IPSEC_TRANSPORT = 'ipsec-transport'
ENCAP_IPSEC_TUNNEL = 'ipsec-tunnel'
ENCAP_IP_IP = 'ip-ip'
ENCAP_L2TP = 'l2tp'
ENCAP_OPENVPN = 'openvpn'
ENCAP_PPTP = 'pptp'
ENCAP_WIREGUARD = 'wireguard'
CHOICES = [
(ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')),
(ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')),
(ENCAP_IP_IP, _('IP-in-IP')),
(ENCAP_GRE, _('GRE')),
(ENCAP_WIREGUARD, _('WireGuard')),
(ENCAP_OPENVPN, _('OpenVPN')),
(ENCAP_L2TP, _('L2TP')),
(ENCAP_PPTP, _('PPTP')),
]

View File

@ -15,13 +15,13 @@ django-tables2==2.7.0
django-timezone-field==7.0
djangorestframework==3.15.2
drf-spectacular==0.27.2
drf-spectacular-sidecar==2024.7.1
drf-spectacular-sidecar==2024.11.1
feedparser==6.0.11
gunicorn==23.0.0
Jinja2==3.1.4
Markdown==3.7
mkdocs-material==9.5.43
mkdocstrings[python-legacy]==0.26.2
mkdocs-material==9.5.45
mkdocstrings[python-legacy]==0.27.0
netaddr==1.3.0
nh3==0.2.18
Pillow==11.0.0
@ -31,8 +31,8 @@ requests==2.32.3
rq==2.0
social-auth-app-django==5.4.2
social-auth-core==4.5.4
strawberry-graphql==0.247.0
strawberry-graphql-django==0.49.1
strawberry-graphql==0.251.0
strawberry-graphql-django==0.50.0
svgwrite==1.4.3
tablib==3.7.0
tzdata==2024.2