mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-21 02:58:43 -06:00
Compare commits
22 Commits
fix_module
...
20911-drop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4c3518bb4 | ||
|
|
5a1282e326 | ||
|
|
cb13eb277f | ||
|
|
434334d927 | ||
|
|
6bd083b7ed | ||
|
|
f38faf2e01 | ||
|
|
e60807adc5 | ||
|
|
e14934e5a5 | ||
|
|
ae03723e43 | ||
|
|
c0f79df91f | ||
|
|
edbfd0bae6 | ||
|
|
c3e111c769 | ||
|
|
c11f4b3716 | ||
|
|
24642be351 | ||
|
|
89af9efd85 | ||
|
|
99d678502f | ||
|
|
e6300ee06d | ||
|
|
3624b88c3f | ||
|
|
f54ed8bb7f | ||
|
|
5d0609e729 | ||
|
|
865b88e724 | ||
|
|
e73db97d46 |
16
.github/ISSUE_TEMPLATE/06-deprecation.yaml
vendored
16
.github/ISSUE_TEMPLATE/06-deprecation.yaml
vendored
@@ -1,20 +1,26 @@
|
|||||||
---
|
---
|
||||||
name: 🗑️ Deprecation
|
name: ⚠️ Deprecation
|
||||||
type: Deprecation
|
type: Deprecation
|
||||||
description: The removal of an existing feature or resource
|
description: Designation of a feature or behavior that will be removed in a future release
|
||||||
labels: ["netbox", "type: deprecation"]
|
labels: ["netbox", "type: deprecation"]
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Proposed Changes
|
label: Deprecated Functionality
|
||||||
description: >
|
description: >
|
||||||
Describe in detail the proposed changes. What is being removed?
|
Describe the feature(s) and/or behavior that is being flagged for deprecation.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Scheduled removal
|
||||||
|
description: In what future release will the deprecated functionality be removed?
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Justification
|
label: Justification
|
||||||
description: Please provide justification for the proposed change(s).
|
description: Please provide justification for the deprecation.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
20
.github/ISSUE_TEMPLATE/07-feature_removal.yaml
vendored
Normal file
20
.github/ISSUE_TEMPLATE/07-feature_removal.yaml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: 🗑️ Feature Removal
|
||||||
|
type: Removal
|
||||||
|
description: The removal of a deprecated feature or resource
|
||||||
|
labels: ["netbox", "type: removal"]
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Deprecation Issue
|
||||||
|
description: Specify the issue in which this deprecation was announced.
|
||||||
|
placeholder: "#1234"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Summary of Changes
|
||||||
|
description: >
|
||||||
|
List all changes necessary to remove the deprecated feature or resource.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.12
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: sudo apt install -y gettext
|
run: sudo apt install -y gettext
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,7 +9,8 @@ yarn-error.log*
|
|||||||
/netbox/netbox/configuration.py
|
/netbox/netbox/configuration.py
|
||||||
/netbox/netbox/ldap_config.py
|
/netbox/netbox/ldap_config.py
|
||||||
/netbox/local/*
|
/netbox/local/*
|
||||||
/netbox/media
|
/netbox/media/*
|
||||||
|
!/netbox/media/.gitkeep
|
||||||
/netbox/reports/*
|
/netbox/reports/*
|
||||||
!/netbox/reports/__init__.py
|
!/netbox/reports/__init__.py
|
||||||
/netbox/scripts/*
|
/netbox/scripts/*
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ Change records are exposed in the API via the read-only endpoint `/api/extras/ob
|
|||||||
|
|
||||||
## User Messages
|
## User Messages
|
||||||
|
|
||||||
!!! info "This feature was introduced in NetBox v4.4."
|
When creating, modifying, or deleting an object in NetBox, a user has the option of recording an arbitrary message (up to 200 characters) that will appear in the change record. This can be helpful to capture additional context, such as the reason for a change or a reference to an external ticket.
|
||||||
|
|
||||||
When creating, modifying, or deleting an object in NetBox, a user has the option of recording an arbitrary message that will appear in the change record. This can be helpful to capture additional context, such as the reason for the change.
|
When editing an object via the web UI, the "Changelog message" field appears at the bottom of the form. This field is optional. The changelog message field is available in object create forms, object edit forms, delete confirmation dialogs, and bulk operations.
|
||||||
|
|
||||||
|
For information on including changelog messages when making changes via the REST API, see [Changelog Messages](../integrations/rest-api.md#changelog-messages).
|
||||||
|
|
||||||
## Correlating Changes by Request
|
## Correlating Changes by Request
|
||||||
|
|
||||||
|
|||||||
@@ -610,9 +610,7 @@ http://netbox/api/dcim/sites/ \
|
|||||||
|
|
||||||
## Changelog Messages
|
## Changelog Messages
|
||||||
|
|
||||||
!!! info "This feature was introduced in NetBox v4.4."
|
Most objects in NetBox support [change logging](../features/change-logging.md), which generates a detailed record each time an object is created, modified, or deleted. Additionally, users can attach a message to the change record as well. This is accomplished via the REST API by including a `changelog_message` field in the object representation.
|
||||||
|
|
||||||
Most objects in NetBox support [change logging](../features/change-logging.md), which generates a detailed record each time an object is created, modified, or deleted. Beginning in NetBox v4.4, users can attach a message to the change record as well. This is accomplished via the REST API by including a `changelog_message` field in the object representation.
|
|
||||||
|
|
||||||
For example, the following API request will create a new site and record a message in the resulting changelog entry:
|
For example, the following API request will create a new site and record a message in the resulting changelog entry:
|
||||||
|
|
||||||
@@ -628,7 +626,7 @@ http://netbox/api/dcim/sites/ \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
This approach works when creating, modifying, or deleting objects, either individually or in bulk.
|
This approach works when creating, modifying, or deleting objects, either individually or in bulk. For more information about change logging, see [Change Logging](../features/change-logging.md).
|
||||||
|
|
||||||
## Uploading Files
|
## Uploading Files
|
||||||
|
|
||||||
|
|||||||
@@ -16,33 +16,9 @@ Note that device bays and module bays may _not_ be added to modules.
|
|||||||
|
|
||||||
## Automatic Component Renaming
|
## Automatic Component Renaming
|
||||||
|
|
||||||
When adding component templates to a module type, placeholders can be used to dynamically incorporate the module bay's `position` field into component names. Two placeholders are available:
|
When adding component templates to a module type, the string `{module}` can be used to reference the `position` field of the module bay into which an instance of the module type is being installed.
|
||||||
|
|
||||||
### `{module}` Placeholder
|
For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`.
|
||||||
|
|
||||||
The `{module}` placeholder references the position of the parent module bay:
|
|
||||||
|
|
||||||
* **Single use**: Expands to the immediate parent's position only
|
|
||||||
* **Multiple uses**: Each `{module}` token is replaced level-by-level (the number of tokens must match the nesting depth)
|
|
||||||
|
|
||||||
For example, a module type with interface templates named `Gi{module}/0/[1-48]`, when installed in a module bay with position "3", will create interfaces named `Gi3/0/[1-48]`.
|
|
||||||
|
|
||||||
### `{module_path}` Placeholder
|
|
||||||
|
|
||||||
The `{module_path}` placeholder expands to the full path from the root device to the current module, with positions joined by `/`. This is useful for modules that can be installed at any nesting depth without modification.
|
|
||||||
|
|
||||||
For example, consider an SFP module type with an interface template named `eth{module_path}`:
|
|
||||||
|
|
||||||
* Installed directly in slot 2: creates interface `eth2`
|
|
||||||
* Installed in slot 1's nested bay 1: creates interface `eth1/1`
|
|
||||||
* Installed in slot 1's nested bay 2's sub-bay 3: creates interface `eth1/2/3`
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
`{module_path}` can only be used once per template attribute, and cannot be mixed with `{module}` in the same attribute.
|
|
||||||
|
|
||||||
### Position Field Resolution
|
|
||||||
|
|
||||||
The `{module}` placeholder can also be used in the `position` field of [module bay templates](./modulebaytemplate.md) defined on a module type. This allows nested module bays to build hierarchical position values. For example, a module bay template with `position="{module}/1"`, when its parent module is installed in a bay with position "2", will have its position resolved to "2/1".
|
|
||||||
|
|
||||||
Automatic renaming is supported for all modular component types (those listed above).
|
Automatic renaming is supported for all modular component types (those listed above).
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
|||||||
#
|
#
|
||||||
|
|
||||||
MODULE_TOKEN = '{module}'
|
MODULE_TOKEN = '{module}'
|
||||||
MODULE_PATH_TOKEN = '{module_path}'
|
|
||||||
MODULE_TOKEN_SEPARATOR = '/'
|
|
||||||
|
|
||||||
MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
|
MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
|
||||||
app_label='dcim',
|
app_label='dcim',
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.utils import resolve_module_placeholders
|
|
||||||
from utilities.forms import get_field_value
|
from utilities.forms import get_field_value
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -120,47 +119,25 @@ class ModuleCommonForm(forms.Form):
|
|||||||
# Get the templates for the module type.
|
# Get the templates for the module type.
|
||||||
for template in getattr(module_type, templates).all():
|
for template in getattr(module_type, templates).all():
|
||||||
resolved_name = template.name
|
resolved_name = template.name
|
||||||
has_module_token = MODULE_TOKEN in template.name
|
|
||||||
has_module_path_token = MODULE_PATH_TOKEN in template.name
|
|
||||||
|
|
||||||
# Installing modules with placeholders require that the bay has a position value
|
# Installing modules with placeholders require that the bay has a position value
|
||||||
if has_module_token or has_module_path_token:
|
if MODULE_TOKEN in template.name:
|
||||||
if not module_bay.position:
|
if not module_bay.position:
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
_("Cannot install module with placeholder values in a module bay with no position defined.")
|
_("Cannot install module with placeholder values in a module bay with no position defined.")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cannot mix {module} and {module_path} in the same attribute
|
if len(module_bays) != template.name.count(MODULE_TOKEN):
|
||||||
if has_module_token and has_module_path_token:
|
|
||||||
raise forms.ValidationError(
|
|
||||||
_("Cannot mix {module} and {module_path} placeholders in the same template attribute.")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate {module_path} - can only appear once
|
|
||||||
if has_module_path_token:
|
|
||||||
path_token_count = template.name.count(MODULE_PATH_TOKEN)
|
|
||||||
if path_token_count > 1:
|
|
||||||
raise forms.ValidationError(
|
|
||||||
_("The {module_path} placeholder can only be used once per template.")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate {module} - multi-token must match depth exactly
|
|
||||||
if has_module_token:
|
|
||||||
token_count = template.name.count(MODULE_TOKEN)
|
|
||||||
# Multiple {module} tokens must match the tree depth exactly
|
|
||||||
if token_count > 1 and token_count != len(module_bays):
|
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
_(
|
_(
|
||||||
"Cannot install module with placeholder values in a module bay tree {level} deep "
|
"Cannot install module with placeholder values in a module bay tree {level} in tree "
|
||||||
"but {tokens} placeholders given."
|
"but {tokens} placeholders given."
|
||||||
).format(
|
).format(
|
||||||
level=len(module_bays), tokens=token_count
|
level=len(module_bays), tokens=template.name.count(MODULE_TOKEN)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use centralized helper for placeholder substitution
|
for module_bay in module_bays:
|
||||||
positions = [mb.position for mb in module_bays]
|
resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1)
|
||||||
resolved_name = resolve_module_placeholders(resolved_name, positions)
|
|
||||||
|
|
||||||
existing_item = installed_components.get(resolved_name)
|
existing_item = installed_components.get(resolved_name)
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ class ScopedFilterMixin:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ComponentModelFilterMixin:
|
class ComponentModelFilterMixin:
|
||||||
|
_site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
|
strawberry_django.filter_field(name='site')
|
||||||
|
)
|
||||||
|
_location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
|
strawberry_django.filter_field(name='location')
|
||||||
|
)
|
||||||
|
_rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
|
strawberry_django.filter_field(name='rack')
|
||||||
|
)
|
||||||
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
device_id: ID | None = strawberry_django.filter_field()
|
device_id: ID | None = strawberry_django.filter_field()
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from mptt.models import MPTTModel, TreeForeignKey
|
|||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.models.base import PortMappingBase
|
from dcim.models.base import PortMappingBase
|
||||||
from dcim.utils import resolve_module_placeholders
|
|
||||||
from dcim.models.mixins import InterfaceValidationMixin
|
from dcim.models.mixins import InterfaceValidationMixin
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from utilities.fields import ColorField, NaturalOrderingField
|
from utilities.fields import ColorField, NaturalOrderingField
|
||||||
@@ -171,17 +170,27 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
|||||||
return modules
|
return modules
|
||||||
|
|
||||||
def resolve_name(self, module):
|
def resolve_name(self, module):
|
||||||
"""Resolve {module} and {module_path} placeholders in component name."""
|
if MODULE_TOKEN not in self.name:
|
||||||
|
return self.name
|
||||||
|
|
||||||
if module:
|
if module:
|
||||||
positions = [m.module_bay.position for m in self._get_module_tree(module)]
|
modules = self._get_module_tree(module)
|
||||||
return resolve_module_placeholders(self.name, positions)
|
name = self.name
|
||||||
|
for module in modules:
|
||||||
|
name = name.replace(MODULE_TOKEN, module.module_bay.position, 1)
|
||||||
|
return name
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def resolve_label(self, module):
|
def resolve_label(self, module):
|
||||||
"""Resolve {module} and {module_path} placeholders in component label."""
|
if MODULE_TOKEN not in self.label:
|
||||||
|
return self.label
|
||||||
|
|
||||||
if module:
|
if module:
|
||||||
positions = [m.module_bay.position for m in self._get_module_tree(module)]
|
modules = self._get_module_tree(module)
|
||||||
return resolve_module_placeholders(self.label, positions)
|
label = self.label
|
||||||
|
for module in modules:
|
||||||
|
label = label.replace(MODULE_TOKEN, module.module_bay.position, 1)
|
||||||
|
return label
|
||||||
return self.label
|
return self.label
|
||||||
|
|
||||||
|
|
||||||
@@ -712,26 +721,11 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
|||||||
verbose_name = _('module bay template')
|
verbose_name = _('module bay template')
|
||||||
verbose_name_plural = _('module bay templates')
|
verbose_name_plural = _('module bay templates')
|
||||||
|
|
||||||
def resolve_position(self, module):
|
|
||||||
"""
|
|
||||||
Resolve {module} and {module_path} placeholders in position field.
|
|
||||||
|
|
||||||
This allows positions like "{module}/1" to resolve to "A/1" when
|
|
||||||
the parent module is installed in bay "A".
|
|
||||||
|
|
||||||
Fixes Issue #20467.
|
|
||||||
"""
|
|
||||||
if module:
|
|
||||||
positions = [m.module_bay.position for m in self._get_module_tree(module)]
|
|
||||||
return resolve_module_placeholders(self.position, positions)
|
|
||||||
return self.position
|
|
||||||
|
|
||||||
def instantiate(self, **kwargs):
|
def instantiate(self, **kwargs):
|
||||||
module = kwargs.get('module')
|
|
||||||
return self.component_model(
|
return self.component_model(
|
||||||
name=self.resolve_name(module),
|
name=self.resolve_name(kwargs.get('module')),
|
||||||
label=self.resolve_label(module),
|
label=self.resolve_label(kwargs.get('module')),
|
||||||
position=self.resolve_position(module),
|
position=self.position,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
instantiate.do_not_call_in_templates = True
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|||||||
@@ -1273,7 +1273,7 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
|
|||||||
verbose_name_plural = _('module bays')
|
verbose_name_plural = _('module bays')
|
||||||
|
|
||||||
class MPTTMeta:
|
class MPTTMeta:
|
||||||
order_insertion_by = ('module',)
|
order_insertion_by = ('name',)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|||||||
@@ -259,11 +259,13 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
|
|||||||
module_bays = []
|
module_bays = []
|
||||||
modules = []
|
modules = []
|
||||||
while module:
|
while module:
|
||||||
if module.pk in modules or module.module_bay.pk in module_bays:
|
module_module_bay = getattr(module, "module_bay", None)
|
||||||
|
if module.pk in modules or (module_module_bay and module_module_bay.pk in module_bays):
|
||||||
raise ValidationError(_("A module bay cannot belong to a module installed within it."))
|
raise ValidationError(_("A module bay cannot belong to a module installed within it."))
|
||||||
modules.append(module.pk)
|
modules.append(module.pk)
|
||||||
module_bays.append(module.module_bay.pk)
|
if module_module_bay:
|
||||||
module = module.module_bay.module if module.module_bay else None
|
module_bays.append(module_module_bay.pk)
|
||||||
|
module = module_module_bay.module if module_module_bay else None
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
is_new = self.pk is None
|
is_new = self.pk is None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,54 +4,6 @@ from django.apps import apps
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import router, transaction
|
from django.db import router, transaction
|
||||||
|
|
||||||
from dcim.constants import MODULE_PATH_TOKEN, MODULE_TOKEN, MODULE_TOKEN_SEPARATOR
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_module_placeholders(text, positions):
|
|
||||||
"""
|
|
||||||
Substitute {module} and {module_path} placeholders in text with position values.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: String potentially containing {module} or {module_path} placeholders
|
|
||||||
positions: List of position strings from the module tree (root to leaf)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Text with placeholders replaced according to these rules:
|
|
||||||
|
|
||||||
{module_path}: Always expands to full path (positions joined by MODULE_TOKEN_SEPARATOR).
|
|
||||||
Can only appear once in the text.
|
|
||||||
|
|
||||||
{module}: If used once, expands to the PARENT module bay position only (last in positions).
|
|
||||||
If used multiple times, each token is replaced level-by-level.
|
|
||||||
|
|
||||||
This design (Option 2 per sigprof's feedback) allows two approaches:
|
|
||||||
1. Use {module_path} for automatic full-path expansion (hardcodes '/' separator)
|
|
||||||
2. Use {module} in position fields to build custom paths with user-controlled separators
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return text
|
|
||||||
|
|
||||||
result = text
|
|
||||||
|
|
||||||
# Handle {module_path} - always expands to full path
|
|
||||||
if MODULE_PATH_TOKEN in result:
|
|
||||||
full_path = MODULE_TOKEN_SEPARATOR.join(positions) if positions else ''
|
|
||||||
result = result.replace(MODULE_PATH_TOKEN, full_path)
|
|
||||||
|
|
||||||
# Handle {module} - parent-only for single token, level-by-level for multiple
|
|
||||||
if MODULE_TOKEN in result:
|
|
||||||
token_count = result.count(MODULE_TOKEN)
|
|
||||||
if token_count == 1 and positions:
|
|
||||||
# Single {module}: substitute with parent (immediate) bay position only
|
|
||||||
parent_position = positions[-1] if positions else ''
|
|
||||||
result = result.replace(MODULE_TOKEN, parent_position, 1)
|
|
||||||
else:
|
|
||||||
# Multiple {module}: substitute level-by-level (existing behavior)
|
|
||||||
for pos in positions:
|
|
||||||
result = result.replace(MODULE_TOKEN, pos, 1)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def compile_path_node(ct_id, object_id):
|
def compile_path_node(ct_id, object_id):
|
||||||
return f'{ct_id}:{object_id}'
|
return f'{ct_id}:{object_id}'
|
||||||
|
|||||||
0
netbox/media/.gitkeep
Normal file
0
netbox/media/.gitkeep
Normal file
@@ -232,7 +232,7 @@ VPN_MENU = Menu(
|
|||||||
label=_('L2VPNs'),
|
label=_('L2VPNs'),
|
||||||
items=(
|
items=(
|
||||||
get_model_item('vpn', 'l2vpn', _('L2VPNs')),
|
get_model_item('vpn', 'l2vpn', _('L2VPNs')),
|
||||||
get_model_item('vpn', 'l2vpntermination', _('Terminations')),
|
get_model_item('vpn', 'l2vpntermination', _('L2VPN Terminations')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MenuGroup(
|
MenuGroup(
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ class PluginMenuItem:
|
|||||||
Alternatively, a pre-generated url can be set on the object which will be rendered literally.
|
Alternatively, a pre-generated url can be set on the object which will be rendered literally.
|
||||||
Buttons are each specified as a list of PluginMenuButton instances.
|
Buttons are each specified as a list of PluginMenuButton instances.
|
||||||
"""
|
"""
|
||||||
permissions = []
|
|
||||||
buttons = []
|
|
||||||
_url = None
|
_url = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -54,10 +52,14 @@ class PluginMenuItem:
|
|||||||
if type(permissions) not in (list, tuple):
|
if type(permissions) not in (list, tuple):
|
||||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
|
else:
|
||||||
|
self.permissions = []
|
||||||
if buttons is not None:
|
if buttons is not None:
|
||||||
if type(buttons) not in (list, tuple):
|
if type(buttons) not in (list, tuple):
|
||||||
raise TypeError(_("Buttons must be passed as a tuple or list."))
|
raise TypeError(_("Buttons must be passed as a tuple or list."))
|
||||||
self.buttons = buttons
|
self.buttons = buttons
|
||||||
|
else:
|
||||||
|
self.buttons = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self):
|
def url(self):
|
||||||
@@ -74,7 +76,6 @@ class PluginMenuButton:
|
|||||||
ButtonColorChoices.
|
ButtonColorChoices.
|
||||||
"""
|
"""
|
||||||
color = ButtonColorChoices.DEFAULT
|
color = ButtonColorChoices.DEFAULT
|
||||||
permissions = []
|
|
||||||
_url = None
|
_url = None
|
||||||
|
|
||||||
def __init__(self, link, title, icon_class, color=None, permissions=None):
|
def __init__(self, link, title, icon_class, color=None, permissions=None):
|
||||||
@@ -87,6 +88,8 @@ class PluginMenuButton:
|
|||||||
if type(permissions) not in (list, tuple):
|
if type(permissions) not in (list, tuple):
|
||||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
|
else:
|
||||||
|
self.permissions = []
|
||||||
if color is not None:
|
if color is not None:
|
||||||
if color not in ButtonColorChoices.values():
|
if color not in ButtonColorChoices.values():
|
||||||
raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
|
raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from netbox.tests.dummy_plugin import config as dummy_config
|
|||||||
from netbox.tests.dummy_plugin.data_backends import DummyBackend
|
from netbox.tests.dummy_plugin.data_backends import DummyBackend
|
||||||
from netbox.tests.dummy_plugin.jobs import DummySystemJob
|
from netbox.tests.dummy_plugin.jobs import DummySystemJob
|
||||||
from netbox.tests.dummy_plugin.webhook_callbacks import set_context
|
from netbox.tests.dummy_plugin.webhook_callbacks import set_context
|
||||||
from netbox.plugins.navigation import PluginMenu
|
from netbox.plugins.navigation import PluginMenu, PluginMenuItem, PluginMenuButton
|
||||||
from netbox.plugins.utils import get_plugin_config
|
from netbox.plugins.utils import get_plugin_config
|
||||||
from netbox.graphql.schema import Query
|
from netbox.graphql.schema import Query
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
@@ -227,3 +227,46 @@ class PluginTest(TestCase):
|
|||||||
Test the registration of webhook callbacks.
|
Test the registration of webhook callbacks.
|
||||||
"""
|
"""
|
||||||
self.assertIn(set_context, registry['webhook_callbacks'])
|
self.assertIn(set_context, registry['webhook_callbacks'])
|
||||||
|
|
||||||
|
|
||||||
|
class PluginNavigationTest(TestCase):
|
||||||
|
|
||||||
|
def test_plugin_menu_item_independent_permissions(self):
|
||||||
|
item1 = PluginMenuItem(link='test1', link_text='Test 1')
|
||||||
|
item1.permissions.append('leaked_permission')
|
||||||
|
|
||||||
|
item2 = PluginMenuItem(link='test2', link_text='Test 2')
|
||||||
|
|
||||||
|
self.assertIsNot(item1.permissions, item2.permissions)
|
||||||
|
self.assertEqual(item1.permissions, ['leaked_permission'])
|
||||||
|
self.assertEqual(item2.permissions, [])
|
||||||
|
|
||||||
|
def test_plugin_menu_item_independent_buttons(self):
|
||||||
|
item1 = PluginMenuItem(link='test1', link_text='Test 1')
|
||||||
|
button = PluginMenuButton(link='button1', title='Button 1', icon_class='mdi-test')
|
||||||
|
item1.buttons.append(button)
|
||||||
|
|
||||||
|
item2 = PluginMenuItem(link='test2', link_text='Test 2')
|
||||||
|
|
||||||
|
self.assertIsNot(item1.buttons, item2.buttons)
|
||||||
|
self.assertEqual(len(item1.buttons), 1)
|
||||||
|
self.assertEqual(item1.buttons[0], button)
|
||||||
|
self.assertEqual(item2.buttons, [])
|
||||||
|
|
||||||
|
def test_plugin_menu_button_independent_permissions(self):
|
||||||
|
button1 = PluginMenuButton(link='button1', title='Button 1', icon_class='mdi-test')
|
||||||
|
button1.permissions.append('leaked_permission')
|
||||||
|
|
||||||
|
button2 = PluginMenuButton(link='button2', title='Button 2', icon_class='mdi-test')
|
||||||
|
|
||||||
|
self.assertIsNot(button1.permissions, button2.permissions)
|
||||||
|
self.assertEqual(button1.permissions, ['leaked_permission'])
|
||||||
|
self.assertEqual(button2.permissions, [])
|
||||||
|
|
||||||
|
def test_explicit_permissions_remain_independent(self):
|
||||||
|
item1 = PluginMenuItem(link='test1', link_text='Test 1', permissions=['explicit_permission'])
|
||||||
|
item2 = PluginMenuItem(link='test2', link_text='Test 2', permissions=['different_permission'])
|
||||||
|
|
||||||
|
self.assertIsNot(item1.permissions, item2.permissions)
|
||||||
|
self.assertEqual(item1.permissions, ['explicit_permission'])
|
||||||
|
self.assertEqual(item2.permissions, ['different_permission'])
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
.docExplorerWrap{height:unset!important;min-width:unset!important;width:unset!important}.docExplorerWrap svg{display:unset}.doc-explorer-title{font-size:var(--font-size-h2);font-weight:var(--font-weight-medium)}.doc-explorer-rhs{display:none}.graphiql-explorer-root{font-family:var(--font-family-mono)!important;font-size:var(--font-size-body)!important;padding:0!important}.graphiql-explorer-root>div>div{border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important;padding-top:var(--px-16)}.graphiql-explorer-root input{background:unset}.graphiql-explorer-root select{background:hsl(var(--color-base))!important;border:1px solid hsla(var(--color-neutral),var(--alpha-secondary));border-radius:var(--border-radius-4);color:hsl(var(--color-neutral))!important;margin:0 var(--px-8);padding:var(--px-4) var(--px-6)}.graphiql-operation-title-bar .toolbar-button{line-height:0;margin-left:var(--px-8);color:hsla(var(--color-neutral),var(--alpha-secondary, .6));font-size:var(--font-size-h3);vertical-align:middle}.graphiql-explorer-graphql-arguments input{line-height:0}.graphiql-explorer-actions{border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important}
|
.docExplorerWrap{height:unset!important;min-width:unset!important;width:unset!important}.docExplorerWrap svg{display:unset}.doc-explorer-title{font-size:var(--font-size-h2);font-weight:var(--font-weight-medium)}.doc-explorer-rhs{display:none}.graphiql-explorer-root{font-family:var(--font-family-mono)!important;font-size:var(--font-size-body)!important;padding:0!important}.graphiql-explorer-root>div>div{padding-top:var(--px-16);border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important}.graphiql-explorer-root>div{overflow:auto!important}.graphiql-explorer-root input{background:unset}.graphiql-explorer-root select{border:1px solid hsla(var(--color-neutral),var(--alpha-secondary));border-radius:var(--border-radius-4);margin:0 var(--px-8);padding:var(--px-4)var(--px-6);background:hsl(var(--color-base))!important;color:hsl(var(--color-neutral))!important}.toolbar-button{all:unset;cursor:pointer;margin-left:var(--px-6);color:hsl(var(--color-primary));line-height:0!important;font-size:var(--font-size-h3)!important}.graphiql-explorer-slug .toolbar-button,.graphiql-explorer-graphql-arguments .toolbar-button{font-size:inherit!important}.graphiql-explorer-graphql-arguments input{min-width:2rem;line-height:0}.graphiql-explorer-actions{border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@graphiql/plugin-explorer": "3.2.6",
|
"@graphiql/plugin-explorer": "4.0.6",
|
||||||
"graphiql": "4.1.2",
|
"graphiql": "4.1.2",
|
||||||
"graphql": "16.12.0",
|
"graphql": "16.12.0",
|
||||||
"js-cookie": "3.0.5",
|
"js-cookie": "3.0.5",
|
||||||
|
|||||||
@@ -294,10 +294,10 @@
|
|||||||
react-compiler-runtime "19.1.0-rc.1"
|
react-compiler-runtime "19.1.0-rc.1"
|
||||||
zustand "^5"
|
zustand "^5"
|
||||||
|
|
||||||
"@graphiql/plugin-explorer@3.2.6":
|
"@graphiql/plugin-explorer@4.0.6":
|
||||||
version "3.2.6"
|
version "4.0.6"
|
||||||
resolved "https://registry.npmjs.org/@graphiql/plugin-explorer/-/plugin-explorer-3.2.6.tgz"
|
resolved "https://registry.yarnpkg.com/@graphiql/plugin-explorer/-/plugin-explorer-4.0.6.tgz#bec1207dc27334914590ab31f46c2e944bbf4ebf"
|
||||||
integrity sha512-MXzG/zVNzZfes4Em253bHyAbD/lwwAZkPKvxCAQkjz0i3dtcv4uF3D8iqJ7214iu3SCphbORYZZUC93fik1yew==
|
integrity sha512-TppIi92YPER3v70nlF01KTQrq9AiYqkZicSd1hpU7aqGmbqw/pLwBNLUEcfENBoJtw574Qxjswb01+GaYK0Tzw==
|
||||||
dependencies:
|
dependencies:
|
||||||
graphiql-explorer "^0.9.0"
|
graphiql-explorer "^0.9.0"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -123,7 +123,7 @@ class UserTokenForm(forms.ModelForm):
|
|||||||
token = forms.CharField(
|
token = forms.CharField(
|
||||||
label=_('Token'),
|
label=_('Token'),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
'Tokens must be at least 40 characters in length. <strong>Be sure to record your key</strong> prior to '
|
'Tokens must be at least 40 characters in length. <strong>Be sure to record your token</strong> prior to '
|
||||||
'submitting this form, as it will no longer be accessible once the token has been created.'
|
'submitting this form, as it will no longer be accessible once the token has been created.'
|
||||||
),
|
),
|
||||||
widget=forms.TextInput(
|
widget=forms.TextInput(
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class Token(models.Model):
|
|||||||
write_enabled = models.BooleanField(
|
write_enabled = models.BooleanField(
|
||||||
verbose_name=_('write enabled'),
|
verbose_name=_('write enabled'),
|
||||||
default=True,
|
default=True,
|
||||||
help_text=_('Permit create/update/delete operations using this key')
|
help_text=_('Permit create/update/delete operations using this token')
|
||||||
)
|
)
|
||||||
# For legacy v1 tokens, this field stores the plaintext 40-char token value. Not used for v2.
|
# For legacy v1 tokens, this field stores the plaintext 40-char token value. Not used for v2.
|
||||||
plaintext = models.CharField(
|
plaintext = models.CharField(
|
||||||
@@ -213,6 +213,9 @@ class Token(models.Model):
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
|
if self.version == TokenVersionChoices.V2 and not settings.API_TOKEN_PEPPERS:
|
||||||
|
raise ValidationError(_("Unable to save v2 tokens: API_TOKEN_PEPPERS is not defined."))
|
||||||
|
|
||||||
if self._state.adding:
|
if self._state.adding:
|
||||||
if self.pepper_id is not None and self.pepper_id not in settings.API_TOKEN_PEPPERS:
|
if self.pepper_id is not None and self.pepper_id not in settings.API_TOKEN_PEPPERS:
|
||||||
raise ValidationError(_(
|
raise ValidationError(_(
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase
|
from django.test import TestCase, override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from users.choices import TokenVersionChoices
|
||||||
from users.models import User, Token
|
from users.models import User, Token
|
||||||
from utilities.testing import create_test_user
|
from utilities.testing import create_test_user
|
||||||
|
|
||||||
@@ -94,6 +95,15 @@ class TokenTest(TestCase):
|
|||||||
token.refresh_from_db()
|
token.refresh_from_db()
|
||||||
self.assertEqual(token.description, 'New Description')
|
self.assertEqual(token.description, 'New Description')
|
||||||
|
|
||||||
|
@override_settings(API_TOKEN_PEPPERS={})
|
||||||
|
def test_v2_without_peppers_configured(self):
|
||||||
|
"""
|
||||||
|
Attempting to save a v2 token without API_TOKEN_PEPPERS defined should raise a ValidationError.
|
||||||
|
"""
|
||||||
|
token = Token(version=TokenVersionChoices.V2)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
token.clean()
|
||||||
|
|
||||||
|
|
||||||
class UserConfigTest(TestCase):
|
class UserConfigTest(TestCase):
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user