mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-02 23:19:34 +01:00
Compare commits
9 Commits
20490-rest
...
fix_module
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6548d941b | ||
|
|
898fe8b3d8 | ||
|
|
3680b0ccd4 | ||
|
|
702b1f8210 | ||
|
|
1c6adc40b3 | ||
|
|
bcd3851f4e | ||
|
|
850bfba9e4 | ||
|
|
1df6eee467 | ||
|
|
e613b55ada |
16
.github/ISSUE_TEMPLATE/06-deprecation.yaml
vendored
16
.github/ISSUE_TEMPLATE/06-deprecation.yaml
vendored
@@ -1,26 +1,20 @@
|
||||
---
|
||||
name: ⚠️ Deprecation
|
||||
name: 🗑️ Deprecation
|
||||
type: Deprecation
|
||||
description: Designation of a feature or behavior that will be removed in a future release
|
||||
description: The removal of an existing feature or resource
|
||||
labels: ["netbox", "type: deprecation"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Deprecated Functionality
|
||||
label: Proposed Changes
|
||||
description: >
|
||||
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?
|
||||
Describe in detail the proposed changes. What is being removed?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Justification
|
||||
description: Please provide justification for the deprecation.
|
||||
description: Please provide justification for the proposed change(s).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
20
.github/ISSUE_TEMPLATE/07-feature_removal.yaml
vendored
20
.github/ISSUE_TEMPLATE/07-feature_removal.yaml
vendored
@@ -1,20 +0,0 @@
|
||||
---
|
||||
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
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -30,13 +30,13 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt install -y gettext
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,8 +9,7 @@ yarn-error.log*
|
||||
/netbox/netbox/configuration.py
|
||||
/netbox/netbox/ldap_config.py
|
||||
/netbox/local/*
|
||||
/netbox/media/*
|
||||
!/netbox/media/.gitkeep
|
||||
/netbox/media
|
||||
/netbox/reports/*
|
||||
!/netbox/reports/__init__.py
|
||||
/netbox/scripts/*
|
||||
|
||||
@@ -18,17 +18,7 @@ They can also be used as a mechanism for validating the integrity of data within
|
||||
Custom scripts are Python code which exists outside the NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're completely custom, there is no inherent limitation on what a script can accomplish.
|
||||
|
||||
!!! danger "Only install trusted scripts"
|
||||
Custom scripts have unrestricted access to change anything in the database and are inherently unsafe and should only be installed and run from trusted sources. You should also review and set permissions for who can run scripts if the script can modify any data.
|
||||
|
||||
!!! tip "Permissions for Custom Scripts"
|
||||
A user can be granted permissions on all Custom Scripts via the "Managed File" object-level permission. To further restrict a user to only be able to access certain scripts, create an additional permission on the "Script" object type, with appropriate queryset-style constraints matching fields available on Script. For example:
|
||||
```json
|
||||
{
|
||||
"name__in": [
|
||||
"MyScript"
|
||||
]
|
||||
}
|
||||
```
|
||||
Custom scripts have unrestricted access to change anything in the databse and are inherently unsafe and should only be installed and run from trusted sources. You should also review and set permissions for who can run scripts if the script can modify any data.
|
||||
|
||||
## Writing Custom Scripts
|
||||
|
||||
|
||||
@@ -10,11 +10,9 @@ Change records are exposed in the API via the read-only endpoint `/api/extras/ob
|
||||
|
||||
## User Messages
|
||||
|
||||
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.
|
||||
!!! info "This feature was introduced in NetBox v4.4."
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
## Correlating Changes by Request
|
||||
|
||||
|
||||
@@ -610,7 +610,9 @@ http://netbox/api/dcim/sites/ \
|
||||
|
||||
## Changelog Messages
|
||||
|
||||
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.
|
||||
!!! 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. 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:
|
||||
|
||||
@@ -626,7 +628,7 @@ http://netbox/api/dcim/sites/ \
|
||||
}'
|
||||
```
|
||||
|
||||
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).
|
||||
This approach works when creating, modifying, or deleting objects, either individually or in bulk.
|
||||
|
||||
## Uploading Files
|
||||
|
||||
|
||||
@@ -16,9 +16,33 @@ Note that device bays and module bays may _not_ be added to modules.
|
||||
|
||||
## Automatic Component Renaming
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
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]`.
|
||||
### `{module}` Placeholder
|
||||
|
||||
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).
|
||||
|
||||
|
||||
@@ -44,4 +44,3 @@ class DataFileSerializer(NetBoxModelSerializer):
|
||||
'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'path')
|
||||
read_only_fields = ['path', 'last_updated', 'size', 'hash']
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||
from netbox.models import PrimaryModel
|
||||
@@ -128,9 +128,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
# Ensure URL scheme matches selected type
|
||||
if self.backend_class.is_local and self.url_scheme not in ('file', ''):
|
||||
raise ValidationError({
|
||||
'source_url': _("URLs for local sources must start with {scheme} (or specify no scheme)").format(
|
||||
scheme='file://'
|
||||
)
|
||||
'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -79,6 +79,8 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||
#
|
||||
|
||||
MODULE_TOKEN = '{module}'
|
||||
MODULE_PATH_TOKEN = '{module_path}'
|
||||
MODULE_TOKEN_SEPARATOR = '/'
|
||||
|
||||
MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
|
||||
app_label='dcim',
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.utils import resolve_module_placeholders
|
||||
from utilities.forms import get_field_value
|
||||
|
||||
__all__ = (
|
||||
@@ -119,25 +120,47 @@ class ModuleCommonForm(forms.Form):
|
||||
# Get the templates for the module type.
|
||||
for template in getattr(module_type, templates).all():
|
||||
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
|
||||
if MODULE_TOKEN in template.name:
|
||||
if has_module_token or has_module_path_token:
|
||||
if not module_bay.position:
|
||||
raise forms.ValidationError(
|
||||
_("Cannot install module with placeholder values in a module bay with no position defined.")
|
||||
)
|
||||
|
||||
if len(module_bays) != template.name.count(MODULE_TOKEN):
|
||||
# Cannot mix {module} and {module_path} in the same attribute
|
||||
if has_module_token and has_module_path_token:
|
||||
raise forms.ValidationError(
|
||||
_(
|
||||
"Cannot install module with placeholder values in a module bay tree {level} in tree "
|
||||
"but {tokens} placeholders given."
|
||||
).format(
|
||||
level=len(module_bays), tokens=template.name.count(MODULE_TOKEN)
|
||||
)
|
||||
_("Cannot mix {module} and {module_path} placeholders in the same template attribute.")
|
||||
)
|
||||
|
||||
for module_bay in module_bays:
|
||||
resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1)
|
||||
# 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(
|
||||
_(
|
||||
"Cannot install module with placeholder values in a module bay tree {level} deep "
|
||||
"but {tokens} placeholders given."
|
||||
).format(
|
||||
level=len(module_bays), tokens=token_count
|
||||
)
|
||||
)
|
||||
|
||||
# Use centralized helper for placeholder substitution
|
||||
positions = [mb.position for mb in module_bays]
|
||||
resolved_name = resolve_module_placeholders(resolved_name, positions)
|
||||
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
|
||||
@@ -140,6 +140,9 @@ class FrontPortFormMixin(forms.Form):
|
||||
widget=forms.SelectMultiple(attrs={'size': 8})
|
||||
)
|
||||
|
||||
port_mapping_model = PortMapping
|
||||
parent_field = 'device'
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -200,22 +203,3 @@ class FrontPortFormMixin(forms.Form):
|
||||
using=connection,
|
||||
update_fields=None
|
||||
)
|
||||
|
||||
def _get_rear_port_choices(self, parent_filter, front_port):
|
||||
"""
|
||||
Return a list of choices representing each available rear port & position pair on the parent object (identified
|
||||
by a Q filter), excluding those assigned to the specified instance.
|
||||
"""
|
||||
occupied_rear_port_positions = [
|
||||
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
|
||||
for mapping in self.port_mapping_model.objects.filter(parent_filter).exclude(front_port=front_port.pk)
|
||||
]
|
||||
|
||||
choices = []
|
||||
for rear_port in self.rear_port_model.objects.filter(parent_filter):
|
||||
for i in range(1, rear_port.positions + 1):
|
||||
pair_id = f'{rear_port.pk}:{i}'
|
||||
if pair_id not in occupied_rear_port_positions:
|
||||
pair_label = f'{rear_port.name}:{i}'
|
||||
choices.append((pair_id, pair_label))
|
||||
return choices
|
||||
|
||||
@@ -1124,8 +1124,9 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
|
||||
),
|
||||
)
|
||||
|
||||
# Override FrontPortFormMixin attrs
|
||||
port_mapping_model = PortTemplateMapping
|
||||
rear_port_model = RearPortTemplate
|
||||
parent_field = 'device_type'
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
@@ -1136,14 +1137,13 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Populate rear port choices based on parent DeviceType or ModuleType
|
||||
if device_type_id := self.data.get('device_type') or self.initial.get('device_type'):
|
||||
parent_filter = Q(device_type=device_type_id)
|
||||
elif module_type_id := self.data.get('module_type') or self.initial.get('module_type'):
|
||||
parent_filter = Q(module_type=module_type_id)
|
||||
device_type = DeviceType.objects.get(pk=device_type_id)
|
||||
else:
|
||||
return
|
||||
self.fields['rear_ports'].choices = self._get_rear_port_choices(parent_filter, self.instance)
|
||||
|
||||
# Populate rear port choices
|
||||
self.fields['rear_ports'].choices = self._get_rear_port_choices(device_type, self.instance)
|
||||
|
||||
# Set initial rear port mappings
|
||||
if self.instance.pk:
|
||||
@@ -1152,6 +1152,27 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
|
||||
for mapping in PortTemplateMapping.objects.filter(front_port_id=self.instance.pk)
|
||||
]
|
||||
|
||||
def _get_rear_port_choices(self, device_type, front_port):
|
||||
"""
|
||||
Return a list of choices representing each available rear port & position pair on the device type, excluding
|
||||
those assigned to the specified instance.
|
||||
"""
|
||||
occupied_rear_port_positions = [
|
||||
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
|
||||
for mapping in device_type.port_mappings.exclude(front_port=front_port.pk)
|
||||
]
|
||||
|
||||
choices = []
|
||||
for rear_port in RearPortTemplate.objects.filter(device_type=device_type):
|
||||
for i in range(1, rear_port.positions + 1):
|
||||
pair_id = f'{rear_port.pk}:{i}'
|
||||
if pair_id not in occupied_rear_port_positions:
|
||||
pair_label = f'{rear_port.name}:{i}'
|
||||
choices.append(
|
||||
(pair_id, pair_label)
|
||||
)
|
||||
return choices
|
||||
|
||||
|
||||
class RearPortTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
@@ -1598,9 +1619,6 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
|
||||
),
|
||||
)
|
||||
|
||||
port_mapping_model = PortMapping
|
||||
rear_port_model = RearPort
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = [
|
||||
@@ -1611,12 +1629,13 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Populate rear port choices
|
||||
if device_id := self.data.get('device') or self.initial.get('device'):
|
||||
parent_filter = Q(device=device_id)
|
||||
device = Device.objects.get(pk=device_id)
|
||||
else:
|
||||
return
|
||||
self.fields['rear_ports'].choices = self._get_rear_port_choices(parent_filter, self.instance)
|
||||
|
||||
# Populate rear port choices
|
||||
self.fields['rear_ports'].choices = self._get_rear_port_choices(device, self.instance)
|
||||
|
||||
# Set initial rear port mappings
|
||||
if self.instance.pk:
|
||||
@@ -1625,6 +1644,27 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
|
||||
for mapping in PortMapping.objects.filter(front_port_id=self.instance.pk)
|
||||
]
|
||||
|
||||
def _get_rear_port_choices(self, device, front_port):
|
||||
"""
|
||||
Return a list of choices representing each available rear port & position pair on the device, excluding those
|
||||
assigned to the specified instance.
|
||||
"""
|
||||
occupied_rear_port_positions = [
|
||||
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
|
||||
for mapping in device.port_mappings.exclude(front_port=front_port.pk)
|
||||
]
|
||||
|
||||
choices = []
|
||||
for rear_port in RearPort.objects.filter(device=device):
|
||||
for i in range(1, rear_port.positions + 1):
|
||||
pair_id = f'{rear_port.pk}:{i}'
|
||||
if pair_id not in occupied_rear_port_positions:
|
||||
pair_label = f'{rear_port.name}:{i}'
|
||||
choices.append(
|
||||
(pair_id, pair_label)
|
||||
)
|
||||
return choices
|
||||
|
||||
|
||||
class RearPortForm(ModularDeviceComponentForm):
|
||||
fieldsets = (
|
||||
|
||||
@@ -38,15 +38,6 @@ class ScopedFilterMixin:
|
||||
|
||||
@dataclass
|
||||
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_id: ID | None = strawberry_django.filter_field()
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
@@ -8,6 +8,7 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models.base import PortMappingBase
|
||||
from dcim.utils import resolve_module_placeholders
|
||||
from dcim.models.mixins import InterfaceValidationMixin
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
@@ -170,27 +171,17 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
return modules
|
||||
|
||||
def resolve_name(self, module):
|
||||
if MODULE_TOKEN not in self.name:
|
||||
return self.name
|
||||
|
||||
"""Resolve {module} and {module_path} placeholders in component name."""
|
||||
if module:
|
||||
modules = self._get_module_tree(module)
|
||||
name = self.name
|
||||
for module in modules:
|
||||
name = name.replace(MODULE_TOKEN, module.module_bay.position, 1)
|
||||
return name
|
||||
positions = [m.module_bay.position for m in self._get_module_tree(module)]
|
||||
return resolve_module_placeholders(self.name, positions)
|
||||
return self.name
|
||||
|
||||
def resolve_label(self, module):
|
||||
if MODULE_TOKEN not in self.label:
|
||||
return self.label
|
||||
|
||||
"""Resolve {module} and {module_path} placeholders in component label."""
|
||||
if module:
|
||||
modules = self._get_module_tree(module)
|
||||
label = self.label
|
||||
for module in modules:
|
||||
label = label.replace(MODULE_TOKEN, module.module_bay.position, 1)
|
||||
return label
|
||||
positions = [m.module_bay.position for m in self._get_module_tree(module)]
|
||||
return resolve_module_placeholders(self.label, positions)
|
||||
return self.label
|
||||
|
||||
|
||||
@@ -721,11 +712,26 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
||||
verbose_name = _('module bay template')
|
||||
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):
|
||||
module = kwargs.get('module')
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
position=self.position,
|
||||
name=self.resolve_name(module),
|
||||
label=self.resolve_label(module),
|
||||
position=self.resolve_position(module),
|
||||
**kwargs
|
||||
)
|
||||
instantiate.do_not_call_in_templates = True
|
||||
|
||||
@@ -259,13 +259,11 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
|
||||
module_bays = []
|
||||
modules = []
|
||||
while module:
|
||||
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):
|
||||
if module.pk in modules or module.module_bay.pk in module_bays:
|
||||
raise ValidationError(_("A module bay cannot belong to a module installed within it."))
|
||||
modules.append(module.pk)
|
||||
if module_module_bay:
|
||||
module_bays.append(module_module_bay.pk)
|
||||
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):
|
||||
is_new = self.pk is None
|
||||
|
||||
@@ -211,16 +211,12 @@ def sync_cached_scope_fields(instance, created, **kwargs):
|
||||
for model in (Prefix, Cluster, WirelessLAN):
|
||||
qs = model.objects.filter(**filters)
|
||||
|
||||
# Bulk update cached fields to avoid O(N) performance issues with large datasets.
|
||||
# This does not trigger post_save signals, avoiding spurious change log entries.
|
||||
objects_to_update = []
|
||||
for obj in qs:
|
||||
# Recompute cache using the same logic as save()
|
||||
obj.cache_related_objects()
|
||||
objects_to_update.append(obj)
|
||||
|
||||
if objects_to_update:
|
||||
model.objects.bulk_update(
|
||||
objects_to_update,
|
||||
['_location', '_site', '_site_group', '_region']
|
||||
)
|
||||
obj.save(update_fields=[
|
||||
'_location',
|
||||
'_site',
|
||||
'_site_group',
|
||||
'_region',
|
||||
])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@ class RackDimensionsPanel(panels.ObjectAttributesPanel):
|
||||
outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display')
|
||||
outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display')
|
||||
outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display')
|
||||
mounting_depth = attrs.TextAttr('mounting_depth', format_string='{} mm')
|
||||
mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm')
|
||||
|
||||
|
||||
class RackNumberingPanel(panels.ObjectAttributesPanel):
|
||||
|
||||
@@ -4,6 +4,54 @@ from django.apps import apps
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
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):
|
||||
return f'{ct_id}:{object_id}'
|
||||
|
||||
@@ -1845,7 +1845,6 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
|
||||
class ModuleTypeBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ModuleType.objects.all()
|
||||
filterset = filtersets.ModuleTypeFilterSet
|
||||
field_name = 'model'
|
||||
|
||||
|
||||
@register_model_view(ModuleType, 'bulk_delete', path='delete', detail=False)
|
||||
|
||||
@@ -28,7 +28,7 @@ class ConfigContextProfileSerializer(PrimaryModelSerializer):
|
||||
)
|
||||
data_file = DataFileSerializer(
|
||||
nested=True,
|
||||
required=False
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -143,7 +143,7 @@ class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedM
|
||||
)
|
||||
data_file = DataFileSerializer(
|
||||
nested=True,
|
||||
required=False
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
@@ -8,7 +7,7 @@ from rest_framework import status
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.events import *
|
||||
from core.models import DataFile, DataSource, ObjectType
|
||||
from core.models import ObjectType
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
@@ -732,51 +731,6 @@ class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
ConfigContextProfile.objects.bulk_create(profiles)
|
||||
|
||||
def test_update_data_source_and_data_file(self):
|
||||
"""
|
||||
Regression test: Ensure data_source and data_file can be assigned via the API.
|
||||
|
||||
This specifically covers PATCHing a ConfigContext with integer IDs for both fields.
|
||||
"""
|
||||
self.add_permissions(
|
||||
'core.view_datafile',
|
||||
'core.view_datasource',
|
||||
'extras.view_configcontextprofile',
|
||||
'extras.change_configcontextprofile',
|
||||
)
|
||||
config_context_profile = ConfigContextProfile.objects.first()
|
||||
|
||||
# Create a data source and file
|
||||
datasource = DataSource.objects.create(
|
||||
name='Data Source 1',
|
||||
type='local',
|
||||
source_url='file:///tmp/netbox-datasource/',
|
||||
)
|
||||
# Generate a valid dummy YAML file
|
||||
file_data = b'profile: configcontext\n'
|
||||
datafile = DataFile.objects.create(
|
||||
source=datasource,
|
||||
path='dir1/file1.yml',
|
||||
last_updated=now(),
|
||||
size=len(file_data),
|
||||
hash=hashlib.sha256(file_data).hexdigest(),
|
||||
data=file_data,
|
||||
)
|
||||
|
||||
url = self._get_detail_url(config_context_profile)
|
||||
payload = {
|
||||
'data_source': datasource.pk,
|
||||
'data_file': datafile.pk,
|
||||
}
|
||||
response = self.client.patch(url, payload, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
config_context_profile.refresh_from_db()
|
||||
self.assertEqual(config_context_profile.data_source_id, datasource.pk)
|
||||
self.assertEqual(config_context_profile.data_file_id, datafile.pk)
|
||||
self.assertEqual(response.data['data_source']['id'], datasource.pk)
|
||||
self.assertEqual(response.data['data_file']['id'], datafile.pk)
|
||||
|
||||
|
||||
class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConfigContext
|
||||
@@ -858,51 +812,6 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
||||
rendered_context = device.get_config_context()
|
||||
self.assertEqual(rendered_context['bar'], 456)
|
||||
|
||||
def test_update_data_source_and_data_file(self):
|
||||
"""
|
||||
Regression test: Ensure data_source and data_file can be assigned via the API.
|
||||
|
||||
This specifically covers PATCHing a ConfigContext with integer IDs for both fields.
|
||||
"""
|
||||
self.add_permissions(
|
||||
'core.view_datafile',
|
||||
'core.view_datasource',
|
||||
'extras.view_configcontext',
|
||||
'extras.change_configcontext',
|
||||
)
|
||||
config_context = ConfigContext.objects.first()
|
||||
|
||||
# Create a data source and file
|
||||
datasource = DataSource.objects.create(
|
||||
name='Data Source 1',
|
||||
type='local',
|
||||
source_url='file:///tmp/netbox-datasource/',
|
||||
)
|
||||
# Generate a valid dummy YAML file
|
||||
file_data = b'context: config\n'
|
||||
datafile = DataFile.objects.create(
|
||||
source=datasource,
|
||||
path='dir1/file1.yml',
|
||||
last_updated=now(),
|
||||
size=len(file_data),
|
||||
hash=hashlib.sha256(file_data).hexdigest(),
|
||||
data=file_data,
|
||||
)
|
||||
|
||||
url = self._get_detail_url(config_context)
|
||||
payload = {
|
||||
'data_source': datasource.pk,
|
||||
'data_file': datafile.pk,
|
||||
}
|
||||
response = self.client.patch(url, payload, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
config_context.refresh_from_db()
|
||||
self.assertEqual(config_context.data_source_id, datasource.pk)
|
||||
self.assertEqual(config_context.data_file_id, datafile.pk)
|
||||
self.assertEqual(response.data['data_source']['id'], datasource.pk)
|
||||
self.assertEqual(response.data['data_file']['id'], datafile.pk)
|
||||
|
||||
|
||||
class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConfigTemplate
|
||||
|
||||
@@ -24,11 +24,9 @@ from extras.utils import SharedObjectViewMixin
|
||||
from netbox.object_actions import *
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.mixins import TableMixin
|
||||
from users.models import ObjectPermission
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
from utilities.htmx import htmx_partial, htmx_maybe_redirect_current_page
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.permissions import qs_filter_from_constraints
|
||||
from utilities.query import count_related
|
||||
from utilities.querydict import normalize_querydict
|
||||
from utilities.request import copy_safe_request
|
||||
@@ -1443,24 +1441,12 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request):
|
||||
# Permissions for the Scripts page are given via the "Managed File" object permission. To further restrict
|
||||
# users to access only specified scripts, create permissions on the "Script" object with appropriate
|
||||
# queryset-style constraints matching fields available on Script.
|
||||
script_modules = ScriptModule.objects.restrict(request.user).prefetch_related(
|
||||
'data_source', 'data_file', 'jobs'
|
||||
)
|
||||
script_ct = ContentType.objects.get_for_model(Script)
|
||||
script_permissions = qs_filter_from_constraints(
|
||||
ObjectPermission.objects.filter(
|
||||
users=self.request.user, object_types=script_ct
|
||||
).values_list("constraints", flat=True)
|
||||
)
|
||||
available_scripts = Script.objects.filter(script_permissions, module__in=script_modules)
|
||||
|
||||
context = {
|
||||
'model': ScriptModule,
|
||||
'script_modules': script_modules,
|
||||
'available_scripts': available_scripts,
|
||||
}
|
||||
|
||||
# Use partial template for dashboard widgets
|
||||
|
||||
@@ -538,7 +538,7 @@ class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
FieldSet('qinq_role', 'qinq_svlan_id', name=_('Q-in-Q/802.1ad')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'group_id')
|
||||
selector_fields = ('filter_id', 'q', 'site_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -372,8 +372,8 @@ class IPAddressForm(TenancyForm, PrimaryModelForm):
|
||||
'virtual_machine_id': instance.assigned_object.virtual_machine.pk,
|
||||
})
|
||||
|
||||
# Disable object assignment fields if the IP address is designated as primary or OOB
|
||||
if self.initial.get('primary_for_parent') or self.initial.get('oob_for_parent'):
|
||||
# Disable object assignment fields if the IP address is designated as primary
|
||||
if self.initial.get('primary_for_parent'):
|
||||
self.fields['interface'].disabled = True
|
||||
self.fields['vminterface'].disabled = True
|
||||
self.fields['fhrpgroup'].disabled = True
|
||||
|
||||
@@ -940,13 +940,6 @@ class IPAddress(ContactsMixin, PrimaryModel):
|
||||
_("Cannot reassign IP address while it is designated as the primary IP for the parent object")
|
||||
)
|
||||
|
||||
# can't use is_oob_ip as self.assigned_object might be changed
|
||||
if hasattr(original_parent, 'oob_ip') and original_parent.oob_ip_id == self.pk:
|
||||
if parent != original_parent:
|
||||
raise ValidationError(
|
||||
_("Cannot reassign IP address while it is designated as the OOB IP for the parent object")
|
||||
)
|
||||
|
||||
# Validate IP status selection
|
||||
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
|
||||
raise ValidationError({
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import strawberry_django
|
||||
from strawberry import ID
|
||||
from strawberry_django import ComparisonFilterLookup, FilterLookup
|
||||
from strawberry_django import FilterLookup
|
||||
|
||||
from core.graphql.filter_mixins import ChangeLoggingMixin
|
||||
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
|
||||
@@ -23,7 +23,7 @@ __all__ = (
|
||||
|
||||
@dataclass
|
||||
class BaseModelFilter:
|
||||
id: ComparisonFilterLookup[ID] | None = strawberry_django.filter_field()
|
||||
id: FilterLookup[ID] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
class ChangeLoggedModelFilter(ChangeLoggingMixin, BaseModelFilter):
|
||||
|
||||
@@ -232,7 +232,7 @@ VPN_MENU = Menu(
|
||||
label=_('L2VPNs'),
|
||||
items=(
|
||||
get_model_item('vpn', 'l2vpn', _('L2VPNs')),
|
||||
get_model_item('vpn', 'l2vpntermination', _('L2VPN Terminations')),
|
||||
get_model_item('vpn', 'l2vpntermination', _('Terminations')),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
|
||||
@@ -37,6 +37,8 @@ class PluginMenuItem:
|
||||
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.
|
||||
"""
|
||||
permissions = []
|
||||
buttons = []
|
||||
_url = None
|
||||
|
||||
def __init__(
|
||||
@@ -52,14 +54,10 @@ class PluginMenuItem:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
self.permissions = permissions
|
||||
else:
|
||||
self.permissions = []
|
||||
if buttons is not None:
|
||||
if type(buttons) not in (list, tuple):
|
||||
raise TypeError(_("Buttons must be passed as a tuple or list."))
|
||||
self.buttons = buttons
|
||||
else:
|
||||
self.buttons = []
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
@@ -76,6 +74,7 @@ class PluginMenuButton:
|
||||
ButtonColorChoices.
|
||||
"""
|
||||
color = ButtonColorChoices.DEFAULT
|
||||
permissions = []
|
||||
_url = None
|
||||
|
||||
def __init__(self, link, title, icon_class, color=None, permissions=None):
|
||||
@@ -88,8 +87,6 @@ class PluginMenuButton:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
self.permissions = permissions
|
||||
else:
|
||||
self.permissions = []
|
||||
if color is not None:
|
||||
if color not in ButtonColorChoices.values():
|
||||
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.jobs import DummySystemJob
|
||||
from netbox.tests.dummy_plugin.webhook_callbacks import set_context
|
||||
from netbox.plugins.navigation import PluginMenu, PluginMenuItem, PluginMenuButton
|
||||
from netbox.plugins.navigation import PluginMenu
|
||||
from netbox.plugins.utils import get_plugin_config
|
||||
from netbox.graphql.schema import Query
|
||||
from netbox.registry import registry
|
||||
@@ -227,46 +227,3 @@ class PluginTest(TestCase):
|
||||
Test the registration of 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'])
|
||||
|
||||
@@ -164,7 +164,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
|
||||
"""
|
||||
label = name[:1].upper() + name[1:]
|
||||
label = label.replace('_', ' ')
|
||||
return _(label)
|
||||
return label
|
||||
|
||||
def get_context(self, context):
|
||||
# Determine which attributes to display in the panel based on only/exclude args
|
||||
|
||||
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{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}
|
||||
.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}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@graphiql/plugin-explorer": "4.0.6",
|
||||
"@graphiql/plugin-explorer": "3.2.6",
|
||||
"graphiql": "4.1.2",
|
||||
"graphql": "16.12.0",
|
||||
"js-cookie": "3.0.5",
|
||||
|
||||
@@ -294,10 +294,10 @@
|
||||
react-compiler-runtime "19.1.0-rc.1"
|
||||
zustand "^5"
|
||||
|
||||
"@graphiql/plugin-explorer@4.0.6":
|
||||
version "4.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@graphiql/plugin-explorer/-/plugin-explorer-4.0.6.tgz#bec1207dc27334914590ab31f46c2e944bbf4ebf"
|
||||
integrity sha512-TppIi92YPER3v70nlF01KTQrq9AiYqkZicSd1hpU7aqGmbqw/pLwBNLUEcfENBoJtw574Qxjswb01+GaYK0Tzw==
|
||||
"@graphiql/plugin-explorer@3.2.6":
|
||||
version "3.2.6"
|
||||
resolved "https://registry.npmjs.org/@graphiql/plugin-explorer/-/plugin-explorer-3.2.6.tgz"
|
||||
integrity sha512-MXzG/zVNzZfes4Em253bHyAbD/lwwAZkPKvxCAQkjz0i3dtcv4uF3D8iqJ7214iu3SCphbORYZZUC93fik1yew==
|
||||
dependencies:
|
||||
graphiql-explorer "^0.9.0"
|
||||
|
||||
|
||||
@@ -38,83 +38,81 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for script in scripts %}
|
||||
{% if script in available_scripts %}
|
||||
{% with last_job=script.get_latest_jobs|first %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if script.is_executable %}
|
||||
<a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
|
||||
<span class="text-danger">
|
||||
<i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ script.python_class.description|markdown|placeholder }}</td>
|
||||
{% if last_job %}
|
||||
<td>
|
||||
<a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% badge last_job.get_status_display last_job.get_status_color %}
|
||||
</td>
|
||||
{% with last_job=script.get_latest_jobs|first %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if script.is_executable %}
|
||||
<a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
|
||||
{% else %}
|
||||
<td class="text-muted">{% trans "Never" %}</td>
|
||||
<td>{{ ''|placeholder }}</td>
|
||||
<a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
|
||||
<span class="text-danger">
|
||||
<i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ script.python_class.description|markdown|placeholder }}</td>
|
||||
{% if last_job %}
|
||||
<td>
|
||||
{% if request.user|can_run:script and script.is_executable %}
|
||||
<div class="float-end d-print-none">
|
||||
<form action="{% url 'extras:script' script.pk %}" method="post">
|
||||
{% if script.python_class.commit_default %}
|
||||
<input type="checkbox" name="_commit" hidden checked>
|
||||
{% endif %}
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="_run" class="btn btn-primary{% if embedded %} btn-sm{% endif %}">
|
||||
{% if last_job %}
|
||||
<i class="mdi mdi-replay"></i> {% if not embedded %}{% trans "Run Again" %}{% endif %}
|
||||
{% else %}
|
||||
<i class="mdi mdi-play"></i> {% if not embedded %}{% trans "Run Script" %}{% endif %}
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% if last_job and not embedded %}
|
||||
{% for test_name, data in last_job.data.tests.items %}
|
||||
<tr>
|
||||
<td colspan="4" class="method">
|
||||
<span class="ps-3">{{ test_name }}</span>
|
||||
</td>
|
||||
<td class="text-end text-nowrap script-stats">
|
||||
<span class="badge text-bg-success">{{ data.success }}</span>
|
||||
<span class="badge text-bg-info">{{ data.info }}</span>
|
||||
<span class="badge text-bg-warning">{{ data.warning }}</span>
|
||||
<span class="badge text-bg-danger">{{ data.failure }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% elif last_job and not last_job.data.log and not embedded %}
|
||||
{# legacy #}
|
||||
{% for method, stats in last_job.data.items %}
|
||||
<tr>
|
||||
<td colspan="4" class="method">
|
||||
<span class="ps-3">{{ method }}</span>
|
||||
</td>
|
||||
<td class="text-end text-nowrap report-stats">
|
||||
<span class="badge bg-success">{{ stats.success }}</span>
|
||||
<span class="badge bg-info">{{ stats.info }}</span>
|
||||
<span class="badge bg-warning">{{ stats.warning }}</span>
|
||||
<span class="badge bg-danger">{{ stats.failure }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<td>
|
||||
{% badge last_job.get_status_display last_job.get_status_color %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="text-muted">{% trans "Never" %}</td>
|
||||
<td>{{ ''|placeholder }}</td>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
<td>
|
||||
{% if request.user|can_run:script and script.is_executable %}
|
||||
<div class="float-end d-print-none">
|
||||
<form action="{% url 'extras:script' script.pk %}" method="post">
|
||||
{% if script.python_class.commit_default %}
|
||||
<input type="checkbox" name="_commit" hidden checked>
|
||||
{% endif %}
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="_run" class="btn btn-primary{% if embedded %} btn-sm{% endif %}">
|
||||
{% if last_job %}
|
||||
<i class="mdi mdi-replay"></i> {% if not embedded %}{% trans "Run Again" %}{% endif %}
|
||||
{% else %}
|
||||
<i class="mdi mdi-play"></i> {% if not embedded %}{% trans "Run Script" %}{% endif %}
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if last_job and not embedded %}
|
||||
{% for test_name, data in last_job.data.tests.items %}
|
||||
<tr>
|
||||
<td colspan="4" class="method">
|
||||
<span class="ps-3">{{ test_name }}</span>
|
||||
</td>
|
||||
<td class="text-end text-nowrap script-stats">
|
||||
<span class="badge text-bg-success">{{ data.success }}</span>
|
||||
<span class="badge text-bg-info">{{ data.info }}</span>
|
||||
<span class="badge text-bg-warning">{{ data.warning }}</span>
|
||||
<span class="badge text-bg-danger">{{ data.failure }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% elif last_job and not last_job.data.log and not embedded %}
|
||||
{# legacy #}
|
||||
{% for method, stats in last_job.data.items %}
|
||||
<tr>
|
||||
<td colspan="4" class="method">
|
||||
<span class="ps-3">{{ method }}</span>
|
||||
</td>
|
||||
<td class="text-end text-nowrap report-stats">
|
||||
<span class="badge bg-success">{{ stats.success }}</span>
|
||||
<span class="badge bg-info">{{ stats.info }}</span>
|
||||
<span class="badge bg-warning">{{ stats.warning }}</span>
|
||||
<span class="badge bg-danger">{{ stats.failure }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -123,7 +123,7 @@ class UserTokenForm(forms.ModelForm):
|
||||
token = forms.CharField(
|
||||
label=_('Token'),
|
||||
help_text=_(
|
||||
'Tokens must be at least 40 characters in length. <strong>Be sure to record your token</strong> prior to '
|
||||
'Tokens must be at least 40 characters in length. <strong>Be sure to record your key</strong> prior to '
|
||||
'submitting this form, as it will no longer be accessible once the token has been created.'
|
||||
),
|
||||
widget=forms.TextInput(
|
||||
|
||||
@@ -69,7 +69,7 @@ class Token(models.Model):
|
||||
write_enabled = models.BooleanField(
|
||||
verbose_name=_('write enabled'),
|
||||
default=True,
|
||||
help_text=_('Permit create/update/delete operations using this token')
|
||||
help_text=_('Permit create/update/delete operations using this key')
|
||||
)
|
||||
# For legacy v1 tokens, this field stores the plaintext 40-char token value. Not used for v2.
|
||||
plaintext = models.CharField(
|
||||
@@ -213,9 +213,6 @@ class Token(models.Model):
|
||||
def clean(self):
|
||||
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.pepper_id is not None and self.pepper_id not in settings.API_TOKEN_PEPPERS:
|
||||
raise ValidationError(_(
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from users.choices import TokenVersionChoices
|
||||
from users.models import User, Token
|
||||
from utilities.testing import create_test_user
|
||||
|
||||
@@ -95,15 +94,6 @@ class TokenTest(TestCase):
|
||||
token.refresh_from_db()
|
||||
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):
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from utilities.forms.widgets.apiselect import APISelect, APISelectMultiple
|
||||
|
||||
__all__ = (
|
||||
'FilterModifierWidget',
|
||||
'MODIFIER_EMPTY_FALSE',
|
||||
@@ -96,37 +94,9 @@ class FilterModifierWidget(forms.Widget):
|
||||
# to the original widget before rendering
|
||||
self.original_widget.attrs.update(self.attrs)
|
||||
|
||||
# For APISelect/APISelectMultiple widgets, temporarily clear choices to prevent queryset evaluation
|
||||
original_choices = None
|
||||
if isinstance(self.original_widget, (APISelect, APISelectMultiple)):
|
||||
original_choices = self.original_widget.choices
|
||||
|
||||
# Only keep selected choices to preserve current selection in HTML
|
||||
if value:
|
||||
values = value if isinstance(value, (list, tuple)) else [value]
|
||||
|
||||
if hasattr(original_choices, 'queryset'):
|
||||
queryset = original_choices.queryset
|
||||
selected_objects = queryset.filter(pk__in=values)
|
||||
# Build minimal choice list with just the selected values
|
||||
self.original_widget.choices = [
|
||||
(obj.pk, str(obj)) for obj in selected_objects
|
||||
]
|
||||
else:
|
||||
self.original_widget.choices = [
|
||||
choice for choice in original_choices if choice[0] in values
|
||||
]
|
||||
else:
|
||||
# No selection - render empty select element
|
||||
self.original_widget.choices = []
|
||||
|
||||
# Get context from the original widget
|
||||
original_context = self.original_widget.get_context(name, value, attrs)
|
||||
|
||||
# Restore original choices if we modified them
|
||||
if original_choices is not None:
|
||||
self.original_widget.choices = original_choices
|
||||
|
||||
# Build our wrapper context
|
||||
context = super().get_context(name, value, attrs)
|
||||
context['widget']['original_widget'] = original_context['widget']
|
||||
|
||||
16
scripts/git-hooks/pre-commit
Executable file
16
scripts/git-hooks/pre-commit
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# TODO: Remove this file in NetBox v4.3
|
||||
# This script has been maintained to ease transition to the pre-commit tool.
|
||||
|
||||
exec 1>&2
|
||||
|
||||
EXIT=0
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[0;33m'
|
||||
NOCOLOR='\033[0m'
|
||||
|
||||
printf "${YELLOW}The pre-commit hook script is obsolete. Please use pre-commit instead:${NOCOLOR}\n"
|
||||
printf " pip install pre-commit\n"
|
||||
printf " pre-commit install${NOCOLOR}\n"
|
||||
|
||||
exit 1
|
||||
Reference in New Issue
Block a user