mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-15 08:12:18 -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:
|
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
|
from netbox.plugins import PluginConfig
|
||||||
|
|
||||||
class FooBarConfig(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:
|
An example `pyproject.toml` is below:
|
||||||
|
|
||||||
```
|
```toml title="pyproject.toml"
|
||||||
# See PEP 518 for the spec of this file
|
# See PEP 518 for the spec of this file
|
||||||
# https://www.python.org/dev/peps/pep-0518/
|
# https://www.python.org/dev/peps/pep-0518/
|
||||||
|
|
||||||
@@ -179,11 +179,24 @@ classifiers=[
|
|||||||
]
|
]
|
||||||
|
|
||||||
requires-python = ">=3.10.0"
|
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/).
|
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
|
## 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/`.)
|
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)"
|
'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):
|
def to_objectchange(self, action):
|
||||||
objectchange = super().to_objectchange(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.contrib.contenttypes.models import ContentType
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
@@ -183,6 +183,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
|||||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||||
parent = NestedInterfaceSerializer(required=False, allow_null=True)
|
parent = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||||
bridge = 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)
|
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||||
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
|
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
|
||||||
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
|
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
|
||||||
@@ -222,13 +223,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
|||||||
model = Interface
|
model = Interface
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
|
'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',
|
'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
|
||||||
'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type',
|
'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
|
||||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
|
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
|
||||||
'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
|
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end',
|
||||||
'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
|
'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination',
|
||||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
|
||||||
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||||
|
|
||||||
@@ -350,14 +351,14 @@ class ModuleBaySerializer(NetBoxModelSerializer):
|
|||||||
device = DeviceSerializer(nested=True)
|
device = DeviceSerializer(nested=True)
|
||||||
module = ModuleSerializer(
|
module = ModuleSerializer(
|
||||||
nested=True,
|
nested=True,
|
||||||
fields=('id', 'url', 'display'),
|
fields=('id', 'url', 'display', 'device', 'module_bay'),
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
default=None
|
default=None
|
||||||
)
|
)
|
||||||
installed_module = ModuleSerializer(
|
installed_module = ModuleSerializer(
|
||||||
nested=True,
|
nested=True,
|
||||||
fields=('id', 'url', 'display', 'serial', 'description'),
|
fields=('id', 'url', 'display', 'device', 'module_bay', 'serial', 'description'),
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True
|
allow_null=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,35 +24,45 @@ __all__ = (
|
|||||||
'CableFilterForm',
|
'CableFilterForm',
|
||||||
'ConsoleConnectionFilterForm',
|
'ConsoleConnectionFilterForm',
|
||||||
'ConsolePortFilterForm',
|
'ConsolePortFilterForm',
|
||||||
|
'ConsolePortTemplateFilterForm',
|
||||||
'ConsoleServerPortFilterForm',
|
'ConsoleServerPortFilterForm',
|
||||||
|
'ConsoleServerPortTemplateFilterForm',
|
||||||
'DeviceBayFilterForm',
|
'DeviceBayFilterForm',
|
||||||
|
'DeviceBayTemplateFilterForm',
|
||||||
'DeviceFilterForm',
|
'DeviceFilterForm',
|
||||||
'DeviceRoleFilterForm',
|
'DeviceRoleFilterForm',
|
||||||
'DeviceTypeFilterForm',
|
'DeviceTypeFilterForm',
|
||||||
'FrontPortFilterForm',
|
'FrontPortFilterForm',
|
||||||
|
'FrontPortTemplateFilterForm',
|
||||||
'InterfaceConnectionFilterForm',
|
'InterfaceConnectionFilterForm',
|
||||||
'InterfaceFilterForm',
|
'InterfaceFilterForm',
|
||||||
|
'InterfaceTemplateFilterForm',
|
||||||
'InventoryItemFilterForm',
|
'InventoryItemFilterForm',
|
||||||
|
'InventoryItemTemplateFilterForm',
|
||||||
'InventoryItemRoleFilterForm',
|
'InventoryItemRoleFilterForm',
|
||||||
'LocationFilterForm',
|
'LocationFilterForm',
|
||||||
'MACAddressFilterForm',
|
'MACAddressFilterForm',
|
||||||
'ManufacturerFilterForm',
|
'ManufacturerFilterForm',
|
||||||
'ModuleFilterForm',
|
'ModuleFilterForm',
|
||||||
'ModuleBayFilterForm',
|
'ModuleBayFilterForm',
|
||||||
|
'ModuleBayTemplateFilterForm',
|
||||||
'ModuleTypeFilterForm',
|
'ModuleTypeFilterForm',
|
||||||
'ModuleTypeProfileFilterForm',
|
'ModuleTypeProfileFilterForm',
|
||||||
'PlatformFilterForm',
|
'PlatformFilterForm',
|
||||||
'PowerConnectionFilterForm',
|
'PowerConnectionFilterForm',
|
||||||
'PowerFeedFilterForm',
|
'PowerFeedFilterForm',
|
||||||
'PowerOutletFilterForm',
|
'PowerOutletFilterForm',
|
||||||
|
'PowerOutletTemplateFilterForm',
|
||||||
'PowerPanelFilterForm',
|
'PowerPanelFilterForm',
|
||||||
'PowerPortFilterForm',
|
'PowerPortFilterForm',
|
||||||
|
'PowerPortTemplateFilterForm',
|
||||||
'RackFilterForm',
|
'RackFilterForm',
|
||||||
'RackElevationFilterForm',
|
'RackElevationFilterForm',
|
||||||
'RackReservationFilterForm',
|
'RackReservationFilterForm',
|
||||||
'RackRoleFilterForm',
|
'RackRoleFilterForm',
|
||||||
'RackTypeFilterForm',
|
'RackTypeFilterForm',
|
||||||
'RearPortFilterForm',
|
'RearPortFilterForm',
|
||||||
|
'RearPortTemplateFilterForm',
|
||||||
'RegionFilterForm',
|
'RegionFilterForm',
|
||||||
'SiteFilterForm',
|
'SiteFilterForm',
|
||||||
'SiteGroupFilterForm',
|
'SiteGroupFilterForm',
|
||||||
@@ -1290,6 +1300,23 @@ class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
# Device components
|
# 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):
|
class CabledFilterForm(forms.Form):
|
||||||
cabled = forms.NullBooleanField(
|
cabled = forms.NullBooleanField(
|
||||||
label=_('Cabled'),
|
label=_('Cabled'),
|
||||||
@@ -1342,6 +1369,20 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
tag = TagFilterField(model)
|
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):
|
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||||
model = ConsoleServerPort
|
model = ConsoleServerPort
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@@ -1367,6 +1408,20 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
|||||||
tag = TagFilterField(model)
|
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):
|
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||||
model = PowerPort
|
model = PowerPort
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@@ -1387,6 +1442,20 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
tag = TagFilterField(model)
|
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):
|
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||||
model = PowerOutlet
|
model = PowerOutlet
|
||||||
fieldsets = (
|
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):
|
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||||
model = Interface
|
model = Interface
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@@ -1543,6 +1626,51 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
tag = TagFilterField(model)
|
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):
|
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
@@ -1567,6 +1695,24 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
|||||||
tag = TagFilterField(model)
|
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):
|
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||||
model = RearPort
|
model = RearPort
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@@ -1591,6 +1737,24 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
|||||||
tag = TagFilterField(model)
|
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):
|
class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||||
model = ModuleBay
|
model = ModuleBay
|
||||||
fieldsets = (
|
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):
|
class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||||
model = DeviceBay
|
model = DeviceBay
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@@ -1623,6 +1800,15 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
|||||||
tag = TagFilterField(model)
|
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):
|
class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||||
model = InventoryItem
|
model = InventoryItem
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@@ -1670,6 +1856,25 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
|||||||
tag = TagFilterField(model)
|
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
|
# Device component roles
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ def sync_cached_scope_fields(instance, created, **kwargs):
|
|||||||
for model in (Prefix, Cluster, WirelessLAN):
|
for model in (Prefix, Cluster, WirelessLAN):
|
||||||
qs = model.objects.filter(**filters)
|
qs = model.objects.filter(**filters)
|
||||||
|
|
||||||
for obj in qs.only('id'):
|
for obj in qs:
|
||||||
# Recompute cache using the same logic as save()
|
# Recompute cache using the same logic as save()
|
||||||
obj.cache_related_objects()
|
obj.cache_related_objects()
|
||||||
obj.save(update_fields=[
|
obj.save(update_fields=[
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from core.models import ObjectType
|
|||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from extras.models import CustomField
|
from extras.models import CustomField
|
||||||
|
from ipam.models import Prefix
|
||||||
from netbox.choices import WeightUnitChoices
|
from netbox.choices import WeightUnitChoices
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.data import drange
|
from utilities.data import drange
|
||||||
@@ -1192,3 +1193,14 @@ class VirtualChassisTestCase(TestCase):
|
|||||||
device2.vc_position = 1
|
device2.vc_position = 1
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
device2.full_clean()
|
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 circuits.models import Circuit, CircuitTermination
|
||||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
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 ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||||
from netbox.object_actions import *
|
from netbox.object_actions import *
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
@@ -2900,6 +2900,7 @@ class InterfaceView(generic.ObjectView):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'vdc_table': vdc_table,
|
'vdc_table': vdc_table,
|
||||||
|
'bridge_interfaces': bridge_interfaces,
|
||||||
'bridge_interfaces_table': bridge_interfaces_table,
|
'bridge_interfaces_table': bridge_interfaces_table,
|
||||||
'child_interfaces_table': child_interfaces_table,
|
'child_interfaces_table': child_interfaces_table,
|
||||||
'vlan_table': vlan_table,
|
'vlan_table': vlan_table,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.db.models import ForeignKey
|
||||||
from django.template import loader
|
from django.template import loader
|
||||||
from django.urls.exceptions import NoReverseMatch
|
from django.urls.exceptions import NoReverseMatch
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -175,6 +176,21 @@ class BulkEdit(ObjectAction):
|
|||||||
permissions_required = {'change'}
|
permissions_required = {'change'}
|
||||||
template_name = 'buttons/bulk_edit.html'
|
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):
|
class BulkRename(ObjectAction):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from unittest import skipIf
|
from unittest import skipIf
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
from dcim.models import Device
|
from dcim.models import Device, DeviceType, Manufacturer
|
||||||
from netbox.object_actions import AddObject, BulkImport
|
from netbox.object_actions import AddObject, BulkEdit, BulkImport
|
||||||
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectActionTest(TestCase):
|
class ObjectActionTest(TestCase):
|
||||||
@@ -20,9 +19,11 @@ class ObjectActionTest(TestCase):
|
|||||||
url = BulkImport.get_url(obj)
|
url = BulkImport.get_url(obj)
|
||||||
self.assertEqual(url, '/dcim/devices/import/')
|
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):
|
def test_get_url_plugin_model(self):
|
||||||
"""Test URL generation for plugin models includes plugins: namespace"""
|
"""Test URL generation for plugin models includes plugins: namespace"""
|
||||||
|
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
|
||||||
|
|
||||||
obj = DummyNetBoxModel()
|
obj = DummyNetBoxModel()
|
||||||
|
|
||||||
url = AddObject.get_url(obj)
|
url = AddObject.get_url(obj)
|
||||||
@@ -30,3 +31,29 @@ class ObjectActionTest(TestCase):
|
|||||||
|
|
||||||
url = BulkImport.get_url(obj)
|
url = BulkImport.get_url(obj)
|
||||||
self.assertEqual(url, '/plugins/dummy-plugin/netboxmodel/import/')
|
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')) {
|
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
|
||||||
const svg = elevation.contentDocument?.querySelector('svg') ?? null;
|
const svg = elevation.firstElementChild ?? null;
|
||||||
if (svg !== null) {
|
if (svg !== null && svg.nodeName == 'svg') {
|
||||||
svg.setAttribute(`data-bs-theme`, targetMode);
|
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.
|
* Call all functions necessary to update the color mode across the UI.
|
||||||
*
|
*
|
||||||
@@ -115,6 +129,7 @@ function initColorModeToggle(): void {
|
|||||||
*/
|
*/
|
||||||
export function initColorMode(): void {
|
export function initColorMode(): void {
|
||||||
window.addEventListener('load', defaultColorMode);
|
window.addEventListener('load', defaultColorMode);
|
||||||
|
window.addEventListener('htmx:afterSwap', updateElevations as EventListener); // Uses a custom event from HTMX
|
||||||
for (const func of [initColorModeToggle]) {
|
for (const func of [initColorModeToggle]) {
|
||||||
func();
|
func();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,19 @@
|
|||||||
<th scope="row">{% trans "Bridge" %}</th>
|
<th scope="row">{% trans "Bridge" %}</th>
|
||||||
<td>{{ object.bridge|linkify|placeholder }}</td>
|
<td>{{ object.bridge|linkify|placeholder }}</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<th scope="row">{% trans "LAG" %}</th>
|
<th scope="row">{% trans "LAG" %}</th>
|
||||||
<td>{{ object.lag|linkify|placeholder }}</td>
|
<td>{{ object.lag|linkify|placeholder }}</td>
|
||||||
@@ -435,13 +448,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if object.is_bridge %}
|
<div class="row mb-3">
|
||||||
<div class="row mb-3">
|
<div class="col col-md-12">
|
||||||
<div class="col col-md-12">
|
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridged Interfaces" %}
|
||||||
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridge Interfaces" %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
{% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
|
{% 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