From a20715f229f7f39141588691bfa52d215d51981c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Aug 2025 10:23:58 -0400 Subject: [PATCH 01/36] Fixes #19321: Reduce redundant database queries during bulk creation of devices (#19993) * Fixes #19321: Reduce redundant database queries during bulk creation of devices * Add test for test_get_prefetchable_fields --- netbox/dcim/models/devices.py | 8 ++++-- netbox/utilities/prefetch.py | 34 +++++++++++++++++++++++++ netbox/utilities/tests/test_prefetch.py | 17 +++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 netbox/utilities/prefetch.py create mode 100644 netbox/utilities/tests/test_prefetch.py diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index f0c116b0e..abd29d4e3 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError from django.core.files.storage import default_storage from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import F, ProtectedError +from django.db.models import F, ProtectedError, prefetch_related_objects from django.db.models.functions import Lower from django.db.models.signals import post_save from django.urls import reverse @@ -28,6 +28,7 @@ from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models.mixins import WeightMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from utilities.fields import ColorField, CounterCacheField +from utilities.prefetch import get_prefetchable_fields from utilities.tracking import TrackingModelMixin from .device_components import * from .mixins import RenderConfigMixin @@ -924,7 +925,10 @@ class Device( if cf_defaults := CustomField.objects.get_defaults_for_model(model): for component in components: component.custom_field_data = cf_defaults - model.objects.bulk_create(components) + components = model.objects.bulk_create(components) + # Prefetch related objects to minimize queries needed during post_save + prefetch_fields = get_prefetchable_fields(model) + prefetch_related_objects(components, *prefetch_fields) # Manually send the post_save signal for each of the newly created components for component in components: post_save.send( diff --git a/netbox/utilities/prefetch.py b/netbox/utilities/prefetch.py new file mode 100644 index 000000000..c73a3fd4f --- /dev/null +++ b/netbox/utilities/prefetch.py @@ -0,0 +1,34 @@ +from django.contrib.contenttypes.fields import GenericRelation +from django.db.models import ManyToManyField +from django.db.models.fields.related import ForeignObjectRel +from taggit.managers import TaggableManager + +__all__ = ( + 'get_prefetchable_fields', +) + + +def get_prefetchable_fields(model): + """ + Return a list containing the names of all fields on the given model which support prefetching. + """ + field_names = [] + + for field in model._meta.get_fields(): + # Forward relations (e.g. ManyToManyFields) + if isinstance(field, ManyToManyField): + field_names.append(field.name) + + # Reverse relations (e.g. reverse ForeignKeys, reverse M2M) + elif isinstance(field, ForeignObjectRel): + field_names.append(field.get_accessor_name()) + + # Generic relations + elif isinstance(field, GenericRelation): + field_names.append(field.name) + + # Tags + elif isinstance(field, TaggableManager): + field_names.append(field.name) + + return field_names diff --git a/netbox/utilities/tests/test_prefetch.py b/netbox/utilities/tests/test_prefetch.py new file mode 100644 index 000000000..9da35c12e --- /dev/null +++ b/netbox/utilities/tests/test_prefetch.py @@ -0,0 +1,17 @@ +from circuits.models import Circuit, Provider +from utilities.prefetch import get_prefetchable_fields +from utilities.testing.base import TestCase + + +class GetPrefetchableFieldsTest(TestCase): + """ + Verify the operation of get_prefetchable_fields() + """ + def test_get_prefetchable_fields(self): + field_names = get_prefetchable_fields(Provider) + self.assertIn('asns', field_names) # ManyToManyField + self.assertIn('circuits', field_names) # Reverse relation + self.assertIn('tags', field_names) # Tags + + field_names = get_prefetchable_fields(Circuit) + self.assertIn('group_assignments', field_names) # Generic relation From c7b68664f9e48c0d70b18f4da8ae2c59b0c26e99 Mon Sep 17 00:00:00 2001 From: Jonathan Ramstedt Date: Fri, 1 Aug 2025 19:51:00 +0300 Subject: [PATCH 02/36] Closes #18843: use color name in cable export (#19983) --- netbox/dcim/models/cables.py | 10 ++++++++++ netbox/dcim/tables/cables.py | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 0a28d5acb..26bb0fed7 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -11,6 +11,7 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node +from netbox.choices import ColorChoices from netbox.models import ChangeLoggedModel, PrimaryModel from utilities.conversion import to_meters from utilities.exceptions import AbortRequest @@ -155,6 +156,15 @@ class Cable(PrimaryModel): self._terminations_modified = True self._b_terminations = value + @property + def color_name(self): + color_name = "" + for hex_code, label in ColorChoices.CHOICES: + if hex_code.lower() == self.color.lower(): + color_name = str(label) + + return color_name + def clean(self): super().clean() diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index e12545fd2..321eb79f5 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -113,6 +113,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable): order_by=('_abs_length') ) color = columns.ColorColumn() + color_name = tables.Column( + verbose_name=_('Color Name'), + orderable=False + ) comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:cable_list' @@ -123,7 +127,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable): fields = ( 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b', 'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color', - 'length', 'description', 'comments', 'tags', 'created', 'last_updated', + 'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type', From de53fd2bd145c5ee544f9626d6e4ec580d05895b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Aug 2025 13:39:25 -0400 Subject: [PATCH 03/36] Configure CodeQL to ignore compiled JS resources (#20000) * Configure CodeQL to ignore compiled JS resources * Enable CodeQL for feature branch --- .github/codeql/codeql-config.yml | 3 +++ .github/workflows/codeql.yml | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 .github/codeql/codeql-config.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 000000000..f763ef6df --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,3 @@ +paths-ignore: + # Ignore compiled JS + - netbox/project-static/dist diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..899242128 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: "CodeQL" + +on: + push: + branches: [ "main", "feature" ] + pull_request: + branches: [ "main", "feature" ] + schedule: + - cron: '38 16 * * 4' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: .github/codeql/codeql-config.yml + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" From d4b30a64ba0060b4202995411c9a47de9b24ff7d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Aug 2025 13:20:30 -0400 Subject: [PATCH 04/36] Fixes #20001: is_api_request() should not evaluate a request's content type --- netbox/utilities/api.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 6793c0526..9b3463036 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -9,7 +9,6 @@ from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import Serializer from rest_framework.views import get_view_name as drf_get_view_name -from extras.constants import HTTP_CONTENT_TYPE_JSON from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound from netbox.api.fields import RelatedObjectCountField from .query import count_related, dict_to_filter_params @@ -56,8 +55,7 @@ def is_api_request(request): """ Return True of the request is being made via the REST API. """ - api_path = reverse('api-root') - return request.path_info.startswith(api_path) and request.content_type == HTTP_CONTENT_TYPE_JSON + return request.path_info.startswith(reverse('api-root')) def get_view_name(view): From 35b9d80819d91010a7f06584868b3e7dc8a3eb95 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Aug 2025 15:06:23 -0400 Subject: [PATCH 05/36] Closes #19968: Use multiple selection lists for the assignment of object types when editing a permission (#19991) * Closes #19968: Use multiple selection lists for the assignment of object types when editing a permission * Remove errant logging statements * Defer compilation of choices for object_types * Fix test data --- netbox/project-static/dist/netbox.js | Bin 382185 -> 382549 bytes netbox/project-static/dist/netbox.js.map | Bin 1733459 -> 1734998 bytes .../project-static/src/buttons/moveOptions.ts | 49 ++++++++--- netbox/templates/extras/tableconfig_edit.html | 4 +- netbox/users/forms/model_forms.py | 15 +++- netbox/users/tests/test_views.py | 2 +- netbox/utilities/forms/widgets/select.py | 77 ++++++++++++++++++ .../templates/helpers/table_config_form.html | 8 +- .../templates/widgets/splitmultiselect.html | 31 +++++++ 9 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 netbox/utilities/templates/widgets/splitmultiselect.html diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 9e2d6b383c2f22e003900f3bd894ce5d117a9676..7572150d4a6efa855f939ab1f78d159ee4acc9b2 100644 GIT binary patch delta 952 zcma)5TTB#J7|uWc91x;Gv0%jq))mN1GCM4-)(csU_r(i^O;jRnT--g3qw5Tg!^U7X zULa^xWWkmH!Ke>Llcr5*lP0H$HsyiZl#9@$MjmWapPD|D5FdQ*S)0l}^ud?&o%8+q zFW-00`Rj{S&%auAu@Amg{*J-%EAuf$%*SxTTw!qie5dPaDeedSfZG|Mu}fI7N%zgO zJjD|}PuA6G-0bM;;!bks2|Jb4d^3@99p3CFxwbZ}>xOT7{Ir|qnyypiGJZmTTtW*D zQ1miREi?0xklW?kt`np$vshi?c6$jPlzF#W?^Krh9iGK~d9-O^E3RG|x0A3ZfrNlso;lNL0oq8B&N z*AZM(vDl!||DPi8DGl~wC7AT67dO6N$^jAV!!iX9(ntN+1E=VRtym>~>c=Obp0e~t z3SYw(I4I_>Vpf4>VPD4$08MoN2Iiod)(_zIiY80*jkc7Yk<)iOiM(HdnD})7Ct-Qa z(wqW1N~43AR$45&nZ-7`kj00vhiZp#cTtO_=|+^ghNOZ$bY%z^poY#3V+eN8qhSog zVew)Zw?pZUd`aW5TCT^Jvd2b4%gA2IUJU0J-6|Wa>OF&mHv(mVKFs~U_JCu z#|VbYPfA^dHn$%U4@PjbX2lsR@^Lua_)f$jk$8+-%HfQ-H;w%Ra7fg)t0j!);%XH| z<7yW6Qhi)qO|RqX9PAg99qK63W3GNcH@O;;(I;HpUAEsUaA13aUS*(?=KG*nggRB@ z8nlU{3o0*?IQB*@nO0gY`eud&Y1=qkLrcclN@d?76N01S^f+?>TIuDFti5QT^ev-P z!!)a=&z`c2GXC3BW-AI$Q}k!{dmgn3=7TTpPOw2J_tVn9f{C2;+jb&8#g-_`owWQ6 ri}z^Lxo6BOv(p8fm!>iK-bL{YYXI1X&qv4X!a%Q5FboAFhY=`MM;yUg^8OWGv zR_bi}R#Mc37gFGj@ZA{I#Z>&0X$W1_jTjNxqMPWdGo#vNR}bIw@I1WVZ~a;Br^(!& zC`=1q+)&=Ps0BwBzHrfHIY1bgep^Y97;;sGFilkXR0XZy(eVuBH=5JR`E?zo> zi;(S7BsI-;Q+^CXLWM#&Nd?yJ_}@YGWE(>>Js-s&-5*5@)s5kZ zzPLGt=OOYLaP^&uXORYTX9!^>IG$Lh1wOM=zXL)gpIE8xf(I=MDrcc~Q4n;h!2dW}A48S=) z`%Uy3bhZ8vGnWOALQW?hqNfR#Pg;WQC3}LMqS*v93Y7{ao5d5{ILqu{@aWjWG7T@W z0t(ErVngk3Dnj|N_}#Jpfk znQ|I`Ewp_IUs`56glsJ&rH3kQtS}`@4W$+QA$sY=1N{0Mb_~Ez6RYeB1Uas;!=S7D HmRa!^Smh9T diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 0ba7a62d196c16b438f603fcc2e4ccad2acccbf2..b794d3ce5005d23f632d5333e39541eb2ba48384 100644 GIT binary patch delta 1927 zcmb_cO>7%Q6jo@{22b5UoGgi}&~aPZI1G+rkWh*ub#}6G+}1T_6G947vN)TtG}$%l zO%tdf99F70@FRgVrydYjq)G_MQg2mDiS0;$5ZvIvffFYVoXXFW9 zPj-0xVlzI-|NJNxul;rleX>8M1dObDmwD+XdW_e%(7@2h2rhV+Tk7mZtf~%G)A(gO zShsB#uQ=79%75HKW5+Wh*?vMnPNmcMijXb-%AQ*dTC&IMXpn#K73$+p*HJdHJ2tN< z@Y1`nBi#$m;{1s#Y8G!Ur1^V`JxTt<&F-$aloGT7t3V4(Fm{%3G>r(2Q{&^hNxNpm=5W=C$apyL&OS8pynV%tjLvqSw$r=)Jau68yq)Pn_eQ z&{6)&Efl+Yy@`Y!5*8PBSXj5PhlD*WtVdWvSa0omv#<91W?)caQfkhS9Hq027?fsp z`Ojg5Q8s3fk_?=qbcPftEf8@#HwnNZDZ*h;w!sYLn5ArbLS5%5os)%%WCn6YLS9s) z;A3wvDp6XiM6RNXpsC0(?$xzd&z}W|nS-+gWK?QJm;NGwUb1b8K(9$a6rlL19=R<_ zEy=WG@IM-w#-C|u0|yyMwWs)t4b3^-zDJ+~CLc8t^O+IFnX8k$@Int-JPQ?%Jg4&8 z(r{UXlmf^OnF27)1JeyA^Gh-a9$L7-FEzEJn?-1~4l^+Vwrd4)qL1sOOlcYLIZ4a_ zbm0YXh)n`LSy3G@5jHJjX4{x#zpTr5Kx{D^QrHybE&@#{0RSwRups+h+`nNP6SF|( zk`9kWtgUrtV{A%wcXUb}9WREUJ7VMo0J`tV3Ao~O#KDn(8m+m_e{>8SI(ssIS7@{!~6A-_` zt9Q3GVUGywuif1q_|Wb>M({E9C5M~(GVo8c$$tKRHksl#vdPJQ6_38tJFxj$?8#*Q F%wLx`ar^)P delta 862 zcmbu7KTH#G6vtBqO7B+?Y55^tIQ|Sa{u06@po72r^?H=HX-aJx5(lcDdRVU&tC$dz znw(+M#o?JSF}rFyIjKt%dQcN52TcqU#y`8k-@Dcj4U2L3Uf#X;z3=$o5@f9KP zrJ*QLYZBEr0ikV@4OrLI)79eYbrZh7R4Ams`V{E>q(sBZ6O-%Sp!#4r8dcXX*{TX3 zw#l6Tj#)CVRHIM}^vl$SGWOTXkI%he~b9qQM=}Jm4UB1NenH5ZMjtx=KyncoixVSj) z4cQXsO9nd9=$O_z?IPzz!*k4o&!tm-of({)3vkw@*4)wYRSi9f&JIbFm$|~$SVDGn z49lc2jKVjUb}zO=j>ND<$KGZTsCEG93qFhmH~h_pNET8*cB=A$YV) zslS5^TRSvVx)Jk8b#xY3l8ehMD=Q&Op7*YHrcLeL4llzINX*8(VTM^6=az;a('#move-option-up')) { + // Move selected option(s) between lists + for (const button of getElements('.move-option')) { + const source = button.getAttribute('data-source'); const target = button.getAttribute('data-target'); - if (target !== null) { - for (const select of getElements(`#${target}`)) { - button.addEventListener('click', () => moveOptionUp(select)); - } + const source_select = document.getElementById(`id_${source}`) as HTMLSelectElement; + const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement; + if (source_select !== null && target_select !== null) { + button.addEventListener('click', () => moveOption(source_select, target_select)); } } - for (const button of getElements('#move-option-down')) { + + // Move selected option(s) up in current list + for (const button of getElements('.move-option-up')) { const target = button.getAttribute('data-target'); - if (target !== null) { - for (const select of getElements(`#${target}`)) { - button.addEventListener('click', () => moveOptionDown(select)); - } + const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement; + if (target_select !== null) { + button.addEventListener('click', () => moveOptionUp(target_select)); + } + } + + // Move selected option(s) down in current list + for (const button of getElements('.move-option-down')) { + const target = button.getAttribute('data-target'); + const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement; + if (target_select !== null) { + button.addEventListener('click', () => moveOptionDown(target_select)); } } } diff --git a/netbox/templates/extras/tableconfig_edit.html b/netbox/templates/extras/tableconfig_edit.html index df1e67082..31057c298 100644 --- a/netbox/templates/extras/tableconfig_edit.html +++ b/netbox/templates/extras/tableconfig_edit.html @@ -36,10 +36,10 @@
{{ form.columns }} - + {% trans "Move Up" %} - + {% trans "Move Down" %}
diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index d8773feb4..d875b0792 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -15,7 +15,7 @@ from users.models import * from utilities.data import flatten_dict from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet -from utilities.forms.widgets import DateTimePicker +from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget from utilities.permissions import qs_filter_from_constraints __all__ = ( @@ -272,12 +272,21 @@ class GroupForm(forms.ModelForm): return instance +def get_object_types_choices(): + return [ + (ot.pk, str(ot)) + for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model') + ] + + class ObjectPermissionForm(forms.ModelForm): object_types = ContentTypeMultipleChoiceField( label=_('Object types'), queryset=ObjectType.objects.all(), - limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, - widget=forms.SelectMultiple(attrs={'size': 6}) + widget=SplitMultiSelectWidget( + choices=get_object_types_choices + ), + help_text=_('Select the types of objects to which the permission will appy.') ) can_view = forms.BooleanField( required=False diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 8226a8be9..e66c00d0a 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -180,7 +180,7 @@ class ObjectPermissionTestCase( cls.form_data = { 'name': 'Permission X', 'description': 'A new permission', - 'object_types': [object_type.pk], + 'object_types_1': [object_type.pk], # SplitMultiSelectWidget requires _1 suffix on field name 'actions': 'view,edit,delete', } diff --git a/netbox/utilities/forms/widgets/select.py b/netbox/utilities/forms/widgets/select.py index 8115e2449..7f4e9c87f 100644 --- a/netbox/utilities/forms/widgets/select.py +++ b/netbox/utilities/forms/widgets/select.py @@ -8,6 +8,7 @@ __all__ = ( 'ColorSelect', 'HTMXSelect', 'SelectWithPK', + 'SplitMultiSelectWidget', ) @@ -63,3 +64,79 @@ class SelectWithPK(forms.Select): Include the primary key of each option in the option label (e.g. "Router7 (4721)"). """ option_template_name = 'widgets/select_option_with_pk.html' + + +class AvailableOptions(forms.SelectMultiple): + """ + Renders a including only choices that have _not_ been selected. (For unbound fields, this + will include _all_ choices.) Employed by SplitMultiSelectWidget. + """ + def optgroups(self, name, value, attrs=None): + self.choices = [ + choice for choice in self.choices if str(choice[0]) in value + ] + value = [] # Clear selected choices + return super().optgroups(name, value, attrs) + + +class SplitMultiSelectWidget(forms.MultiWidget): + """ + Renders two + {% endif %} + {% csrf_token %} + + + + {% endif %} + + + {% if last_job and not embedded %} + {% for test_name, data in last_job.data.tests.items %} + + + {{ test_name }} + + + {{ data.success }} + {{ data.info }} + {{ data.warning }} + {{ data.failure }} + + + {% endfor %} + {% elif last_job and not last_job.data.log and not embedded %} + {# legacy #} + {% for method, stats in last_job.data.items %} + + + {{ method }} + + + {{ stats.success }} + {{ stats.info }} + {{ stats.warning }} + {{ stats.failure }} + + + {% endfor %} + {% endif %} + {% endwith %} + {% endfor %} + + + {% else %} +
+ +
+ {% endif %} + {% endwith %} + +{% empty %} + +{% endfor %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 57269aa53..4f7652c1f 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -19,135 +19,5 @@ {% endblock controls %} {% block content %} - {% for module in script_modules %} - {% include 'inc/sync_warning.html' with object=module %} -
-

- {{ module }} -
- {% if perms.extras.edit_scriptmodule %} - - {% trans "Edit" %} - - {% endif %} - {% if perms.extras.delete_scriptmodule %} - - {% trans "Delete" %} - - {% endif %} -
-

- {% with scripts=module.ordered_scripts %} - {% if scripts %} - - - - - - - - - - - - {% for script in scripts %} - {% with last_job=script.get_latest_jobs|first %} - - - - {% if last_job %} - - - {% else %} - - - {% endif %} - - - {% if last_job %} - {% for test_name, data in last_job.data.tests.items %} - - - - - {% endfor %} - {% elif not last_job.data.log %} - {# legacy #} - {% for method, stats in last_job.data.items %} - - - - - {% endfor %} - {% endif %} - {% endwith %} - {% endfor %} - -
{% trans "Name" %}{% trans "Description" %}{% trans "Last Run" %}{% trans "Status" %}
- {% if script.is_executable %} - {{ script.python_class.name }} - {% else %} - {{ script.python_class.name }} - - - - {% endif %} - {{ script.python_class.description|markdown|placeholder }} - {{ last_job.created|isodatetime }} - - {% badge last_job.get_status_display last_job.get_status_color %} - {% trans "Never" %}{{ ''|placeholder }} - {% if request.user|can_run:script and script.is_executable %} -
-
- {% if script.python_class.commit_default %} - - {% endif %} - {% csrf_token %} - -
-
- {% endif %} -
- {{ test_name }} - - {{ data.success }} - {{ data.info }} - {{ data.warning }} - {{ data.failure }} -
- {{ method }} - - {{ stats.success }} - {{ stats.info }} - {{ stats.warning }} - {{ stats.failure }} -
- {% else %} -
- -
- {% endif %} - {% endwith %} -
- {% empty %} - - {% endfor %} + {% include 'extras/inc/script_list_content.html' with embedded=False %} {% endblock content %} From 6ce3012f93b2a289a10704a9cd9af5393ae7a4f6 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 05:08:54 +0000 Subject: [PATCH 17/36] Update source translation strings --- netbox/translations/en/LC_MESSAGES/django.po | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po index 866052d23..b9cf6bb10 100644 --- a/netbox/translations/en/LC_MESSAGES/django.po +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-08-02 05:04+0000\n" +"POT-Creation-Date: 2025-08-05 05:08+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -8035,33 +8035,37 @@ msgstr "" msgid "RSS Feed" msgstr "" -#: netbox/extras/dashboard/widgets.py:314 +#: netbox/extras/dashboard/widgets.py:315 msgid "Embed an RSS feed from an external website." msgstr "" -#: netbox/extras/dashboard/widgets.py:321 +#: netbox/extras/dashboard/widgets.py:322 msgid "Feed URL" msgstr "" -#: netbox/extras/dashboard/widgets.py:325 +#: netbox/extras/dashboard/widgets.py:326 msgid "Requires external connection" msgstr "" -#: netbox/extras/dashboard/widgets.py:331 +#: netbox/extras/dashboard/widgets.py:332 msgid "The maximum number of objects to display" msgstr "" -#: netbox/extras/dashboard/widgets.py:336 +#: netbox/extras/dashboard/widgets.py:337 msgid "How long to stored the cached content (in seconds)" msgstr "" -#: netbox/extras/dashboard/widgets.py:393 netbox/templates/account/base.html:10 +#: netbox/extras/dashboard/widgets.py:343 +msgid "Timeout value for fetching the feed (in seconds)" +msgstr "" + +#: netbox/extras/dashboard/widgets.py:400 netbox/templates/account/base.html:10 #: netbox/templates/account/bookmarks.html:7 #: netbox/templates/inc/user_menu.html:43 msgid "Bookmarks" msgstr "" -#: netbox/extras/dashboard/widgets.py:397 +#: netbox/extras/dashboard/widgets.py:404 msgid "Show your personal bookmarks" msgstr "" From 15541c644099142bf48b5e35051755c084f126d9 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 5 Aug 2025 15:26:12 -0500 Subject: [PATCH 18/36] Fixes: #19998 - Add changelog entry when clearing M2M fields --- netbox/core/signals.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 3d0317011..9f08c4e65 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -13,6 +13,7 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates from core.choices import JobStatusChoices, ObjectChangeActionChoices from core.events import * from extras.events import enqueue_event +from extras.models import Tag from extras.utils import run_validators from netbox.config import get_config from netbox.context import current_request, events_queue @@ -72,6 +73,15 @@ def handle_changed_object(sender, instance, **kwargs): # m2m_changed with objects added or removed m2m_changed = True event_type = OBJECT_UPDATED + elif kwargs.get('action') == 'post_clear': + # Handle clearing of an M2M field + if isinstance(Tag, kwargs.get('model')) and getattr(instance, '_prechange_snapshot', {}).get('tags'): + # Handle tags as it is a Generic M2M + m2m_changed = True + event_type = OBJECT_UPDATED + else: + # Other M2M models are unsupported + return else: return From a86cd9dfc6cb10386a3d5a45a33ea2264926cdea Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 5 Aug 2025 15:49:01 -0500 Subject: [PATCH 19/36] Clarify comment --- netbox/core/signals.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 9f08c4e65..e5342dbf2 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -76,7 +76,8 @@ def handle_changed_object(sender, instance, **kwargs): elif kwargs.get('action') == 'post_clear': # Handle clearing of an M2M field if isinstance(Tag, kwargs.get('model')) and getattr(instance, '_prechange_snapshot', {}).get('tags'): - # Handle tags as it is a Generic M2M + # Handle generation of M2M changes for Tags which have a previous value (ignoring changes where the + # prechange snapshot is empty) m2m_changed = True event_type = OBJECT_UPDATED else: From 11f228cae9ba7584711591504ed033d90b33d459 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 6 Aug 2025 10:29:17 -0400 Subject: [PATCH 20/36] Fixes #20033: Fix exception when bulk deleting bookmarks --- netbox/extras/models/models.py | 3 +++ netbox/extras/tables/tables.py | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index aa5af892f..f5a3c4040 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -849,6 +849,9 @@ class Bookmark(models.Model): return str(self.object) return super().__str__() + def get_absolute_url(self): + return reverse('account:bookmarks') + def clean(self): super().clean() diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index e6f488fde..34ba3fb01 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -317,11 +317,12 @@ class TableConfigTable(NetBoxTable): class BookmarkTable(NetBoxTable): object_type = columns.ContentTypeColumn( - verbose_name=_('Object Types'), + verbose_name=_('Object Type'), ) object = tables.Column( verbose_name=_('Object'), - linkify=True + linkify=True, + orderable=False ) actions = columns.ActionsColumn( actions=('delete',) From fce10c73b7342ab5494a45e4d9df8d368b05112a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 6 Aug 2025 15:28:01 -0400 Subject: [PATCH 21/36] Closes #17222: Improve visibility of notifications icon (#20035) --- netbox/templates/htmx/notifications.html | 4 ++-- netbox/templates/inc/light_toggle.html | 4 ++-- netbox/templates/inc/notification_bell.html | 3 ++- netbox/templates/inc/user_menu.html | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/netbox/templates/htmx/notifications.html b/netbox/templates/htmx/notifications.html index e03e5afc9..2154d56ba 100644 --- a/netbox/templates/htmx/notifications.html +++ b/netbox/templates/htmx/notifications.html @@ -15,14 +15,14 @@
{{ notification.event }} {{ notification.created|timesince }} {% trans "ago" %}
{% empty %} -