mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-11 14:22:16 -06:00
Compare commits
10 Commits
v4.4.9
...
7154d4ae2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7154d4ae2e | ||
|
|
da64c564ae | ||
|
|
6199b3e039 | ||
|
|
2a391253a5 | ||
|
|
914653d63e | ||
|
|
3813aad8b1 | ||
|
|
ea5371040e | ||
|
|
6c824cc48f | ||
|
|
f510e40428 | ||
|
|
860db9590b |
@@ -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/`.)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user