diff --git a/.github/ISSUE_TEMPLATE/01-feature_request.yaml b/.github/ISSUE_TEMPLATE/01-feature_request.yaml index 300d7ce6a..ec755cd0c 100644 --- a/.github/ISSUE_TEMPLATE/01-feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/01-feature_request.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml index 2b782a6cd..3ae3cbd33 100644 --- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3b4876c3..622c8ad7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/update-translation-strings.yml b/.github/workflows/update-translation-strings.yml index bcd68c887..e78cd4296 100644 --- a/.github/workflows/update-translation-strings.yml +++ b/.github/workflows/update-translation-strings.yml @@ -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 diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json index 56ddee50e..639f0df8d 100644 --- a/contrib/generated_schema.json +++ b/contrib/generated_schema.json @@ -329,6 +329,7 @@ "100base-tx", "100base-t1", "1000base-t", + "1000base-lx", "1000base-tx", "2.5gbase-t", "5gbase-t", diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index b1f23ae55..82da8cc4e 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -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)) diff --git a/docs/release-notes/version-4.1.md b/docs/release-notes/version-4.1.md index b94374721..397741171 100644 --- a/docs/release-notes/version-4.1.md +++ b/docs/release-notes/version-4.1.md @@ -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 --- diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 3cfea3e2a..82bfd72c8 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -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): """ diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 11d192732..776846e44 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -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)'), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 908312a8a..8f9c4227d 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -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: [ge,xe]-0/0/[0-9]). The token {module}, if present, will be " + "automatically replaced with the position value when creating a new module." + ) + class ConsolePortTemplateForm(ModularComponentTemplateForm): fieldsets = ( diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index d18c7ed14..85c613b8c 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -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 {module} will be replaced with the position of the assigned module, if any." - ) - class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): device = DynamicModelChoiceField( diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 0f73095b5..73a696bae 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -48,6 +48,7 @@ def get_device_description(device): Name: Role: + Status: Device Type: () Asset tag: (if defined) 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: diff --git a/netbox/extras/jobs.py b/netbox/extras/jobs.py index a913fe456..190166b5b 100644 --- a/netbox/extras/jobs.py +++ b/netbox/extras/jobs.py @@ -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): """ diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 0d98b1324..133d02540 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -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, diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index dea250c79..749ab9ccf 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -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 diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index f98f8b24f..f595bf459 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -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 diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 998bc9e2c..ea26bd3b5 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -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: diff --git a/netbox/netbox/api/serializers/base.py b/netbox/netbox/api/serializers/base.py index 8115fe020..6cd4e5738 100644 --- a/netbox/netbox/api/serializers/base.py +++ b/netbox/netbox/api/serializers/base.py @@ -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 diff --git a/netbox/netbox/jobs.py b/netbox/netbox/jobs.py index ae8f2f109..8c3e23730 100644 --- a/netbox/netbox/jobs.py +++ b/netbox/netbox/jobs.py @@ -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, diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 7158f056a..23a43744f 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -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) diff --git a/netbox/release.yaml b/netbox/release.yaml index b4811804f..fadb01274 100644 --- a/netbox/release.yaml +++ b/netbox/release.yaml @@ -1,3 +1,3 @@ -version: "4.1.6" +version: "4.1.7" edition: "Community" -published: "2024-10-31" +published: "2024-11-21" diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 693a1a017..9dcb7bded 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -19,7 +19,7 @@ Blocks:
{# Sidebar #} -