Compare commits

..

4 Commits

Author SHA1 Message Date
Arthur
c69c6c4ca1 cleanup 2026-04-13 14:20:05 -07:00
Arthur
b1d98643ea #21361 Expand unit tests for ObjectChange and testing asserts 2026-04-13 12:12:55 -07:00
Martin Hauser
9b734bac93 chore(ci): Update GitHub Actions to use commit SHA pinning
Bump actions/create-github-app-token from v1 to v3.1.1 and
EndBug/add-and-commit from v9.1.4 to v10.0.0, both pinned to full commit
SHAs for improved supply chain security.

Fixes #21896
2026-04-13 08:04:55 -04:00
Martin Hauser
0f277894b2 chore(ci): Update ruff-action to v4.0.0
Update ruff GitHub Action from v3.6.1 to v4.0.0 and bump ruff version
from 0.15.2 to 0.15.10 for latest linting improvements.

Fixes #21682
2026-04-13 08:03:58 -04:00
7 changed files with 34 additions and 33 deletions

View File

@@ -56,9 +56,9 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check Python linting & PEP8 compliance
uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1
uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b # v4.0.0
with:
version: "0.15.2"
version: "0.15.10"
args: "check --output-format=github"
src: "netbox/"

View File

@@ -20,7 +20,7 @@ jobs:
steps:
- name: Create app token
uses: actions/create-github-app-token@v1
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
id: app-token
with:
app-id: 1076524
@@ -48,7 +48,7 @@ jobs:
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
- name: Commit changes
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
with:
add: 'netbox/translations/'
default_author: github_actions

View File

@@ -6,7 +6,6 @@ from django.dispatch import receiver
from dcim.choices import CableEndChoices, LinkStatusChoices
from ipam.models import Prefix
from netbox.signals import post_raw_create
from virtualization.models import Cluster, VMInterface
from wireless.models import WirelessLAN
@@ -167,27 +166,6 @@ def retrace_cable_paths(instance, **kwargs):
cablepath.retrace()
@receiver(post_raw_create, sender=Cable)
def retrace_cable_paths_after_raw_create(sender, pks, **kwargs):
"""
When Cables are created via a raw save, the normal Cable.save() path is bypassed,
so trace_paths is never sent. Retrace paths for all newly created cables.
Callers must only send this signal after all CableTerminations for the given cables
have been applied. If a cable has no terminations, update_connected_endpoints will
find empty termination lists and skip path creation — so this is safe to call even
if terminations are absent, but path tracing will have no effect.
Note: raw=False (the default) is intentional here — we explicitly want
update_connected_endpoints to run, unlike during fixture loading (raw=True).
"""
logger = logging.getLogger('netbox.dcim.cable')
for cable in Cable.objects.filter(pk__in=pks):
cable._terminations_modified = True
trace_paths.send(Cable, instance=cable, created=True)
logger.debug(f"Retraced cable paths for Cable {cable.pk}")
@receiver((post_delete, post_save), sender=PortMapping)
def update_passthrough_port_paths(instance, **kwargs):
"""

View File

@@ -2,10 +2,3 @@ from django.dispatch import Signal
# Signals that a model has completed its clean() method
post_clean = Signal()
# Sent after objects of a given model are created via raw save.
# Expected call signature: post_raw_create.send(sender=MyModel, pks=[...])
# Provides: pks (list) - PKs of the newly created objects.
# Callers must ensure all related objects (e.g. M2M, dependent rows) are in place
# before sending, as receivers may query related data to perform post-create work.
post_raw_create = Signal()

View File

@@ -254,6 +254,7 @@ class APIViewTestCases:
action=ObjectChangeActionChoices.ACTION_CREATE,
)
self.assertEqual(objectchange.message, data['changelog_message'])
self.assertObjectChangeData(objectchange, prechange_is_none=True, postchange_is_none=False)
def test_bulk_create_objects(self):
"""
@@ -307,6 +308,7 @@ class APIViewTestCases:
self.assertEqual(len(objectchanges), len(self.create_data))
for oc in objectchanges:
self.assertEqual(oc.message, changelog_message)
self.assertObjectChangeData(oc, prechange_is_none=True, postchange_is_none=False)
class UpdateObjectViewTestCase(APITestCase):
update_data = {}
@@ -366,6 +368,8 @@ class APIViewTestCases:
)
self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.message, data['changelog_message'])
self.assertObjectChangeData(objectchange, prechange_is_none=False, postchange_is_none=False)
self.assertNotEqual(objectchange.prechange_data, objectchange.postchange_data)
def test_bulk_update_objects(self):
"""
@@ -416,6 +420,8 @@ class APIViewTestCases:
for oc in objectchanges:
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.message, changelog_message)
self.assertObjectChangeData(oc, prechange_is_none=False, postchange_is_none=False)
self.assertNotEqual(oc.prechange_data, oc.postchange_data)
class DeleteObjectViewTestCase(APITestCase):
@@ -464,6 +470,7 @@ class APIViewTestCases:
)
self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(objectchange.message, data['changelog_message'])
self.assertObjectChangeData(objectchange, prechange_is_none=False, postchange_is_none=True)
def test_bulk_delete_objects(self):
"""
@@ -505,6 +512,7 @@ class APIViewTestCases:
for oc in objectchanges:
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.message, changelog_message)
self.assertObjectChangeData(oc, prechange_is_none=False, postchange_is_none=True)
class GraphQLTestCase(APITestCase):

View File

@@ -83,6 +83,20 @@ class TestCase(_TestCase):
# Custom assertions
#
def assertObjectChangeData(self, objectchange, *, prechange_is_none: bool, postchange_is_none: bool):
"""
Assert that an ObjectChange record has the expected prechange_data and postchange_data.
Set prechange_is_none=True to assert the field is null, False to assert it is populated.
"""
if prechange_is_none:
self.assertIsNone(objectchange.prechange_data, "Expected prechange_data to be None")
else:
self.assertIsNotNone(objectchange.prechange_data, "Expected prechange_data to be populated")
if postchange_is_none:
self.assertIsNone(objectchange.postchange_data, "Expected postchange_data to be None")
else:
self.assertIsNotNone(objectchange.postchange_data, "Expected postchange_data to be populated")
def assertHttpStatus(self, response, expected_status):
"""
TestCase method. Provide more detail in the event of an unexpected HTTP response.

View File

@@ -194,6 +194,7 @@ class ViewTestCases:
self.assertEqual(len(objectchanges), 1)
self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(objectchanges[0].message, self.form_data['changelog_message'])
self.assertObjectChangeData(objectchanges[0], prechange_is_none=True, postchange_is_none=False)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_create_object_with_constrained_permission(self):
@@ -301,6 +302,8 @@ class ViewTestCases:
self.assertEqual(len(objectchanges), 1)
self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchanges[0].message, self.form_data['changelog_message'])
self.assertObjectChangeData(objectchanges[0], prechange_is_none=False, postchange_is_none=False)
self.assertNotEqual(objectchanges[0].prechange_data, objectchanges[0].postchange_data)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_edit_object_with_constrained_permission(self):
@@ -396,6 +399,7 @@ class ViewTestCases:
self.assertEqual(len(objectchanges), 1)
self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(objectchanges[0].message, form_data['changelog_message'])
self.assertObjectChangeData(objectchanges[0], prechange_is_none=False, postchange_is_none=True)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_delete_object_with_constrained_permission(self):
@@ -717,6 +721,7 @@ class ViewTestCases:
for oc in objectchanges:
self.assertEqual(oc.message, data['changelog_message'])
self.assertObjectChangeData(oc, prechange_is_none=True, postchange_is_none=False)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_bulk_update_objects_with_permission(self):
@@ -870,6 +875,8 @@ class ViewTestCases:
for oc in objectchanges:
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.message, data['changelog_message'])
self.assertObjectChangeData(oc, prechange_is_none=False, postchange_is_none=False)
self.assertNotEqual(oc.prechange_data, oc.postchange_data)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_edit_objects_with_constrained_permission(self):
@@ -966,6 +973,7 @@ class ViewTestCases:
for oc in objectchanges:
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.message, data['changelog_message'])
self.assertObjectChangeData(oc, prechange_is_none=False, postchange_is_none=True)
def test_bulk_delete_objects_with_constrained_permission(self):
pk_list = self._get_queryset().values_list('pk', flat=True)