mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-17 04:32:51 -06:00
Merge branch 'develop' into 17292-detect-infinite-loop-in-cable-trace
This commit is contained in:
commit
b8545d2ff6
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.1.6
|
placeholder: v4.1.7
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@ -39,7 +39,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox Version
|
label: NetBox Version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.1.6
|
placeholder: v4.1.7
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@ -15,6 +15,11 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
# Add concurrency group to control job running
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.event_name }}-${{ github.ref }}-${{ github.actor }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -18,8 +18,17 @@ jobs:
|
|||||||
NETBOX_CONFIGURATION: netbox.configuration_testing
|
NETBOX_CONFIGURATION: netbox.configuration_testing
|
||||||
|
|
||||||
steps:
|
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
|
- name: Check out repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
|
@ -329,6 +329,7 @@
|
|||||||
"100base-tx",
|
"100base-tx",
|
||||||
"100base-t1",
|
"100base-t1",
|
||||||
"1000base-t",
|
"1000base-t",
|
||||||
|
"1000base-lx",
|
||||||
"1000base-tx",
|
"1000base-tx",
|
||||||
"2.5gbase-t",
|
"2.5gbase-t",
|
||||||
"5gbase-t",
|
"5gbase-t",
|
||||||
|
@ -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.
|
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)
|
#### [Version 4.0](./version-4.0.md) (April 2024)
|
||||||
|
|
||||||
* Complete UI Refresh ([#12128](https://github.com/netbox-community/netbox/issues/12128))
|
* Complete UI Refresh ([#12128](https://github.com/netbox-community/netbox/issues/12128))
|
||||||
|
@ -6,14 +6,21 @@
|
|||||||
|
|
||||||
* [#15239](https://github.com/netbox-community/netbox/issues/15239) - Enable adding/removing individual VLANs while bulk editing device interfaces
|
* [#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
|
* [#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
|
### 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
|
* [#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
|
* [#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
|
* [#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
|
* [#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
|
* [#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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ from django.db import models
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from rq.exceptions import InvalidJobOperation
|
||||||
|
|
||||||
from core.choices import JobStatusChoices
|
from core.choices import JobStatusChoices
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
@ -158,7 +159,11 @@ class Job(models.Model):
|
|||||||
job = queue.fetch_job(str(self.job_id))
|
job = queue.fetch_job(str(self.job_id))
|
||||||
|
|
||||||
if job:
|
if job:
|
||||||
|
try:
|
||||||
job.cancel()
|
job.cancel()
|
||||||
|
except InvalidJobOperation:
|
||||||
|
# Job may raise this exception from get_status() if missing from Redis
|
||||||
|
pass
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""
|
"""
|
||||||
|
@ -871,6 +871,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
TYPE_100ME_T1 = '100base-t1'
|
TYPE_100ME_T1 = '100base-t1'
|
||||||
TYPE_100ME_SFP = '100base-x-sfp'
|
TYPE_100ME_SFP = '100base-x-sfp'
|
||||||
TYPE_1GE_FIXED = '1000base-t'
|
TYPE_1GE_FIXED = '1000base-t'
|
||||||
|
TYPE_1GE_LX_FIXED = '1000base-lx'
|
||||||
TYPE_1GE_TX_FIXED = '1000base-tx'
|
TYPE_1GE_TX_FIXED = '1000base-tx'
|
||||||
TYPE_1GE_GBIC = '1000base-x-gbic'
|
TYPE_1GE_GBIC = '1000base-x-gbic'
|
||||||
TYPE_1GE_SFP = '1000base-x-sfp'
|
TYPE_1GE_SFP = '1000base-x-sfp'
|
||||||
@ -1033,6 +1034,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
|
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
|
||||||
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
|
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
|
||||||
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
|
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
|
||||||
|
(TYPE_1GE_LX_FIXED, '1000BASE-LX (1GE)'),
|
||||||
(TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
|
(TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
|
||||||
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
|
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
|
||||||
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
|
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
|
||||||
|
@ -909,6 +909,13 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
|
|||||||
if self.instance.pk:
|
if self.instance.pk:
|
||||||
self.fields['module_type'].disabled = True
|
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):
|
class ConsolePortTemplateForm(ModularComponentTemplateForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
|
@ -243,14 +243,6 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
|
|||||||
class Meta(model_forms.InterfaceForm.Meta):
|
class Meta(model_forms.InterfaceForm.Meta):
|
||||||
exclude = ('name', 'label')
|
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):
|
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||||
device = DynamicModelChoiceField(
|
device = DynamicModelChoiceField(
|
||||||
|
@ -48,6 +48,7 @@ def get_device_description(device):
|
|||||||
|
|
||||||
Name: <name>
|
Name: <name>
|
||||||
Role: <role>
|
Role: <role>
|
||||||
|
Status: <status>
|
||||||
Device Type: <manufacturer> <model> (<u_height>)
|
Device Type: <manufacturer> <model> (<u_height>)
|
||||||
Asset tag: <asset_tag> (if defined)
|
Asset tag: <asset_tag> (if defined)
|
||||||
Serial: <serial> (if defined)
|
Serial: <serial> (if defined)
|
||||||
@ -55,6 +56,7 @@ def get_device_description(device):
|
|||||||
"""
|
"""
|
||||||
description = f'Name: {device.name}'
|
description = f'Name: {device.name}'
|
||||||
description += f'\nRole: {device.role}'
|
description += f'\nRole: {device.role}'
|
||||||
|
description += f'\nStatus: {device.get_status_display()}'
|
||||||
u_height = f'{floatformat(device.device_type.u_height)}U'
|
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})'
|
description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})'
|
||||||
if device.asset_tag:
|
if device.asset_tag:
|
||||||
|
@ -22,9 +22,7 @@ class ScriptJob(JobRunner):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
# An explicit job name is not set because it doesn't make sense in this context. Currently, there's no scenario
|
name = 'Run Script'
|
||||||
# where jobs other than this one are used. Therefore, it is hidden, resulting in a cleaner job table overview.
|
|
||||||
name = ''
|
|
||||||
|
|
||||||
def run_script(self, script, request, data, commit):
|
def run_script(self, script, request, data, commit):
|
||||||
"""
|
"""
|
||||||
|
@ -1141,12 +1141,14 @@ class ScriptView(BaseScriptView):
|
|||||||
script_class = self._get_script_class(script)
|
script_class = self._get_script_class(script)
|
||||||
if not script_class:
|
if not script_class:
|
||||||
return render(request, 'extras/script.html', {
|
return render(request, 'extras/script.html', {
|
||||||
|
'object': script,
|
||||||
'script': script,
|
'script': script,
|
||||||
})
|
})
|
||||||
|
|
||||||
form = script_class.as_form(initial=normalize_querydict(request.GET))
|
form = script_class.as_form(initial=normalize_querydict(request.GET))
|
||||||
|
|
||||||
return render(request, 'extras/script.html', {
|
return render(request, 'extras/script.html', {
|
||||||
|
'object': script,
|
||||||
'script': script,
|
'script': script,
|
||||||
'script_class': script_class,
|
'script_class': script_class,
|
||||||
'form': form,
|
'form': form,
|
||||||
@ -1162,6 +1164,7 @@ class ScriptView(BaseScriptView):
|
|||||||
script_class = self._get_script_class(script)
|
script_class = self._get_script_class(script)
|
||||||
if not script_class:
|
if not script_class:
|
||||||
return render(request, 'extras/script.html', {
|
return render(request, 'extras/script.html', {
|
||||||
|
'object': script,
|
||||||
'script': script,
|
'script': script,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1181,12 +1184,12 @@ class ScriptView(BaseScriptView):
|
|||||||
request=copy_safe_request(request),
|
request=copy_safe_request(request),
|
||||||
job_timeout=script.python_class.job_timeout,
|
job_timeout=script.python_class.job_timeout,
|
||||||
commit=form.cleaned_data.pop('_commit'),
|
commit=form.cleaned_data.pop('_commit'),
|
||||||
name=script.name
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return redirect('extras:script_result', job_pk=job.pk)
|
return redirect('extras:script_result', job_pk=job.pk)
|
||||||
|
|
||||||
return render(request, 'extras/script.html', {
|
return render(request, 'extras/script.html', {
|
||||||
|
'object': script,
|
||||||
'script': script,
|
'script': script,
|
||||||
'script_class': script.python_class(),
|
'script_class': script.python_class(),
|
||||||
'form': form,
|
'form': form,
|
||||||
|
@ -326,12 +326,17 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
|||||||
help_text=_('Make this the primary IP for the assigned device'),
|
help_text=_('Make this the primary IP for the assigned device'),
|
||||||
required=False
|
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:
|
class Meta:
|
||||||
model = IPAddress
|
model = IPAddress
|
||||||
fields = [
|
fields = [
|
||||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
|
'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):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
@ -345,7 +350,7 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
|||||||
**{f"device__{self.fields['device'].to_field_name}": data['device']}
|
**{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'):
|
elif data.get('virtual_machine'):
|
||||||
self.fields['interface'].queryset = VMInterface.objects.filter(
|
self.fields['interface'].queryset = VMInterface.objects.filter(
|
||||||
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
|
**{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')
|
virtual_machine = self.cleaned_data.get('virtual_machine')
|
||||||
interface = self.cleaned_data.get('interface')
|
interface = self.cleaned_data.get('interface')
|
||||||
is_primary = self.cleaned_data.get('is_primary')
|
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:
|
if is_primary and not device and not virtual_machine:
|
||||||
raise forms.ValidationError({
|
raise forms.ValidationError({
|
||||||
"is_primary": _("No device or virtual machine specified; cannot set as primary IP")
|
"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:
|
if is_primary and not interface:
|
||||||
raise forms.ValidationError({
|
raise forms.ValidationError({
|
||||||
"is_primary": _("No interface specified; cannot set as primary IP")
|
"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):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -386,6 +404,12 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
|||||||
parent.primary_ip6 = ipaddress
|
parent.primary_ip6 = ipaddress
|
||||||
parent.save()
|
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
|
return ipaddress
|
||||||
|
|
||||||
|
|
||||||
|
@ -309,6 +309,10 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Make this the primary IP for the device/VM')
|
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()
|
comments = CommentField()
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@ -320,7 +324,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
FieldSet('vminterface', name=_('Virtual Machine')),
|
FieldSet('vminterface', name=_('Virtual Machine')),
|
||||||
FieldSet('fhrpgroup', name=_('FHRP Group')),
|
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)')),
|
FieldSet('nat_inside', name=_('NAT IP (Inside)')),
|
||||||
)
|
)
|
||||||
@ -328,8 +332,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = IPAddress
|
model = IPAddress
|
||||||
fields = [
|
fields = [
|
||||||
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_inside', 'tenant_group',
|
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent', 'nat_inside',
|
||||||
'tenant', 'description', 'comments', 'tags',
|
'tenant_group', 'tenant', 'description', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -348,7 +352,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
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:
|
if self.instance.pk and self.instance.assigned_object:
|
||||||
parent = getattr(self.instance.assigned_object, 'parent_object', None)
|
parent = getattr(self.instance.assigned_object, 'parent_object', None)
|
||||||
if parent and (
|
if parent and (
|
||||||
@ -357,6 +361,9 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
):
|
):
|
||||||
self.initial['primary_for_parent'] = True
|
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:
|
if type(instance.assigned_object) is Interface:
|
||||||
self.fields['interface'].widget.add_query_params({
|
self.fields['interface'].widget.add_query_params({
|
||||||
'device_id': instance.assigned_object.device.pk,
|
'device_id': instance.assigned_object.device.pk,
|
||||||
@ -385,9 +392,14 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
})
|
})
|
||||||
elif selected_objects:
|
elif selected_objects:
|
||||||
assigned_object = self.cleaned_data[selected_objects[0]]
|
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:
|
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(
|
raise ValidationError(
|
||||||
_("Cannot reassign IP address while it is designated as the primary IP for the parent object")
|
_("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
|
self.instance.assigned_object = assigned_object
|
||||||
else:
|
else:
|
||||||
@ -400,6 +412,16 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
|
'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):
|
def save(self, *args, **kwargs):
|
||||||
ipaddress = super().save(*args, **kwargs)
|
ipaddress = super().save(*args, **kwargs)
|
||||||
|
|
||||||
@ -421,6 +443,17 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
parent.primary_ip6 = None
|
parent.primary_ip6 = None
|
||||||
parent.save()
|
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
|
return ipaddress
|
||||||
|
|
||||||
|
|
||||||
|
@ -97,16 +97,32 @@ class VLANGroup(OrganizationalModel):
|
|||||||
raise ValidationError(_("Cannot set scope_id without scope_type."))
|
raise ValidationError(_("Cannot set scope_id without scope_type."))
|
||||||
|
|
||||||
# Validate VID ranges
|
# 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:
|
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({
|
raise ValidationError({
|
||||||
'vid_ranges': _(
|
'vid_ranges': _(
|
||||||
"Maximum child VID must be greater than or equal to minimum child VID ({value})"
|
"Ending VLAN ID in range must be greater than or equal to the starting VLAN ID ({range})"
|
||||||
).format(value=vid_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):
|
def save(self, *args, **kwargs):
|
||||||
self._total_vlan_ids = 0
|
self._total_vlan_ids = 0
|
||||||
for vid_range in self.vid_ranges:
|
for vid_range in self.vid_ranges:
|
||||||
|
@ -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
|
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)
|
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):
|
def validate(self, data):
|
||||||
|
|
||||||
# Skip validation if we're being used to represent a nested object
|
# Skip validation if we're being used to represent a nested object
|
||||||
|
@ -72,6 +72,7 @@ class JobRunner(ABC):
|
|||||||
kwargs["job_timeout"] = job.object.python_class.job_timeout
|
kwargs["job_timeout"] = job.object.python_class.job_timeout
|
||||||
cls.enqueue(
|
cls.enqueue(
|
||||||
instance=job.object,
|
instance=job.object,
|
||||||
|
name=job.name,
|
||||||
user=job.user,
|
user=job.user,
|
||||||
schedule_at=new_scheduled_time,
|
schedule_at=new_scheduled_time,
|
||||||
interval=job.interval,
|
interval=job.interval,
|
||||||
|
@ -744,7 +744,6 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
renamed_pks = []
|
renamed_pks = []
|
||||||
|
|
||||||
for obj in selected_objects:
|
for obj in selected_objects:
|
||||||
|
|
||||||
# Take a snapshot of change-logged models
|
# Take a snapshot of change-logged models
|
||||||
if hasattr(obj, 'snapshot'):
|
if hasattr(obj, 'snapshot'):
|
||||||
obj.snapshot()
|
obj.snapshot()
|
||||||
@ -758,7 +757,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
except re.error:
|
except re.error:
|
||||||
obj.new_name = obj.name
|
obj.new_name = obj.name
|
||||||
else:
|
else:
|
||||||
obj.new_name = obj.name.replace(find, replace)
|
obj.new_name = (obj.name or '').replace(find, replace)
|
||||||
renamed_pks.append(obj.pk)
|
renamed_pks.append(obj.pk)
|
||||||
|
|
||||||
return renamed_pks
|
return renamed_pks
|
||||||
@ -793,6 +792,10 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
)
|
)
|
||||||
return redirect(self.get_return_url(request))
|
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:
|
except (AbortRequest, PermissionsViolation) as e:
|
||||||
logger.debug(e.message)
|
logger.debug(e.message)
|
||||||
form.add_error(None, e.message)
|
form.add_error(None, e.message)
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
version: "4.1.6"
|
version: "4.1.7"
|
||||||
edition: "Community"
|
edition: "Community"
|
||||||
published: "2024-10-31"
|
published: "2024-11-21"
|
||||||
|
@ -19,7 +19,7 @@ Blocks:
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
||||||
{# Sidebar #}
|
{# 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 %}
|
{% if 'commercial' in settings.RELEASE.features %}
|
||||||
<img class="motif" src="{% static 'motif.svg' %}" alt="{% trans "NetBox Motif" %}">
|
<img class="motif" src="{% static 'motif.svg' %}" alt="{% trans "NetBox Motif" %}">
|
||||||
|
@ -87,7 +87,7 @@
|
|||||||
{% for name, field in backend.parameters.items %}
|
{% for name, field in backend.parameters.items %}
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{{ field.label }}</th>
|
<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>
|
<td>********</td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td>{{ object.parameters|get_key:name|placeholder }}</td>
|
<td>{{ object.parameters|get_key:name|placeholder }}</td>
|
||||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -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:
|
try:
|
||||||
if weight < 0:
|
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))
|
raise TypeError(_("Invalid value '{weight}' for weight (must be a number)").format(weight=weight))
|
||||||
|
|
||||||
if unit == WeightUnitChoices.UNIT_KILOGRAM:
|
if unit == WeightUnitChoices.UNIT_KILOGRAM:
|
||||||
return weight * 1000
|
return int(weight * 1000)
|
||||||
if unit == WeightUnitChoices.UNIT_GRAM:
|
if unit == WeightUnitChoices.UNIT_GRAM:
|
||||||
return weight
|
return int(weight)
|
||||||
if unit == WeightUnitChoices.UNIT_POUND:
|
if unit == WeightUnitChoices.UNIT_POUND:
|
||||||
return weight * Decimal(453.592)
|
return int(weight * Decimal(453.592))
|
||||||
if unit == WeightUnitChoices.UNIT_OUNCE:
|
if unit == WeightUnitChoices.UNIT_OUNCE:
|
||||||
return weight * Decimal(28.3495)
|
return int(weight * Decimal(28.3495))
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
_("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
|
_("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
|
||||||
unit=unit,
|
unit=unit,
|
||||||
|
@ -28,10 +28,14 @@ class DataFileLoader(BaseLoader):
|
|||||||
raise TemplateNotFound(template)
|
raise TemplateNotFound(template)
|
||||||
|
|
||||||
# Find and pre-fetch referenced templates
|
# 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({
|
self.cache_templates({
|
||||||
df.path: df.data_as_string for df in
|
df.path: df.data_as_string for df in related_files
|
||||||
DataFile.objects.filter(source=self.data_source, path__in=referenced_templates)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return template_source, template, lambda: True
|
return template_source, template, lambda: True
|
||||||
|
@ -23,15 +23,23 @@ class TunnelStatusChoices(ChoiceSet):
|
|||||||
|
|
||||||
class TunnelEncapsulationChoices(ChoiceSet):
|
class TunnelEncapsulationChoices(ChoiceSet):
|
||||||
ENCAP_GRE = 'gre'
|
ENCAP_GRE = 'gre'
|
||||||
ENCAP_IP_IP = 'ip-ip'
|
|
||||||
ENCAP_IPSEC_TRANSPORT = 'ipsec-transport'
|
ENCAP_IPSEC_TRANSPORT = 'ipsec-transport'
|
||||||
ENCAP_IPSEC_TUNNEL = 'ipsec-tunnel'
|
ENCAP_IPSEC_TUNNEL = 'ipsec-tunnel'
|
||||||
|
ENCAP_IP_IP = 'ip-ip'
|
||||||
|
ENCAP_L2TP = 'l2tp'
|
||||||
|
ENCAP_OPENVPN = 'openvpn'
|
||||||
|
ENCAP_PPTP = 'pptp'
|
||||||
|
ENCAP_WIREGUARD = 'wireguard'
|
||||||
|
|
||||||
CHOICES = [
|
CHOICES = [
|
||||||
(ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')),
|
(ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')),
|
||||||
(ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')),
|
(ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')),
|
||||||
(ENCAP_IP_IP, _('IP-in-IP')),
|
(ENCAP_IP_IP, _('IP-in-IP')),
|
||||||
(ENCAP_GRE, _('GRE')),
|
(ENCAP_GRE, _('GRE')),
|
||||||
|
(ENCAP_WIREGUARD, _('WireGuard')),
|
||||||
|
(ENCAP_OPENVPN, _('OpenVPN')),
|
||||||
|
(ENCAP_L2TP, _('L2TP')),
|
||||||
|
(ENCAP_PPTP, _('PPTP')),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,13 +15,13 @@ django-tables2==2.7.0
|
|||||||
django-timezone-field==7.0
|
django-timezone-field==7.0
|
||||||
djangorestframework==3.15.2
|
djangorestframework==3.15.2
|
||||||
drf-spectacular==0.27.2
|
drf-spectacular==0.27.2
|
||||||
drf-spectacular-sidecar==2024.7.1
|
drf-spectacular-sidecar==2024.11.1
|
||||||
feedparser==6.0.11
|
feedparser==6.0.11
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
Jinja2==3.1.4
|
Jinja2==3.1.4
|
||||||
Markdown==3.7
|
Markdown==3.7
|
||||||
mkdocs-material==9.5.43
|
mkdocs-material==9.5.45
|
||||||
mkdocstrings[python-legacy]==0.26.2
|
mkdocstrings[python-legacy]==0.27.0
|
||||||
netaddr==1.3.0
|
netaddr==1.3.0
|
||||||
nh3==0.2.18
|
nh3==0.2.18
|
||||||
Pillow==11.0.0
|
Pillow==11.0.0
|
||||||
@ -31,8 +31,8 @@ requests==2.32.3
|
|||||||
rq==2.0
|
rq==2.0
|
||||||
social-auth-app-django==5.4.2
|
social-auth-app-django==5.4.2
|
||||||
social-auth-core==4.5.4
|
social-auth-core==4.5.4
|
||||||
strawberry-graphql==0.247.0
|
strawberry-graphql==0.251.0
|
||||||
strawberry-graphql-django==0.49.1
|
strawberry-graphql-django==0.50.0
|
||||||
svgwrite==1.4.3
|
svgwrite==1.4.3
|
||||||
tablib==3.7.0
|
tablib==3.7.0
|
||||||
tzdata==2024.2
|
tzdata==2024.2
|
||||||
|
Loading…
Reference in New Issue
Block a user