From 206201a6593d6bb1486f4cc253a55be6dbfb3b08 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Aug 2025 13:26:40 -0400 Subject: [PATCH 01/23] #17413: Remove redundant name & slug fields from Platform model --- netbox/dcim/models/devices.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index cfebc4a9ce0..be93f33b9ba 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -438,15 +438,6 @@ class Platform(NestedGroupModel): null=True, help_text=_('Optionally limit this platform to devices of a certain manufacturer') ) - # Override name & slug from OrganizationalModel to not enforce uniqueness - name = models.CharField( - verbose_name=_('name'), - max_length=100 - ) - slug = models.SlugField( - verbose_name=_('slug'), - max_length=100 - ) config_template = models.ForeignKey( to='extras.ConfigTemplate', on_delete=models.PROTECT, From f206a2238f5249a66253b629d04785e949019b0a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Aug 2025 13:39:27 -0400 Subject: [PATCH 02/23] #17413: Distinguish platforms by manufacturer when bulk importing devices --- netbox/dcim/forms/bulk_import.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 94e2307e042..ce234130a73 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -691,6 +691,12 @@ def __init__(self, data=None, *args, **kwargs): }) self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) + # Limit platform queryset by manufacturer + params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')} + self.fields['platform'].queryset = self.fields['platform'].queryset.filter( + Q(**params) | Q(manufacturer=None) + ) + # Limit device bay queryset by parent device if parent := data.get('parent'): params = {f"device__{self.fields['parent'].to_field_name}": parent} From 77def92c25b150e420b962efb2434c3e9121b234 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Aug 2025 13:48:05 -0400 Subject: [PATCH 03/23] #19740: Add parent column to PlatformTable --- netbox/dcim/tables/devices.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index d63a098042e..8287e36669d 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -107,6 +107,10 @@ class PlatformTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) + parent = tables.Column( + verbose_name=_('Parent'), + linkify=True, + ) manufacturer = tables.Column( verbose_name=_('Manufacturer'), linkify=True @@ -132,8 +136,8 @@ class PlatformTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = models.Platform fields = ( - 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'description', - 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'parent', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', + 'description', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description', From e4f0611f78a114f189858ef9960dd4be64ba8f18 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Aug 2025 13:58:48 -0400 Subject: [PATCH 04/23] #19740: Annotate cumulative counts for platform child objects --- netbox/dcim/api/serializers_/platforms.py | 7 ++++--- netbox/dcim/api/views.py | 17 +++++++++++++++-- netbox/dcim/views.py | 15 ++++++++++++--- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/api/serializers_/platforms.py b/netbox/dcim/api/serializers_/platforms.py index c357b0bbe81..08f8a64a853 100644 --- a/netbox/dcim/api/serializers_/platforms.py +++ b/netbox/dcim/api/serializers_/platforms.py @@ -1,6 +1,7 @@ +from rest_framework import serializers + from dcim.models import Platform from extras.api.serializers_.configtemplates import ConfigTemplateSerializer -from netbox.api.fields import RelatedObjectCountField from netbox.api.serializers import NestedGroupModelSerializer from .manufacturers import ManufacturerSerializer from .nested import NestedPlatformSerializer @@ -16,8 +17,8 @@ class PlatformSerializer(NestedGroupModelSerializer): config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) # Related object counts - device_count = RelatedObjectCountField('devices') - virtualmachine_count = RelatedObjectCountField('virtual_machines') + device_count = serializers.IntegerField(read_only=True, default=0) + virtualmachine_count = serializers.IntegerField(read_only=True, default=0) class Meta: model = Platform diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index a64c9e5e3a2..a64a157e05f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -20,6 +20,7 @@ from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from utilities.api import get_serializer_for_model from utilities.query_functions import CollateAsChar +from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -360,8 +361,20 @@ class DeviceRoleViewSet(NetBoxModelViewSet): # Platforms # -class PlatformViewSet(NetBoxModelViewSet): - queryset = Platform.objects.all() +class PlatformViewSet(MPTTLockedMixin, NetBoxModelViewSet): + queryset = Platform.objects.add_related_count( + Platform.objects.add_related_count( + Platform.objects.all(), + VirtualMachine, + 'platform', + 'virtualmachine_count', + cumulative=True + ), + Device, + 'platform', + 'device_count', + cumulative=True + ) serializer_class = serializers.PlatformSerializer filterset_class = filtersets.PlatformFilterSet diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 277a634ce57..4956cc5e37e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2109,9 +2109,18 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView): @register_model_view(Platform, 'list', path='', detail=False) class PlatformListView(generic.ObjectListView): - queryset = Platform.objects.annotate( - device_count=count_related(Device, 'platform'), - vm_count=count_related(VirtualMachine, 'platform') + queryset = Platform.objects.add_related_count( + Platform.objects.add_related_count( + Platform.objects.all(), + VirtualMachine, + 'platform', + 'vm_count', + cumulative=True + ), + Device, + 'platform', + 'device_count', + cumulative=True ) table = tables.PlatformTable filterset = filtersets.PlatformFilterSet From a17503065586ade8b86f9b5245f41a35c0d692af Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Aug 2025 14:26:21 -0400 Subject: [PATCH 05/23] #19740: Add missing advisory lock key --- netbox/netbox/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 75e0f4da6cb..aeeeae90ed5 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -38,6 +38,7 @@ 'wirelesslangroup': 105600, 'inventoryitem': 105700, 'inventoryitemtemplate': 105800, + 'platform': 105900, # Jobs 'job-schedules': 110100, From cd3f930e1940721058df8c16a6eb7443bac024a9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Aug 2025 14:44:05 -0400 Subject: [PATCH 06/23] #18349: Adopt new job logging functionality (#19816) --- netbox/core/jobs.py | 72 +++++++++++++++++----------------- netbox/templates/core/job.html | 4 +- 2 files changed, 36 insertions(+), 40 deletions(-) diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py index 55069d4cf6c..8b516e6604a 100644 --- a/netbox/core/jobs.py +++ b/netbox/core/jobs.py @@ -1,4 +1,3 @@ -import logging import sys from datetime import timedelta from importlib import import_module @@ -17,8 +16,6 @@ from .choices import DataSourceStatusChoices, JobIntervalChoices from .models import DataSource -logger = logging.getLogger(__name__) - class SyncDataSourceJob(JobRunner): """ @@ -69,7 +66,11 @@ class Meta: def run(self, *args, **kwargs): # Skip if running in development or test mode - if settings.DEBUG or 'test' in sys.argv: + if settings.DEBUG: + self.logger.warning("Aborting execution: Debug is enabled") + return + if 'test' in sys.argv: + self.logger.warning("Aborting execution: Tests are running") return self.send_census_report() @@ -78,17 +79,16 @@ def run(self, *args, **kwargs): self.delete_expired_jobs() self.check_for_new_releases() - @staticmethod - def send_census_report(): + def send_census_report(self): """ Send a census report (if enabled). """ - logging.info("Reporting census data...") + self.logger.info("Reporting census data...") if settings.ISOLATED_DEPLOYMENT: - logging.info("ISOLATED_DEPLOYMENT is enabled; skipping") + self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping") return if not settings.CENSUS_REPORTING_ENABLED: - logging.info("CENSUS_REPORTING_ENABLED is disabled; skipping") + self.logger.info("CENSUS_REPORTING_ENABLED is disabled; skipping") return census_data = { @@ -106,73 +106,71 @@ def send_census_report(): except requests.exceptions.RequestException: pass - @staticmethod - def clear_expired_sessions(): + def clear_expired_sessions(self): """ Clear any expired sessions from the database. """ - logging.info("Clearing expired sessions...") + self.logger.info("Clearing expired sessions...") engine = import_module(settings.SESSION_ENGINE) try: engine.SessionStore.clear_expired() - logging.info("Sessions cleared.") + self.logger.info("Sessions cleared.") except NotImplementedError: - logging.warning( + self.logger.warning( f"The configured session engine ({settings.SESSION_ENGINE}) does not support " f"clearing sessions; skipping." ) - @staticmethod - def prune_changelog(): + def prune_changelog(self): """ Delete any ObjectChange records older than the configured changelog retention time (if any). """ - logging.info("Pruning old changelog entries...") + self.logger.info("Pruning old changelog entries...") config = Config() if not config.CHANGELOG_RETENTION: - logging.info("No retention period specified; skipping.") + self.logger.info("No retention period specified; skipping.") return cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION) - logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days") - logging.debug(f"Cut-off time: {cutoff}") + self.logger.debug( + f"Changelog retention period: {config.CHANGELOG_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})" + ) count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0] - logging.info(f"Deleted {count} expired records") + self.logger.info(f"Deleted {count} expired changelog records") - @staticmethod - def delete_expired_jobs(): + def delete_expired_jobs(self): """ Delete any jobs older than the configured retention period (if any). """ - logging.info("Deleting expired jobs...") + self.logger.info("Deleting expired jobs...") config = Config() if not config.JOB_RETENTION: - logging.info("No retention period specified; skipping.") + self.logger.info("No retention period specified; skipping.") return cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION) - logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days") - logging.debug(f"Cut-off time: {cutoff}") + self.logger.debug( + f"Job retention period: {config.JOB_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})" + ) count = Job.objects.filter(created__lt=cutoff).delete()[0] - logging.info(f"Deleted {count} expired records") + self.logger.info(f"Deleted {count} expired jobs") - @staticmethod - def check_for_new_releases(): + def check_for_new_releases(self): """ Check for new releases and cache the latest release. """ - logging.info("Checking for new releases...") + self.logger.info("Checking for new releases...") if settings.ISOLATED_DEPLOYMENT: - logging.info("ISOLATED_DEPLOYMENT is enabled; skipping") + self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping") return if not settings.RELEASE_CHECK_URL: - logging.info("RELEASE_CHECK_URL is not set; skipping") + self.logger.info("RELEASE_CHECK_URL is not set; skipping") return # Fetch the latest releases - logging.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}") + self.logger.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}") try: response = requests.get( url=settings.RELEASE_CHECK_URL, @@ -181,7 +179,7 @@ def check_for_new_releases(): ) response.raise_for_status() except requests.exceptions.RequestException as exc: - logging.error(f"Error fetching release: {exc}") + self.logger.error(f"Error fetching release: {exc}") return # Determine the most recent stable release @@ -191,8 +189,8 @@ def check_for_new_releases(): continue releases.append((version.parse(release['tag_name']), release.get('html_url'))) latest_release = max(releases) - logging.debug(f"Found {len(response.json())} releases; {len(releases)} usable") - logging.info(f"Latest release: {latest_release[0]}") + self.logger.debug(f"Found {len(response.json())} releases; {len(releases)} usable") + self.logger.info(f"Latest release: {latest_release[0]}") # Cache the most recent release cache.set('latest_release', latest_release, None) diff --git a/netbox/templates/core/job.html b/netbox/templates/core/job.html index ae6e4d63d60..3371f164eac 100644 --- a/netbox/templates/core/job.html +++ b/netbox/templates/core/job.html @@ -67,9 +67,7 @@

{% trans "Scheduling" %}

{% trans "Data" %}

-
-
{{ object.data|json }}
-
+
{{ object.data|json }}
From 4044ae8f8a96df98747ba56cdc771ec1a5f8797e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Aug 2025 15:07:56 -0400 Subject: [PATCH 07/23] #18204: Misc cleanup --- netbox/extras/tables/tables.py | 8 ++++---- netbox/templates/inc/panels/image_attachments.html | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 2a8d89a5caf..5c1a63d265e 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -40,9 +40,8 @@ IMAGEATTACHMENT_IMAGE = """ {% if record.image %} - - - + + {% endif %} {{ record }} """ @@ -236,6 +235,7 @@ class ImageAttachmentTable(NetBoxTable): image = columns.TemplateColumn( verbose_name=_('Image'), template_code=IMAGEATTACHMENT_IMAGE, + attrs={'td': {'class': 'text-nowrap'}} ) name = tables.Column( verbose_name=_('Name'), @@ -255,7 +255,7 @@ class ImageAttachmentTable(NetBoxTable): verbose_name=_('Object Type'), ) parent = tables.Column( - verbose_name=_('Parent'), + verbose_name=_('Object'), linkify=True, orderable=False, ) diff --git a/netbox/templates/inc/panels/image_attachments.html b/netbox/templates/inc/panels/image_attachments.html index 2ad7153357b..8e5691ef5e3 100644 --- a/netbox/templates/inc/panels/image_attachments.html +++ b/netbox/templates/inc/panels/image_attachments.html @@ -6,7 +6,7 @@

{% trans "Images" %} {% if perms.extras.add_imageattachment %} From 128b6a9c04fc3c964d9e96895751d2c9ea37eb61 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Aug 2025 16:24:00 -0400 Subject: [PATCH 08/23] #19231: Add bulk rename support for virtual circuits --- netbox/circuits/urls.py | 6 +----- netbox/circuits/views.py | 7 ++++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 90e9e511f30..59457063823 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -35,11 +35,7 @@ path('circuit-group-assignments//', include(get_model_urls('circuits', 'circuitgroupassignment'))), # Virtual circuits - path('virtual-circuits/', views.VirtualCircuitListView.as_view(), name='virtualcircuit_list'), - path('virtual-circuits/add/', views.VirtualCircuitEditView.as_view(), name='virtualcircuit_add'), - path('virtual-circuits/import/', views.VirtualCircuitBulkImportView.as_view(), name='virtualcircuit_bulk_import'), - path('virtual-circuits/edit/', views.VirtualCircuitBulkEditView.as_view(), name='virtualcircuit_bulk_edit'), - path('virtual-circuits/delete/', views.VirtualCircuitBulkDeleteView.as_view(), name='virtualcircuit_bulk_delete'), + path('virtual-circuits/', include(get_model_urls('circuits', 'virtualcircuit', detail=False))), path('virtual-circuits//', include(get_model_urls('circuits', 'virtualcircuit'))), path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 8670b85352a..89ec0383115 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -687,6 +687,7 @@ class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView): # Virtual circuits # +@register_model_view(VirtualCircuit, 'list', path='', detail=False) class VirtualCircuitListView(generic.ObjectListView): queryset = VirtualCircuit.objects.annotate( termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit') @@ -701,6 +702,7 @@ class VirtualCircuitView(generic.ObjectView): queryset = VirtualCircuit.objects.all() +@register_model_view(VirtualCircuit, 'add', detail=False) @register_model_view(VirtualCircuit, 'edit') class VirtualCircuitEditView(generic.ObjectEditView): queryset = VirtualCircuit.objects.all() @@ -712,6 +714,7 @@ class VirtualCircuitDeleteView(generic.ObjectDeleteView): queryset = VirtualCircuit.objects.all() +@register_model_view(VirtualCircuit, 'bulk_import', path='import', detail=False) class VirtualCircuitBulkImportView(generic.BulkImportView): queryset = VirtualCircuit.objects.all() model_form = forms.VirtualCircuitImportForm @@ -727,6 +730,7 @@ def prep_related_object_data(self, parent, data): return data +@register_model_view(VirtualCircuit, 'bulk_edit', path='edit', detail=False) class VirtualCircuitBulkEditView(generic.BulkEditView): queryset = VirtualCircuit.objects.annotate( termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit') @@ -737,11 +741,12 @@ class VirtualCircuitBulkEditView(generic.BulkEditView): @register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False) -class VirtualCircuitulkRenameView(generic.BulkRenameView): +class VirtualCircuitBulkRenameView(generic.BulkRenameView): queryset = VirtualCircuit.objects.all() field_name = 'cid' +@register_model_view(VirtualCircuit, 'bulk_delete', path='delete', detail=False) class VirtualCircuitBulkDeleteView(generic.BulkDeleteView): queryset = VirtualCircuit.objects.annotate( termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit') From 32ee4cf0043af9701eecd9d313bf9bde8315faca Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Aug 2025 16:31:01 -0400 Subject: [PATCH 09/23] #19231: Add bulk rename support for image attachments --- netbox/extras/views.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 771fb0ea388..169bd845bd5 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1098,7 +1098,7 @@ class ImageAttachmentListView(generic.ObjectListView): filterset = filtersets.ImageAttachmentFilterSet filterset_form = forms.ImageAttachmentFilterForm table = tables.ImageAttachmentTable - actions = (BulkExport,) + actions = (BulkRename, BulkExport) @register_model_view(ImageAttachment) @@ -1126,6 +1126,11 @@ def get_extra_addanother_params(self, request): } +@register_model_view(ImageAttachment, 'bulk_rename', path='rename', detail=False) +class ImageAttachmentBulkRenameView(generic.BulkRenameView): + queryset = ImageAttachment.objects.all() + + @register_model_view(ImageAttachment, 'delete') class ImageAttachmentDeleteView(generic.ObjectDeleteView): queryset = ImageAttachment.objects.all() From 619809204746c66b1fe344e6c3cc9708d38842a0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Aug 2025 16:38:01 -0400 Subject: [PATCH 10/23] #18990: Add bulk edit & bulk delete support for image attachments --- netbox/extras/forms/bulk_edit.py | 13 +++++++++++++ netbox/extras/views.py | 21 ++++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 82f3d04c41b..c0a210e42b4 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -20,6 +20,7 @@ 'CustomLinkBulkEditForm', 'EventRuleBulkEditForm', 'ExportTemplateBulkEditForm', + 'ImageAttachmentBulkEditForm', 'JournalEntryBulkEditForm', 'NotificationGroupBulkEditForm', 'SavedFilterBulkEditForm', @@ -401,6 +402,18 @@ class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm): nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension') +class ImageAttachmentBulkEditForm(ChangelogMessageMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ImageAttachment.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + + class JournalEntryBulkEditForm(ChangelogMessageMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=JournalEntry.objects.all(), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 169bd845bd5..c76afbd15fb 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1098,7 +1098,7 @@ class ImageAttachmentListView(generic.ObjectListView): filterset = filtersets.ImageAttachmentFilterSet filterset_form = forms.ImageAttachmentFilterForm table = tables.ImageAttachmentTable - actions = (BulkRename, BulkExport) + actions = (BulkExport, BulkEdit, BulkRename, BulkDelete) @register_model_view(ImageAttachment) @@ -1126,14 +1126,29 @@ def get_extra_addanother_params(self, request): } +@register_model_view(ImageAttachment, 'delete') +class ImageAttachmentDeleteView(generic.ObjectDeleteView): + queryset = ImageAttachment.objects.all() + + +@register_model_view(ImageAttachment, 'bulk_edit', path='edit', detail=False) +class ImageAttachmentBulkEditView(generic.BulkEditView): + queryset = ImageAttachment.objects.all() + filterset = filtersets.ImageAttachmentFilterSet + table = tables.ImageAttachmentTable + form = forms.ImageAttachmentBulkEditForm + + @register_model_view(ImageAttachment, 'bulk_rename', path='rename', detail=False) class ImageAttachmentBulkRenameView(generic.BulkRenameView): queryset = ImageAttachment.objects.all() -@register_model_view(ImageAttachment, 'delete') -class ImageAttachmentDeleteView(generic.ObjectDeleteView): +@register_model_view(ImageAttachment, 'bulk_delete', path='delete', detail=False) +class ImageAttachmentBulkDeleteView(generic.BulkDeleteView): queryset = ImageAttachment.objects.all() + filterset = filtersets.ImageAttachmentFilterSet + table = tables.ImageAttachmentTable # From e4e4bb6bd542a0670c8430105e9d0571d891044d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Aug 2025 16:52:00 -0400 Subject: [PATCH 11/23] #19829: Update API URL for object type serializer --- netbox/core/api/serializers_/object_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/core/api/serializers_/object_types.py b/netbox/core/api/serializers_/object_types.py index 1c17c6e4489..5ab732e2810 100644 --- a/netbox/core/api/serializers_/object_types.py +++ b/netbox/core/api/serializers_/object_types.py @@ -15,7 +15,7 @@ class ObjectTypeSerializer(BaseModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail') + url = serializers.HyperlinkedIdentityField(view_name='core-api:objecttype-detail') app_name = serializers.CharField(source='app_verbose_name', read_only=True) model_name = serializers.CharField(source='model_verbose_name', read_only=True) model_name_plural = serializers.CharField(source='model_verbose_name_plural', read_only=True) From 9cf023a0c62bd51e903f9af217140cfad68333d2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Aug 2025 08:32:19 -0400 Subject: [PATCH 12/23] #19739: Include tab character as CSV delimiter choice --- netbox/netbox/constants.py | 1 + netbox/netbox/preferences.py | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index aeeeae90ed5..7a8d54ca8fe 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -66,4 +66,5 @@ 'comma': ',', 'semicolon': ';', 'pipe': '|', + 'tab': '\t', } diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index b537679b3fb..f69d2abc753 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -1,6 +1,7 @@ from django.conf import settings from django.utils.translation import gettext_lazy as _ +from netbox.constants import CSV_DELIMITERS from netbox.registry import registry from users.preferences import UserPreference from utilities.paginator import EnhancedPaginator @@ -12,6 +13,16 @@ def get_page_lengths(): ] +def get_csv_delimiters(): + choices = [] + for k, v in CSV_DELIMITERS.items(): + label = _(k.title()) + if v.strip(): + label = f'{label} ({v})' + choices.append((k, label)) + return choices + + PREFERENCES = { # User interface @@ -74,11 +85,7 @@ def get_page_lengths(): ), 'csv_delimiter': UserPreference( label=_('CSV delimiter'), - choices=( - ('comma', 'Comma (,)'), - ('semicolon', 'Semicolon (;)'), - ('pipe', 'Pipe (|)'), - ), + choices=get_csv_delimiters(), default='comma', description=_('The character used to separate fields in CSV data') ), From a3ce248c0dcb9897282d3c09c7ce62c699f95d8f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Aug 2025 08:38:57 -0400 Subject: [PATCH 13/23] Add support for pipe character as delimiting character for bulk imports --- netbox/netbox/choices.py | 2 ++ netbox/netbox/constants.py | 8 -------- netbox/netbox/preferences.py | 2 +- netbox/utilities/constants.py | 1 + netbox/utilities/export.py | 2 +- netbox/utilities/forms/bulk_import.py | 2 +- 6 files changed, 6 insertions(+), 11 deletions(-) diff --git a/netbox/netbox/choices.py b/netbox/netbox/choices.py index 5c3110745ff..4c2b2478ab2 100644 --- a/netbox/netbox/choices.py +++ b/netbox/netbox/choices.py @@ -151,12 +151,14 @@ class CSVDelimiterChoices(ChoiceSet): AUTO = 'auto' COMMA = CSV_DELIMITERS['comma'] SEMICOLON = CSV_DELIMITERS['semicolon'] + PIPE = CSV_DELIMITERS['pipe'] TAB = CSV_DELIMITERS['tab'] CHOICES = [ (AUTO, _('Auto-detect')), (COMMA, _('Comma')), (SEMICOLON, _('Semicolon')), + (PIPE, _('Pipe')), (TAB, _('Tab')), ] diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 7a8d54ca8fe..d3f9c478629 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -60,11 +60,3 @@ # Placeholder text for empty tables EMPTY_TABLE_TEXT = 'No results found' - -# CSV delimiters -CSV_DELIMITERS = { - 'comma': ',', - 'semicolon': ';', - 'pipe': '|', - 'tab': '\t', -} diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index f69d2abc753..d8fb130f4ac 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -1,9 +1,9 @@ from django.conf import settings from django.utils.translation import gettext_lazy as _ -from netbox.constants import CSV_DELIMITERS from netbox.registry import registry from users.preferences import UserPreference +from utilities.constants import CSV_DELIMITERS from utilities.paginator import EnhancedPaginator diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 9f027f8e16d..c2ffa37a825 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -69,6 +69,7 @@ CSV_DELIMITERS = { 'comma': ',', 'semicolon': ';', + 'pipe': '|', 'tab': '\t', } diff --git a/netbox/utilities/export.py b/netbox/utilities/export.py index 56ba9506aad..73d78457991 100644 --- a/netbox/utilities/export.py +++ b/netbox/utilities/export.py @@ -1,7 +1,7 @@ from django.utils.translation import gettext_lazy as _ from django_tables2.export import TableExport as TableExport_ -from netbox.constants import CSV_DELIMITERS +from utilities.constants import CSV_DELIMITERS __all__ = ( 'TableExport', diff --git a/netbox/utilities/forms/bulk_import.py b/netbox/utilities/forms/bulk_import.py index 967ba119694..1e38de014d6 100644 --- a/netbox/utilities/forms/bulk_import.py +++ b/netbox/utilities/forms/bulk_import.py @@ -115,7 +115,7 @@ def _clean_csv(self, data, delimiter=CSVDelimiterChoices.AUTO): dialect = csv.Sniffer().sniff(data.strip(), delimiters=delimiters) except csv.Error: dialect = csv.excel - elif delimiter in (CSVDelimiterChoices.COMMA, CSVDelimiterChoices.SEMICOLON): + elif delimiter in (CSVDelimiterChoices.COMMA, CSVDelimiterChoices.SEMICOLON, CSVDelimiterChoices.PIPE): dialect = csv.excel dialect.delimiter = delimiter elif delimiter == CSVDelimiterChoices.TAB: From 8c1b39e031909e2a8e67f237de448d99f7fc04cd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Aug 2025 09:03:16 -0400 Subject: [PATCH 14/23] #19773: Include Django apps in system status view --- netbox/core/views.py | 6 +++++ netbox/netbox/api/views.py | 15 ++--------- netbox/templates/core/system.html | 43 +++++++++++++++++++++++++------ netbox/utilities/apps.py | 17 ++++++++++++ 4 files changed, 60 insertions(+), 21 deletions(-) create mode 100644 netbox/utilities/apps.py diff --git a/netbox/core/views.py b/netbox/core/views.py index 2044e56c7c1..858601e6dd3 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -27,6 +27,7 @@ from netbox.views import generic from netbox.views.generic.base import BaseObjectView from netbox.views.generic.mixins import TableMixin +from utilities.apps import get_installed_apps from utilities.data import shallow_compare_dict from utilities.forms import ConfirmationForm from utilities.htmx import htmx_partial @@ -569,6 +570,9 @@ def get(self, request): 'rq_worker_count': Worker.count(get_connection('default')), } + # Django apps + django_apps = get_installed_apps() + # Configuration config = get_config() @@ -587,6 +591,7 @@ def get(self, request): params = [param.name for param in PARAMS] data = { **stats, + 'django_apps': django_apps, 'plugins': plugins, 'config': { k: getattr(config, k) for k in sorted(params) @@ -606,6 +611,7 @@ def get(self, request): return render(request, 'core/system.html', { 'stats': stats, + 'django_apps': django_apps, 'config': config, 'plugins': plugins, 'objects': objects, diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 82124e1c53c..6740700b821 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -1,7 +1,6 @@ import platform from django import __version__ as DJANGO_VERSION -from django.apps import apps from django.conf import settings from django_rq.queues import get_connection from drf_spectacular.types import OpenApiTypes @@ -13,6 +12,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.plugins.utils import get_installed_plugins +from utilities.apps import get_installed_apps class APIRootView(APIView): @@ -52,21 +52,10 @@ class StatusView(APIView): @extend_schema(responses={200: OpenApiTypes.OBJECT}) def get(self, request): - # Gather the version numbers from all installed Django apps - installed_apps = {} - for app_config in apps.get_app_configs(): - app = app_config.module - version = getattr(app, 'VERSION', getattr(app, '__version__', None)) - if version: - if type(version) is tuple: - version = '.'.join(str(n) for n in version) - installed_apps[app_config.name] = version - installed_apps = {k: v for k, v in sorted(installed_apps.items())} - return Response({ 'django-version': DJANGO_VERSION, 'hostname': settings.HOSTNAME, - 'installed-apps': installed_apps, + 'installed_apps': get_installed_apps(), 'netbox-version': settings.RELEASE.version, 'netbox-full-version': settings.RELEASE.full_version, 'plugins': get_installed_plugins(), diff --git a/netbox/templates/core/system.html b/netbox/templates/core/system.html index 1e2914b3c88..092bc708d0f 100644 --- a/netbox/templates/core/system.html +++ b/netbox/templates/core/system.html @@ -45,6 +45,10 @@

{% trans "System Status" %}

+ + + +
{% trans "System hostname" %}{{ settings.HOSTNAME }}
{% trans "NetBox release" %} @@ -93,6 +97,23 @@

{% trans "System Status" %}

+
+

{% trans "Django Apps" %}

+ {% if django_apps %} + + {% for app_name, version in django_apps.items %} + + + + + {% endfor %} +
{{ app_name }}{{ version }}
+ {% else %} +
+ {% trans "None found" %} +
+ {% endif %} +
@@ -115,14 +136,20 @@

{% trans "Current Configuration" %}

{% trans "Installed Plugins" %}

- - {% for plugin, version in plugins.items %} - - - - - {% endfor %} -
{{ plugin }}{{ version }}
+ {% if plugins %} + + {% for plugin, version in plugins.items %} + + + + + {% endfor %} +
{{ plugin }}{{ version }}
+ {% else %} +
+ {% trans "No plugins are installed." %} +
+ {% endif %}
diff --git a/netbox/utilities/apps.py b/netbox/utilities/apps.py new file mode 100644 index 00000000000..b5445c8c403 --- /dev/null +++ b/netbox/utilities/apps.py @@ -0,0 +1,17 @@ +from django.apps import apps + + +def get_installed_apps(): + """ + Return the name and version number for each installed Django app. + """ + installed_apps = {} + for app_config in apps.get_app_configs(): + app = app_config.module + if version := getattr(app, 'VERSION', getattr(app, '__version__', None)): + if type(version) is tuple: + version = '.'.join(str(n) for n in version) + installed_apps[app_config.name] = version + return { + k: v for k, v in sorted(installed_apps.items()) + } From d8b935e0ec3fcd74de7f18b90f480d91f88e348b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Aug 2025 10:02:58 -0400 Subject: [PATCH 15/23] #19713: Fix duplicate changelog_message fields on bulk edit forms without fieldsets defined --- netbox/netbox/forms/mixins.py | 9 +++++++++ netbox/templates/generic/bulk_edit.html | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/forms/mixins.py b/netbox/netbox/forms/mixins.py index 84928fcd970..4096ffb256f 100644 --- a/netbox/netbox/forms/mixins.py +++ b/netbox/netbox/forms/mixins.py @@ -23,6 +23,15 @@ class ChangelogMessageMixin(forms.Form): max_length=200 ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Declare changelog_message a meta field + if hasattr(self, 'meta_fields'): + self.meta_fields.append('changelog_message') + else: + self.meta_fields = ['changelog_message'] + class CustomFieldsMixin: """ diff --git a/netbox/templates/generic/bulk_edit.html b/netbox/templates/generic/bulk_edit.html index 58bf6dbc223..ffb0d59a9e4 100644 --- a/netbox/templates/generic/bulk_edit.html +++ b/netbox/templates/generic/bulk_edit.html @@ -93,7 +93,8 @@

{% trans "Comments" %}

{# Render all fields #} {% for field in form.visible_fields %} - {% if field.name in form.nullable_fields %} + {% if field.name in form.meta_fields %} + {% elif field.name in form.nullable_fields %} {% render_field field bulk_nullable=True %} {% else %} {% render_field field %} From fc8ee27f86bd532d34bd1e666aaa4abe068d2ce6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Aug 2025 10:05:19 -0400 Subject: [PATCH 16/23] #19891: Fix duplicate background_job fields on bulk edit forms without fieldsets defined --- netbox/utilities/forms/mixins.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/netbox/utilities/forms/mixins.py b/netbox/utilities/forms/mixins.py index e998b6adcc0..d3bf948707b 100644 --- a/netbox/utilities/forms/mixins.py +++ b/netbox/utilities/forms/mixins.py @@ -19,6 +19,15 @@ class BackgroundJobMixin(forms.Form): required=False, ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Declare background_job a meta field + if hasattr(self, 'meta_fields'): + self.meta_fields.append('background_job') + else: + self.meta_fields = ['background_job'] + class CheckLastUpdatedMixin(forms.Form): """ From a0f55f6590daba9dd75668e8a453a043f3ca944b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Aug 2025 10:46:17 -0400 Subject: [PATCH 17/23] #19735: Fix get_context() for ObjectAction subclasses --- netbox/dcim/object_actions.py | 3 --- netbox/netbox/object_actions.py | 27 ++++++++++++++---------- netbox/utilities/templatetags/buttons.py | 7 +++--- netbox/virtualization/object_actions.py | 3 --- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/netbox/dcim/object_actions.py b/netbox/dcim/object_actions.py index 00a409274c5..67cb188e800 100644 --- a/netbox/dcim/object_actions.py +++ b/netbox/dcim/object_actions.py @@ -20,10 +20,7 @@ class BulkAddComponents(ObjectAction): @classmethod def get_context(cls, context, obj): return { - 'perms': context.get('perms'), - 'request': context.get('request'), 'formaction': context.get('formaction'), - 'label': cls.label, } diff --git a/netbox/netbox/object_actions.py b/netbox/netbox/object_actions.py index 4bb1630a013..f812c6b405d 100644 --- a/netbox/netbox/object_actions.py +++ b/netbox/netbox/object_actions.py @@ -51,13 +51,23 @@ def get_url(cls, obj): return @classmethod - def render(cls, obj, **kwargs): - context = { + def get_context(cls, context, obj): + """ + Return any additional context data needed to render the button. + """ + return {} + + @classmethod + def render(cls, context, obj, **kwargs): + ctx = { + 'perms': context['perms'], + 'request': context['request'], 'url': cls.get_url(obj), 'label': cls.label, + **cls.get_context(context, obj), **kwargs, } - return loader.render_to_string(cls.template_name, context) + return loader.render_to_string(cls.template_name, ctx) class AddObject(ObjectAction): @@ -80,13 +90,10 @@ class CloneObject(ObjectAction): template_name = 'buttons/clone.html' @classmethod - def get_context(cls, context, obj): + def get_url(cls, obj): + url = super().get_url(obj) param_string = prepare_cloned_fields(obj).urlencode() - url = f'{cls.get_url(obj)}?{param_string}' if param_string else None - return { - 'url': url, - 'label': cls.label, - } + return f'{url}?{param_string}' if param_string else None class EditObject(ObjectAction): @@ -142,8 +149,6 @@ def get_context(cls, context, model): export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type) return { - 'label': cls.label, - 'perms': context['perms'], 'object_type': object_type, 'url_params': context['request'].GET.urlencode() if context['request'].GET else '', 'export_templates': export_templates, diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 00ce879a981..d6776d72793 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -28,10 +28,11 @@ register = template.Library() -@register.simple_tag -def action_buttons(actions, obj, multi=False, **kwargs): +@register.simple_tag(takes_context=True) +def action_buttons(context, actions, obj, multi=False, **kwargs): buttons = [ - action.render(obj, **kwargs) for action in actions if action.multi == multi + action.render(context, obj, **kwargs) + for action in actions if action.multi == multi ] return mark_safe(''.join(buttons)) diff --git a/netbox/virtualization/object_actions.py b/netbox/virtualization/object_actions.py index 0f248b4e43e..c6c6886c3bd 100644 --- a/netbox/virtualization/object_actions.py +++ b/netbox/virtualization/object_actions.py @@ -19,8 +19,5 @@ class BulkAddComponents(ObjectAction): @classmethod def get_context(cls, context, obj): return { - 'perms': context.get('perms'), - 'request': context.get('request'), 'formaction': context.get('formaction'), - 'label': cls.label, } From f083cf79fe689004cfdaa865276390a84c679e8d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Aug 2025 12:08:21 -0400 Subject: [PATCH 18/23] #19973: lsmodels() should prefix models with app label --- netbox/core/management/commands/nbshell.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/core/management/commands/nbshell.py b/netbox/core/management/commands/nbshell.py index ef7ae191e2e..7b7cee0bc15 100644 --- a/netbox/core/management/commands/nbshell.py +++ b/netbox/core/management/commands/nbshell.py @@ -78,8 +78,8 @@ def _lsmodels(self, app_label=None): for app_label in app_labels: app_name = apps.get_app_config(app_label).verbose_name print(f'{app_name}:') - for m in self.django_models[app_label]: - print(f' {m}') + for model in self.django_models[app_label]: + print(f' {app_label}.{model}') def get_namespace(self): namespace = defaultdict(SimpleNamespace) From 4bd5b3574f352f5f1aa1fa56191906763816d425 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Aug 2025 14:40:52 -0400 Subject: [PATCH 19/23] #19713: Remove changelog_message from bulk import form for unsupported models --- netbox/netbox/views/generic/bulk_views.py | 8 +++++++- netbox/templates/generic/bulk_import.html | 21 ++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 1a5ee15e5bf..d9b2b875fa3 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -21,6 +21,7 @@ from core.signals import clear_events from extras.choices import CustomFieldUIEditableChoices from extras.models import CustomField, ExportTemplate +from netbox.models.features import ChangeLoggingMixin from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation @@ -495,10 +496,13 @@ def create_and_update_objects(self, form, request): # def get(self, request): + model = self.model_form._meta.model form = BulkImportForm() + if not issubclass(model, ChangeLoggingMixin): + form.fields.pop('changelog_message') return render(request, self.template_name, { - 'model': self.model_form._meta.model, + 'model': model, 'form': form, 'fields': self._get_form_fields(), 'return_url': self.get_return_url(request), @@ -509,6 +513,8 @@ def post(self, request): logger = logging.getLogger('netbox.views.BulkImportView') model = self.model_form._meta.model form = BulkImportForm(request.POST, request.FILES) + if not issubclass(model, ChangeLoggingMixin): + form.fields.pop('changelog_message') if form.is_valid(): logger.debug("Import form validation was successful") diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index 0e83afcb3c3..e6817f3432c 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -53,8 +53,10 @@ {% render_field form.csv_delimiter %} {# Meta fields #} -
- {% render_field form.changelog_message %} +
+ {% if form.changelog_message %} + {% render_field form.changelog_message %} + {% endif %} {% render_field form.background_job %}
@@ -83,9 +85,12 @@ {% render_field form.csv_delimiter %} {# Meta fields #} -
- {% render_field form.changelog_message %} -
+ {# Background jobs not supported with file uploads #} + {% if form.changelog_message %} +
+ {% render_field form.changelog_message %} +
+ {% endif %}
@@ -113,8 +118,10 @@ {% render_field form.csv_delimiter %} {# Meta fields #} -
- {% render_field form.changelog_message %} +
+ {% if form.changelog_message %} + {% render_field form.changelog_message %} + {% endif %} {% render_field form.background_job %}
From 72c552dd776e6daf9f26a1223096ad8ac1602ef6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Aug 2025 15:19:39 -0400 Subject: [PATCH 20/23] #19816: Capture additional logging under ScriptJob --- netbox/extras/jobs.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/extras/jobs.py b/netbox/extras/jobs.py index c4a1b3b2618..8a039c7c8a5 100644 --- a/netbox/extras/jobs.py +++ b/netbox/extras/jobs.py @@ -59,6 +59,7 @@ def run_script(self, script, request, data, commit): else: script.log_failure(msg) logger.error(f"Script aborted with error: {e}") + self.logger.error(f"Script aborted with error: {e}") else: stacktrace = traceback.format_exc() @@ -66,9 +67,11 @@ def run_script(self, script, request, data, commit): message=_("An exception occurred: ") + f"`{type(e).__name__}: {e}`\n```\n{stacktrace}\n```" ) logger.error(f"Exception raised during script execution: {e}") + self.logger.error(f"Exception raised during script execution: {e}") if type(e) is not AbortTransaction: script.log_info(message=_("Database changes have been reverted due to error.")) + self.logger.info("Database changes have been reverted due to error.") # Clear all pending events. Job termination (including setting the status) is handled by the job framework. if request: @@ -108,9 +111,11 @@ def run(self, data, request=None, commit=True, **kwargs): # Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process # change logging, event rules, etc. if commit: + self.logger.info("Executing script (commit enabled)") with ExitStack() as stack: for request_processor in registry['request_processors']: stack.enter_context(request_processor(request)) self.run_script(script, request, data, commit) else: + self.logger.warning("Executing script (commit disabled)") self.run_script(script, request, data, commit) From 7fad58a8dfcbb6a7f4d221dee9231678631e93b6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Aug 2025 16:12:08 -0400 Subject: [PATCH 21/23] #19713: Extend render_form() template tag to support meta fields --- .../templates/form_helpers/render_form.html | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/templates/form_helpers/render_form.html b/netbox/utilities/templates/form_helpers/render_form.html index 928b4c09764..4cfa35f8f6d 100644 --- a/netbox/utilities/templates/form_helpers/render_form.html +++ b/netbox/utilities/templates/form_helpers/render_form.html @@ -1,8 +1,25 @@ {% load form_helpers %} +{# Hidden fields #} {% for field in form.hidden_fields %} - {{ field }} + {{ field }} {% endfor %} + +{# Normal fields #} {% for field in form.visible_fields %} + {% if not form.meta_fields or field.name not in form.meta_fields %} {% render_field field %} + {% endif %} {% endfor %} + +{# Meta fields #} +{% if form.meta_fields %} +
+ {% if form.changelog_message %} + {% render_field form.changelog_message %} + {% endif %} + {% if form.background_job %} + {% render_field form.background_job %} + {% endif %} +
+{% endif %} From 3f475e0b8df36a84681f2b0c2cd1c2ad0512bccb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 14 Aug 2025 11:41:43 -0400 Subject: [PATCH 22/23] #19924: Expose public & features fields in API serializer and enable filtering --- netbox/core/api/serializers_/object_types.py | 5 ++- netbox/core/filtersets.py | 10 ++++- netbox/core/tests/test_filtersets.py | 45 ++++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/netbox/core/api/serializers_/object_types.py b/netbox/core/api/serializers_/object_types.py index 5ab732e2810..c36796b5fde 100644 --- a/netbox/core/api/serializers_/object_types.py +++ b/netbox/core/api/serializers_/object_types.py @@ -26,9 +26,10 @@ class ObjectTypeSerializer(BaseModelSerializer): class Meta: model = ObjectType fields = [ - 'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural', - 'is_plugin_model', 'rest_api_endpoint', 'description', + 'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural', 'public', + 'features', 'is_plugin_model', 'rest_api_endpoint', 'description', ] + read_only_fields = ['public', 'features'] @extend_schema_field(OpenApiTypes.STR) def get_rest_api_endpoint(self, obj): diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index 9f90752d702..215745e7d89 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -134,15 +134,18 @@ def search(self, queryset, name, value): ) -class ObjectTypeFilterSet(django_filters.FilterSet): +class ObjectTypeFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), ) + features = django_filters.CharFilter( + method='filter_features' + ) class Meta: model = ObjectType - fields = ('id', 'app_label', 'model') + fields = ('id', 'app_label', 'model', 'public') def search(self, queryset, name, value): if not value.strip(): @@ -152,6 +155,9 @@ def search(self, queryset, name, value): Q(model__icontains=value) ) + def filter_features(self, queryset, name, value): + return queryset.filter(features__icontains=value) + class ObjectChangeFilterSet(BaseFilterSet): q = django_filters.CharFilter( diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index 4b2cff84d40..50441cf62a8 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -241,3 +241,48 @@ def test_changed_object_type(self): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + +class ObjectTypeTestCase(TestCase, BaseFilterSetTests): + queryset = ObjectType.objects.all() + filterset = ObjectTypeFilterSet + ignore_fields = ( + 'custom_fields', + 'custom_links', + 'event_rules', + 'export_templates', + 'object_permissions', + 'saved_filters', + ) + + def test_q(self): + params = {'q': 'vrf'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_app_label(self): + self.assertEqual( + self.filterset({'app_label': ['dcim']}, self.queryset).qs.count(), + ObjectType.objects.filter(app_label='dcim').count(), + ) + + def test_model(self): + self.assertEqual( + self.filterset({'model': ['site']}, self.queryset).qs.count(), + ObjectType.objects.filter(model='site').count(), + ) + + def test_public(self): + self.assertEqual( + self.filterset({'public': True}, self.queryset).qs.count(), + ObjectType.objects.filter(public=True).count(), + ) + self.assertEqual( + self.filterset({'public': False}, self.queryset).qs.count(), + ObjectType.objects.filter(public=False).count(), + ) + + def test_feature(self): + self.assertEqual( + self.filterset({'features': 'tags'}, self.queryset).qs.count(), + ObjectType.objects.filter(features__contains=['tags']).count(), + ) From e7a14e0902c9817192933cb2b1978d87c4c8caff Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 14 Aug 2025 12:54:46 -0400 Subject: [PATCH 23/23] Adjust TODO release targets --- netbox/extras/scripts.py | 8 ++++---- netbox/netbox/constants.py | 2 +- netbox/utilities/templatetags/buttons.py | 18 +++++++++--------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 420ef48826e..a14eba55655 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -588,9 +588,9 @@ def load_yaml(self, filename): """ Return data from a YAML file """ - # TODO: DEPRECATED: Remove this method in v4.4 + # TODO: DEPRECATED: Remove this method in v4.5 self._log( - _("load_yaml is deprecated and will be removed in v4.4"), + _("load_yaml is deprecated and will be removed in v4.5"), level=LogLevelChoices.LOG_WARNING ) file_path = os.path.join(settings.SCRIPTS_ROOT, filename) @@ -603,9 +603,9 @@ def load_json(self, filename): """ Return data from a JSON file """ - # TODO: DEPRECATED: Remove this method in v4.4 + # TODO: DEPRECATED: Remove this method in v4.5 self._log( - _("load_json is deprecated and will be removed in v4.4"), + _("load_json is deprecated and will be removed in v4.5"), level=LogLevelChoices.LOG_WARNING ) file_path = os.path.join(settings.SCRIPTS_ROOT, filename) diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index d3f9c478629..a0ba966d713 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -44,7 +44,7 @@ 'job-schedules': 110100, } -# TODO: Remove in NetBox v4.6 +# TODO: Remove in NetBox v4.5 # Legacy default view action permission mapping DEFAULT_ACTION_PERMISSIONS = { 'add': {'add'}, diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index d6776d72793..e8ce71dceb2 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -107,7 +107,7 @@ def subscribe_button(context, instance): # Legacy object buttons # -# TODO: Remove in NetBox v4.6 +# TODO: Remove in NetBox v4.7 @register.inclusion_tag('buttons/clone.html') def clone_button(instance): # Resolve URL path @@ -125,7 +125,7 @@ def clone_button(instance): } -# TODO: Remove in NetBox v4.6 +# TODO: Remove in NetBox v4.7 @register.inclusion_tag('buttons/edit.html') def edit_button(instance): url = get_action_url(instance, action='edit', kwargs={'pk': instance.pk}) @@ -136,7 +136,7 @@ def edit_button(instance): } -# TODO: Remove in NetBox v4.6 +# TODO: Remove in NetBox v4.7 @register.inclusion_tag('buttons/delete.html') def delete_button(instance): url = get_action_url(instance, action='delete', kwargs={'pk': instance.pk}) @@ -147,7 +147,7 @@ def delete_button(instance): } -# TODO: Remove in NetBox v4.6 +# TODO: Remove in NetBox v4.7 @register.inclusion_tag('buttons/sync.html') def sync_button(instance): url = get_action_url(instance, action='sync', kwargs={'pk': instance.pk}) @@ -162,7 +162,7 @@ def sync_button(instance): # Legacy list buttons # -# TODO: Remove in NetBox v4.6 +# TODO: Remove in NetBox v4.7 @register.inclusion_tag('buttons/add.html') def add_button(model, action='add'): try: @@ -176,7 +176,7 @@ def add_button(model, action='add'): } -# TODO: Remove in NetBox v4.6 +# TODO: Remove in NetBox v4.7 @register.inclusion_tag('buttons/import.html') def import_button(model, action='bulk_import'): try: @@ -190,7 +190,7 @@ def import_button(model, action='bulk_import'): } -# TODO: Remove in NetBox v4.6 +# TODO: Remove in NetBox v4.7 @register.inclusion_tag('buttons/export.html', takes_context=True) def export_button(context, model): object_type = ObjectType.objects.get_for_model(model) @@ -212,7 +212,7 @@ def export_button(context, model): } -# TODO: Remove in NetBox v4.6 +# TODO: Remove in NetBox v4.7 @register.inclusion_tag('buttons/bulk_edit.html', takes_context=True) def bulk_edit_button(context, model, action='bulk_edit', query_params=None): try: @@ -229,7 +229,7 @@ def bulk_edit_button(context, model, action='bulk_edit', query_params=None): } -# TODO: Remove in NetBox v4.6 +# TODO: Remove in NetBox v4.7 @register.inclusion_tag('buttons/bulk_delete.html', takes_context=True) def bulk_delete_button(context, model, action='bulk_delete', query_params=None): try: