Merge branch 'develop' into 17071_bulkimport_oob_address

This commit is contained in:
Jeremy Stretch 2024-12-09 10:13:36 -05:00
commit b93e4e210b
60 changed files with 5187 additions and 4641 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

@ -1,6 +1,6 @@
# IKE Policies
An [Internet Key Exhcnage (IKE)](https://en.wikipedia.org/wiki/Internet_Key_Exchange) policy defines an IKE version, mode, and set of [proposals](./ikeproposal.md) to be used in IKE negotiation. These policies are referenced by [IPSec profiles](./ipsecprofile.md).
An [Internet Key Exchange (IKE)](https://en.wikipedia.org/wiki/Internet_Key_Exchange) policy defines an IKE version, mode, and set of [proposals](./ikeproposal.md) to be used in IKE negotiation. These policies are referenced by [IPSec profiles](./ipsecprofile.md).
## Fields

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

@ -1,5 +1,29 @@
# NetBox v4.1
## v4.1.7 (FUTURE)
### Enhancements
* [#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
---
## v4.1.6 (2024-10-31)
### Bug Fixes

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

@ -346,3 +346,32 @@ class BackgroundTaskTestCase(TestCase):
self.assertIn(str(worker1.name), str(response.content))
self.assertIn('Birth', str(response.content))
self.assertIn('Total working time', str(response.content))
class SystemTestCase(TestCase):
def setUp(self):
super().setUp()
self.user.is_staff = True
self.user.save()
def test_system_view_default(self):
# Test UI render
response = self.client.get(reverse('core:system'))
self.assertEqual(response.status_code, 200)
# Test export
response = self.client.get(f"{reverse('core:system')}?export=true")
self.assertEqual(response.status_code, 200)
def test_system_view_with_config_revision(self):
ConfigRevision.objects.create()
# Test UI render
response = self.client.get(reverse('core:system'))
self.assertEqual(response.status_code, 200)
# Test export
response = self.client.get(f"{reverse('core:system')}?export=true")
self.assertEqual(response.status_code, 200)

View File

@ -626,11 +626,7 @@ class SystemView(UserPassesTestMixin, View):
}
# Configuration
try:
config = ConfigRevision.objects.get(pk=cache.get('config_version'))
except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found
config = get_config()
config = get_config()
# Raw data export
if 'export' in request.GET:

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

@ -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

@ -1,7 +1,7 @@
import urllib.parse
from django.urls import reverse
from django.test import override_settings
from django.test import Client, override_settings
from dcim.models import Site
from netbox.constants import EMPTY_TABLE_TEXT
@ -74,3 +74,21 @@ class SearchViewTestCase(TestCase):
self.assertHttpStatus(response, 200)
content = str(response.content)
self.assertIn(EMPTY_TABLE_TEXT, content)
class MediaViewTestCase(TestCase):
def test_media_login_required(self):
url = reverse('media', kwargs={'path': 'foo.txt'})
response = Client().get(url)
# Unauthenticated request should redirect to login page
self.assertHttpStatus(response, 302)
@override_settings(LOGIN_REQUIRED=False)
def test_media_login_not_required(self):
url = reverse('media', kwargs={'path': 'foo.txt'})
response = Client().get(url)
# Unauthenticated request should return a 404 (not found)
self.assertHttpStatus(response, 404)

View File

@ -2,7 +2,6 @@ from django.conf import settings
from django.conf.urls import include
from django.urls import path
from django.views.decorators.cache import cache_page
from django.views.static import serve
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from account.views import LoginView, LogoutView
@ -10,7 +9,7 @@ from netbox.api.views import APIRootView, StatusView
from netbox.graphql.schema import schema
from netbox.graphql.views import NetBoxGraphQLView
from netbox.plugins.urls import plugin_patterns, plugin_api_patterns
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
from netbox.views import HomeView, MediaView, StaticMediaFailureView, SearchView, htmx
_patterns = [
@ -69,7 +68,7 @@ _patterns = [
path('graphql/', NetBoxGraphQLView.as_view(schema=schema), name='graphql'),
# Serving static media in Django to pipe it through LoginRequiredMiddleware
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
path('media/<path:path>', MediaView.as_view(), name='media'),
path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'),
# Plugins

View File

@ -698,7 +698,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
logger.debug("Form validation failed")
else:
form = self.form(request.POST, initial=initial_data)
form = self.form(initial=initial_data)
restrict_form_fields(form, request.user)
# Retrieve objects being edited
@ -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

@ -8,6 +8,7 @@ from django.core.cache import cache
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from django.views.static import serve
from django_tables2 import RequestConfig
from packaging import version
@ -23,6 +24,7 @@ from utilities.views import ConditionalLoginRequiredMixin
__all__ = (
'HomeView',
'MediaView',
'SearchView',
)
@ -115,3 +117,11 @@ class SearchView(ConditionalLoginRequiredMixin, View):
'form': form,
'table': table,
})
class MediaView(ConditionalLoginRequiredMixin, View):
"""
Wrap Django's serve() view to enforce LOGIN_REQUIRED for static media.
"""
def get(self, request, path):
return serve(request, path, document_root=settings.MEDIA_ROOT)

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

@ -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

@ -171,7 +171,7 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
)
disk = forms.IntegerField(
required=False,
label=_('Disk (GB)')
label=_('Disk (MB)')
)
description = forms.CharField(
label=_('Description'),
@ -331,7 +331,7 @@ class VirtualDiskBulkEditForm(NetBoxModelBulkEditForm):
)
size = forms.IntegerField(
required=False,
label=_('Size (GB)')
label=_('Size (MB)')
)
description = forms.CharField(
label=_('Description'),

View File

@ -248,7 +248,7 @@ class VirtualDiskFilterForm(NetBoxModelFilterSetForm):
label=_('Virtual machine')
)
size = forms.IntegerField(
label=_('Size (GB)'),
label=_('Size (MB)'),
required=False,
min_value=1
)

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