mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-28 11:47:35 +02:00
Compare commits
5 Commits
21988-auth
...
update-aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f16392fe | ||
|
|
ff5f64abf8 | ||
|
|
f68645bbad | ||
|
|
d413b847ab | ||
|
|
aa14e1d322 |
2
.github/workflows/claude-code-review.yml
vendored
2
.github/workflows/claude-code-review.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
|
||||
2
.github/workflows/claude-issue-triage.yml
vendored
2
.github/workflows/claude-issue-triage.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
id: claude-triage
|
||||
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# Restrict Claude to read-only inspection of the repo plus posting a single comment
|
||||
# on THIS issue only. `gh issue comment` is pinned to the current issue number, so an
|
||||
# injection cannot redirect a comment to another issue. Close, label, reopen, assign,
|
||||
|
||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
|
||||
@@ -254,13 +254,37 @@ class CabledObjectModel(models.Model):
|
||||
|
||||
@cached_property
|
||||
def link_peers(self):
|
||||
if self.cable:
|
||||
return [
|
||||
peer.termination
|
||||
for peer in self.cable.terminations.all()
|
||||
if peer.cable_end != self.cable_end
|
||||
]
|
||||
return []
|
||||
if not self.cable:
|
||||
return []
|
||||
|
||||
if self.cable.profile:
|
||||
return self._get_profile_link_peers()
|
||||
|
||||
return [peer.termination for peer in self.cable.terminations.all() if peer.cable_end != self.cable_end]
|
||||
|
||||
def _get_profile_link_peers(self):
|
||||
if self.cable_end is None or self.cable_connector is None or not self.cable_positions:
|
||||
return []
|
||||
|
||||
profile = self.cable.profile_class()
|
||||
peer_terminations = {
|
||||
(peer.connector, position): peer.termination
|
||||
for peer in self.cable.terminations.all()
|
||||
if peer.cable_end == self.opposite_cable_end and peer.connector is not None
|
||||
for position in peer.positions or []
|
||||
}
|
||||
link_peers = []
|
||||
|
||||
for position in self.cable_positions:
|
||||
mapped_position = profile.get_mapped_position(self.cable_end, self.cable_connector, position)
|
||||
if mapped_position is None:
|
||||
continue
|
||||
|
||||
peer = peer_terminations.get(mapped_position)
|
||||
if peer is not None and peer not in link_peers:
|
||||
link_peers.append(peer)
|
||||
|
||||
return link_peers
|
||||
|
||||
@property
|
||||
def _occupied(self):
|
||||
|
||||
68
netbox/dcim/tests/test_cable_profiles.py
Normal file
68
netbox/dcim/tests/test_cable_profiles.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from django.test import tag
|
||||
|
||||
from dcim.choices import CableProfileChoices
|
||||
from dcim.models import Cable, Interface, RearPort
|
||||
from dcim.tests.utils import CablePathTestCase
|
||||
|
||||
|
||||
class CableProfileLinkPeerTests(CablePathTestCase):
|
||||
"""
|
||||
Tests for link peer resolution with cable profiles.
|
||||
"""
|
||||
|
||||
@tag('regression') # #21917
|
||||
def test_trunk_4c1p_link_peers(self):
|
||||
"""
|
||||
Link peers for trunk profile cables should honor connector mappings.
|
||||
"""
|
||||
interfaces = [Interface.objects.create(device=self.device, name=f'Interface {i}') for i in range(1, 5)]
|
||||
rear_ports = [
|
||||
RearPort.objects.create(device=self.device, name=f'Rear Port {i}', positions=1) for i in range(1, 5)
|
||||
]
|
||||
|
||||
cable = Cable(
|
||||
profile=CableProfileChoices.TRUNK_4C1P,
|
||||
a_terminations=interfaces,
|
||||
b_terminations=rear_ports,
|
||||
)
|
||||
cable.clean()
|
||||
cable.save()
|
||||
|
||||
for interface, rear_port in zip(interfaces, rear_ports):
|
||||
interface.refresh_from_db()
|
||||
rear_port.refresh_from_db()
|
||||
|
||||
self.assertEqual(interface.link_peers, [rear_port])
|
||||
self.assertEqual(rear_port.link_peers, [interface])
|
||||
|
||||
@tag('regression') # #21917
|
||||
def test_breakout_shuffle_link_peers(self):
|
||||
"""
|
||||
Link peers for asymmetric breakout profiles should honor mapped connectors.
|
||||
"""
|
||||
rear_ports = [
|
||||
RearPort.objects.create(device=self.device, name=f'Rear Port {i}', positions=4) for i in range(1, 3)
|
||||
]
|
||||
interfaces = [Interface.objects.create(device=self.device, name=f'Interface {i}') for i in range(1, 9)]
|
||||
|
||||
cable = Cable(
|
||||
profile=CableProfileChoices.BREAKOUT_2C4P_8C1P_SHUFFLE,
|
||||
a_terminations=rear_ports,
|
||||
b_terminations=interfaces,
|
||||
)
|
||||
cable.clean()
|
||||
cable.save()
|
||||
|
||||
for rear_port in rear_ports:
|
||||
rear_port.refresh_from_db()
|
||||
for interface in interfaces:
|
||||
interface.refresh_from_db()
|
||||
|
||||
self.assertEqual(rear_ports[0].link_peers, [interfaces[0], interfaces[1], interfaces[4], interfaces[5]])
|
||||
self.assertEqual(rear_ports[1].link_peers, [interfaces[2], interfaces[3], interfaces[6], interfaces[7]])
|
||||
|
||||
for interface in interfaces[0:2] + interfaces[4:6]:
|
||||
self.assertEqual(interface.link_peers, [rear_ports[0]])
|
||||
|
||||
for interface in interfaces[2:4] + interfaces[6:8]:
|
||||
self.assertEqual(interface.link_peers, [rear_ports[1]])
|
||||
@@ -181,9 +181,26 @@ def process_event_rules(event_rules, object_type, event):
|
||||
if not event_rule.eval_conditions(event['data']):
|
||||
continue
|
||||
|
||||
# Guard against action_data that is valid JSON but not a dict
|
||||
# (e.g. a bare string or number). Existing rows with bad data are
|
||||
# tolerated at runtime; validation on EventRule.clean() prevents
|
||||
# new ones.
|
||||
if event_rule.action_data is None:
|
||||
action_data = {}
|
||||
elif isinstance(event_rule.action_data, dict):
|
||||
action_data = event_rule.action_data
|
||||
else:
|
||||
logger.warning(
|
||||
_('Ignoring invalid action_data on event rule "{rule}" (got {data_type})').format(
|
||||
rule=event_rule,
|
||||
data_type=type(event_rule.action_data).__name__,
|
||||
)
|
||||
)
|
||||
action_data = {}
|
||||
|
||||
# Merge rule-specific action_data with the event payload.
|
||||
# Copy to avoid mutating the rule's stored action_data dict.
|
||||
event_data = {**(event_rule.action_data or {}), **event['data']}
|
||||
event_data = {**action_data, **event['data']}
|
||||
|
||||
# Webhooks
|
||||
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
|
||||
|
||||
@@ -143,6 +143,10 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, OwnerMixin, TagsMixin,
|
||||
except ValueError as e:
|
||||
raise ValidationError({'conditions': e})
|
||||
|
||||
# action_data must be a JSON object (or null)
|
||||
if self.action_data is not None and not isinstance(self.action_data, dict):
|
||||
raise ValidationError({'action_data': _('Action data must be a JSON object or null.')})
|
||||
|
||||
def eval_conditions(self, data):
|
||||
"""
|
||||
Test whether the given data meets the conditions of the event rule (if any). Return True
|
||||
|
||||
@@ -563,3 +563,30 @@ class EventRuleTest(APITestCase):
|
||||
job = self.queue.get_jobs()[0]
|
||||
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
|
||||
self.queue.empty()
|
||||
|
||||
def test_non_dict_action_data_does_not_crash_flush(self):
|
||||
"""
|
||||
Pre-existing non-dict action_data must not cause flush_events() to
|
||||
raise.
|
||||
"""
|
||||
site_type = ObjectType.objects.get_for_model(Site)
|
||||
webhook = Webhook.objects.get(name='Webhook 1')
|
||||
webhook_type = ObjectType.objects.get_for_model(Webhook)
|
||||
|
||||
bad_rule = EventRule.objects.create(
|
||||
name='Bad action_data rule',
|
||||
event_types=[OBJECT_CREATED],
|
||||
action_type=EventRuleActionChoices.WEBHOOK,
|
||||
action_object_type=webhook_type,
|
||||
action_object_id=webhook.pk,
|
||||
action_data={},
|
||||
)
|
||||
bad_rule.object_types.set([site_type])
|
||||
|
||||
# Simulate a legacy row that predates model validation.
|
||||
EventRule.objects.filter(pk=bad_rule.pk).update(action_data='not a dict')
|
||||
|
||||
url = reverse('dcim-api:site-list')
|
||||
self.add_permissions('dcim.add_site')
|
||||
response = self.client.post(url, {'name': 'Site X', 'slug': 'site-x'}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
|
||||
@@ -11,9 +11,18 @@ from django.forms import ValidationError
|
||||
from django.test import TestCase, tag
|
||||
from PIL import Image
|
||||
|
||||
from core.events import OBJECT_CREATED
|
||||
from core.models import AutoSyncRecord, DataSource, ObjectType
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
|
||||
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, ImageAttachment, Tag, TaggedItem
|
||||
from extras.models import (
|
||||
ConfigContext,
|
||||
ConfigContextProfile,
|
||||
ConfigTemplate,
|
||||
EventRule,
|
||||
ImageAttachment,
|
||||
Tag,
|
||||
TaggedItem,
|
||||
)
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.exceptions import AbortRequest
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
@@ -889,3 +898,24 @@ class ConfigTemplateTest(TestCase):
|
||||
object_id=config_template.pk
|
||||
)
|
||||
self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
|
||||
|
||||
|
||||
class EventRuleTest(TestCase):
|
||||
|
||||
def test_action_data_clean_accepts_dict(self):
|
||||
"""
|
||||
clean() should accept a JSON object (or null) as action_data.
|
||||
"""
|
||||
for value in ({'key': 'value'}, None):
|
||||
rule = EventRule(name='test', event_types=[OBJECT_CREATED], action_data=value)
|
||||
rule.clean()
|
||||
|
||||
def test_action_data_clean_rejects_non_dict(self):
|
||||
"""
|
||||
clean() should reject action_data that is valid JSON but not an object (#21989).
|
||||
"""
|
||||
for value in ('test', 42, [1, 2, 3], True):
|
||||
rule = EventRule(name='test', event_types=[OBJECT_CREATED], action_data=value)
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
rule.clean()
|
||||
self.assertIn('action_data', cm.exception.message_dict)
|
||||
|
||||
Reference in New Issue
Block a user