Compare commits

...

10 Commits

Author SHA1 Message Date
Prince Kumar
7154d4ae2e Closes #20953: Show interfaces bridged to an interface in the UI (#21010)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-05 09:40:38 -06:00
github-actions
da64c564ae Update source translation strings
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2026-01-01 05:07:03 +00:00
Jeremy Stretch
6199b3e039 FIxes #19506: Add filter forms for component templates (#21057)
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
Co-authored-by: Callum <callum@reja.au>
Co-authored-by: Callum <96725140+callumau@users.noreply.github.com>
2025-12-31 09:50:39 -06:00
github-actions
2a391253a5 Update source translation strings
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-31 05:05:09 +00:00
Jason Novinger
914653d63e Fixes #21045: Allow saving Site with associated Prefix
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
This was a result of the fix for #20944 optimizing a query to only
include the `id` field with `.only(id)`. Since `Prefix.__init__()`
caches original values from other fields (`_prefix` and `_vrf_id`),
these cached values are `None` at init-time.

This might not normally be a problem, but the sequence of events in
the bug report also end up causing the `handle_prefix_saved` handler
to run, which uses an ORM lookup, (either `net_contained_or_equal`
original`net_contained`) that does not support a query argument of
`None`.
2025-12-30 12:26:48 -05:00
Martin Hauser
3813aad8b1 Fixes #20320: Ensure related interface options availibility in bulk edit (#21006) 2025-12-30 10:17:14 -06:00
Jeremy Stretch
ea5371040e Fixes #20817: Re-enable sync button when disabling scheduled syncing for a data source (#21055) 2025-12-30 10:05:08 -06:00
Unknown
6c824cc48f Fixes #20044: Elevations stuck in light mode (#21037)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
Co-authored-by: UnknownTy <meaphunter+git@hotmail.com>
Co-authored-by: Jason Novinger <jnovinger@gmail.com>
2025-12-29 16:27:03 -06:00
Jeremy Stretch
f510e40428 Closes #21047: Add compatibility matrix to plugin setup instructions (#21048) 2025-12-29 11:39:51 -06:00
Prince Kumar
860db9590b Fixed #20950: Add missing module and device properties in module-bay (#21005)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2025-12-23 13:34:06 -06:00
14 changed files with 659 additions and 324 deletions

View File

@@ -74,7 +74,7 @@ The plugin source directory contains all the actual Python code and other resour
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
```python
```python title="__init__.py"
from netbox.plugins import PluginConfig
class FooBarConfig(PluginConfig):
@@ -151,7 +151,7 @@ Any additional apps must be installed within the same Python environment as NetB
An example `pyproject.toml` is below:
```
```toml title="pyproject.toml"
# See PEP 518 for the spec of this file
# https://www.python.org/dev/peps/pep-0518/
@@ -179,11 +179,24 @@ classifiers=[
]
requires-python = ">=3.10.0"
```
Many of these are self-explanatory, but for more information, see the [pyproject.toml documentation](https://packaging.python.org/en/latest/specifications/pyproject-toml/).
## Compatibility Matrix
Consider adding a file named `COMPATIBILITY.md` to your plugin project root (alongside `pyproject.toml`). This file should contain a table listing the minimum and maximum supported versions of NetBox (`min_version` and `max_version`) for each release. This serves as a handy reference for users who are upgrading from a previous version of your plugin. An example is shown below:
```markdown title="COMPATIBILITY.md"
# Compatibility Matrix
| Release | Minimum NetBox Version | Maximum NetBox Version |
|---------|------------------------|------------------------|
| 0.2.0 | 4.4.0 | 4.5.x |
| 0.1.1 | 4.3.0 | 4.4.x |
| 0.1.0 | 4.3.0 | 4.4.x |
```
## Create a Virtual Environment
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) for the development of your plugin, as opposed to using system-wide packages. This will afford you complete control over the installed versions of all dependencies and avoid conflict with system packages. This environment can live wherever you'd like;however, it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)

View File

@@ -131,6 +131,19 @@ class DataSource(JobsMixin, PrimaryModel):
'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
})
def save(self, *args, **kwargs):
# If recurring sync is disabled for an existing DataSource, clear any pending sync jobs for it and reset its
# "queued" status
if not self._state.adding and not self.sync_interval:
self.jobs.filter(status=JobStatusChoices.STATUS_PENDING).delete()
if self.status == DataSourceStatusChoices.QUEUED and self.last_synced:
self.status = DataSourceStatusChoices.COMPLETED
elif self.status == DataSourceStatusChoices.QUEUED:
self.status = DataSourceStatusChoices.NEW
super().save(*args, **kwargs)
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)

View File

@@ -1,5 +1,5 @@
from django.utils.translation import gettext as _
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
@@ -183,6 +183,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
type = ChoiceField(choices=InterfaceTypeChoices)
parent = NestedInterfaceSerializer(required=False, allow_null=True)
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
bridge_interfaces = NestedInterfaceSerializer(many=True, read_only=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
@@ -222,13 +223,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
model = Interface
fields = [
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'primary_mac_address', 'mac_addresses', 'speed', 'duplex',
'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end',
'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
@@ -350,14 +351,14 @@ class ModuleBaySerializer(NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
fields=('id', 'url', 'display'),
fields=('id', 'url', 'display', 'device', 'module_bay'),
required=False,
allow_null=True,
default=None
)
installed_module = ModuleSerializer(
nested=True,
fields=('id', 'url', 'display', 'serial', 'description'),
fields=('id', 'url', 'display', 'device', 'module_bay', 'serial', 'description'),
required=False,
allow_null=True
)

View File

@@ -24,35 +24,45 @@ __all__ = (
'CableFilterForm',
'ConsoleConnectionFilterForm',
'ConsolePortFilterForm',
'ConsolePortTemplateFilterForm',
'ConsoleServerPortFilterForm',
'ConsoleServerPortTemplateFilterForm',
'DeviceBayFilterForm',
'DeviceBayTemplateFilterForm',
'DeviceFilterForm',
'DeviceRoleFilterForm',
'DeviceTypeFilterForm',
'FrontPortFilterForm',
'FrontPortTemplateFilterForm',
'InterfaceConnectionFilterForm',
'InterfaceFilterForm',
'InterfaceTemplateFilterForm',
'InventoryItemFilterForm',
'InventoryItemTemplateFilterForm',
'InventoryItemRoleFilterForm',
'LocationFilterForm',
'MACAddressFilterForm',
'ManufacturerFilterForm',
'ModuleFilterForm',
'ModuleBayFilterForm',
'ModuleBayTemplateFilterForm',
'ModuleTypeFilterForm',
'ModuleTypeProfileFilterForm',
'PlatformFilterForm',
'PowerConnectionFilterForm',
'PowerFeedFilterForm',
'PowerOutletFilterForm',
'PowerOutletTemplateFilterForm',
'PowerPanelFilterForm',
'PowerPortFilterForm',
'PowerPortTemplateFilterForm',
'RackFilterForm',
'RackElevationFilterForm',
'RackReservationFilterForm',
'RackRoleFilterForm',
'RackTypeFilterForm',
'RearPortFilterForm',
'RearPortTemplateFilterForm',
'RegionFilterForm',
'SiteFilterForm',
'SiteGroupFilterForm',
@@ -1290,6 +1300,23 @@ class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
# Device components
#
class DeviceComponentTemplateFilterForm(NetBoxModelFilterSetForm):
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device type'),
)
class ModularDeviceComponentTemplateFilterForm(DeviceComponentTemplateFilterForm):
module_type_id = DynamicModelMultipleChoiceField(
queryset=ModuleType.objects.all(),
required=False,
query_params={'manufacturer_id': '$manufacturer_id'},
label=_('Module Type'),
)
class CabledFilterForm(forms.Form):
cabled = forms.NullBooleanField(
label=_('Cabled'),
@@ -1342,6 +1369,20 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model)
class ConsolePortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = ConsolePortTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=ConsolePortTypeChoices,
required=False
)
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsoleServerPort
fieldsets = (
@@ -1367,6 +1408,20 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
tag = TagFilterField(model)
class ConsoleServerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = ConsoleServerPortTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=ConsolePortTypeChoices,
required=False
)
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerPort
fieldsets = (
@@ -1387,6 +1442,20 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model)
class PowerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = PowerPortTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=PowerPortTypeChoices,
required=False
)
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet
fieldsets = (
@@ -1416,6 +1485,20 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
)
class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = PowerOutletTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=PowerOutletTypeChoices,
required=False
)
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface
fieldsets = (
@@ -1543,6 +1626,51 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model)
class InterfaceTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = InterfaceTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'enabled', 'mgmt_only', name=_('Attributes')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('rf_role', name=_('Wireless')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=InterfaceTypeChoices,
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
mgmt_only = forms.NullBooleanField(
label=_('Management only'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
poe_mode = forms.MultipleChoiceField(
choices=InterfacePoEModeChoices,
required=False,
label=_('PoE mode')
)
poe_type = forms.MultipleChoiceField(
choices=InterfacePoETypeChoices,
required=False,
label=_('PoE type')
)
rf_role = forms.MultipleChoiceField(
choices=WirelessRoleChoices,
required=False,
label=_('Wireless role')
)
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
@@ -1567,6 +1695,24 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model)
class FrontPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = FrontPortTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=PortTypeChoices,
required=False
)
color = ColorField(
label=_('Color'),
required=False
)
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
model = RearPort
fieldsets = (
@@ -1591,6 +1737,24 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model)
class RearPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = RearPortTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=PortTypeChoices,
required=False
)
color = ColorField(
label=_('Color'),
required=False
)
class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay
fieldsets = (
@@ -1609,6 +1773,19 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
)
class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = ModuleBayTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'position', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
position = forms.CharField(
label=_('Position'),
required=False,
)
class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay
fieldsets = (
@@ -1623,6 +1800,15 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
model = DeviceBayTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', name=_('Attributes')),
FieldSet('device_type_id', name=_('Device')),
)
class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem
fieldsets = (
@@ -1670,6 +1856,25 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
model = InventoryItemTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'role_id', 'manufacturer_id', name=_('Attributes')),
FieldSet('device_type_id', name=_('Device')),
)
role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(),
required=False,
label=_('Role')
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_('Manufacturer')
)
#
# Device component roles
#

View File

@@ -210,7 +210,7 @@ def sync_cached_scope_fields(instance, created, **kwargs):
for model in (Prefix, Cluster, WirelessLAN):
qs = model.objects.filter(**filters)
for obj in qs.only('id'):
for obj in qs:
# Recompute cache using the same logic as save()
obj.cache_related_objects()
obj.save(update_fields=[

View File

@@ -6,6 +6,7 @@ from core.models import ObjectType
from dcim.choices import *
from dcim.models import *
from extras.models import CustomField
from ipam.models import Prefix
from netbox.choices import WeightUnitChoices
from tenancy.models import Tenant
from utilities.data import drange
@@ -1192,3 +1193,14 @@ class VirtualChassisTestCase(TestCase):
device2.vc_position = 1
with self.assertRaises(ValidationError):
device2.full_clean()
class SiteSignalTestCase(TestCase):
@tag('regression')
def test_edit_site_with_prefix_no_vrf(self):
site = Site.objects.create(name='Test Site', slug='test-site')
Prefix.objects.create(prefix='192.0.2.0/24', scope=site, vrf=None)
# Regression test for #21045: should not raise ValueError
site.save()

View File

@@ -13,7 +13,7 @@ from django.views.generic import View
from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.object_actions import *
from netbox.views import generic
@@ -2900,6 +2900,7 @@ class InterfaceView(generic.ObjectView):
return {
'vdc_table': vdc_table,
'bridge_interfaces': bridge_interfaces,
'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_table,
'vlan_table': vlan_table,

View File

@@ -1,3 +1,4 @@
from django.db.models import ForeignKey
from django.template import loader
from django.urls.exceptions import NoReverseMatch
from django.utils.translation import gettext_lazy as _
@@ -175,6 +176,21 @@ class BulkEdit(ObjectAction):
permissions_required = {'change'}
template_name = 'buttons/bulk_edit.html'
@classmethod
def get_context(cls, context, model):
url_params = super().get_url_params(context)
# If this is a child object, pass the parent's PK as a URL parameter
if parent := context.get('object'):
for field in model._meta.get_fields():
if isinstance(field, ForeignKey) and field.remote_field.model == parent.__class__:
url_params[field.name] = parent.pk
break
return {
'url_params': url_params,
}
class BulkRename(ObjectAction):
"""

View File

@@ -1,11 +1,10 @@
from unittest import skipIf
from django.conf import settings
from django.test import TestCase
from django.test import RequestFactory, TestCase
from dcim.models import Device
from netbox.object_actions import AddObject, BulkImport
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
from dcim.models import Device, DeviceType, Manufacturer
from netbox.object_actions import AddObject, BulkEdit, BulkImport
class ObjectActionTest(TestCase):
@@ -20,9 +19,11 @@ class ObjectActionTest(TestCase):
url = BulkImport.get_url(obj)
self.assertEqual(url, '/dcim/devices/import/')
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
def test_get_url_plugin_model(self):
"""Test URL generation for plugin models includes plugins: namespace"""
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
obj = DummyNetBoxModel()
url = AddObject.get_url(obj)
@@ -30,3 +31,29 @@ class ObjectActionTest(TestCase):
url = BulkImport.get_url(obj)
self.assertEqual(url, '/plugins/dummy-plugin/netboxmodel/import/')
def test_bulk_edit_get_context_child_object(self):
"""
Test that the parent object's PK is included in the context for child objects.
Ensure that BulkEdit.get_context() correctly identifies and
includes the parent object's PK when rendering a child object's
action button.
"""
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
# Mock context containing the parent object (DeviceType)
request = RequestFactory().get('/')
context = {
'request': request,
'object': device_type,
}
# Get context for the child model (Device)
action_context = BulkEdit.get_context(context, Device)
# Verify that 'device_type' (the FK field name) is present in
# url_params with the parent's PK
self.assertIn('url_params', action_context)
self.assertEqual(action_context['url_params'].get('device_type'), device_type.pk)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -28,13 +28,27 @@ function updateElements(targetMode: ColorMode): void {
}
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const svg = elevation.contentDocument?.querySelector('svg') ?? null;
if (svg !== null) {
const svg = elevation.firstElementChild ?? null;
if (svg !== null && svg.nodeName == 'svg') {
svg.setAttribute(`data-bs-theme`, targetMode);
}
}
}
/**
* Set the color mode to light of elevations after an htmx call.
* Pulls current color mode from document
*
* @param event htmx listener event details. See: https://htmx.org/events/#htmx:afterSwap
*/
function updateElevations(evt: CustomEvent, ): void {
const swappedElement = evt.detail.elt
if (swappedElement.nodeName == 'svg') {
const currentMode = localStorage.getItem(COLOR_MODE_KEY);
swappedElement.setAttribute('data-bs-theme', currentMode)
}
}
/**
* Call all functions necessary to update the color mode across the UI.
*
@@ -115,6 +129,7 @@ function initColorModeToggle(): void {
*/
export function initColorMode(): void {
window.addEventListener('load', defaultColorMode);
window.addEventListener('htmx:afterSwap', updateElevations as EventListener); // Uses a custom event from HTMX
for (const func of [initColorModeToggle]) {
func();
}

View File

@@ -112,6 +112,19 @@
<th scope="row">{% trans "Bridge" %}</th>
<td>{{ object.bridge|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Bridged Interfaces" %}</th>
<td>
{% if bridge_interfaces %}
{% for interface in bridge_interfaces %}
{{ interface|linkify }}
{% if not forloop.last %}<br />{% endif %}
{% endfor %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "LAG" %}</th>
<td>{{ object.lag|linkify|placeholder }}</td>
@@ -435,13 +448,11 @@
</div>
</div>
{% endif %}
{% if object.is_bridge %}
<div class="row mb-3">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridge Interfaces" %}
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridged Interfaces" %}
</div>
{% endif %}
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}

File diff suppressed because it is too large Load Diff