mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-09 21:32:17 -06:00
Compare commits
15 Commits
circuit-sw
...
20660-scri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
285abe7cc0 | ||
|
|
c5124cb2e4 | ||
|
|
d01d7b4156 | ||
|
|
4db6123fb2 | ||
|
|
43648d629b | ||
|
|
0b97df0984 | ||
|
|
5334c8143c | ||
|
|
bbb330becf | ||
|
|
e4c74ce6a3 | ||
|
|
a4868f894d | ||
|
|
531ea34207 | ||
|
|
6747c82a1a | ||
|
|
e251ea10b5 | ||
|
|
a1aaf465ac | ||
|
|
2a1d315d85 |
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.6.9
|
rev: v0.14.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
name: "Ruff linter"
|
name: "Ruff linter"
|
||||||
|
|||||||
@@ -404,6 +404,61 @@ A complete date & time. Returns a `datetime.datetime` object.
|
|||||||
|
|
||||||
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button. It is possible to schedule a script to be executed at specified time in the future. A scheduled script can be canceled by deleting the associated job result object.
|
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button. It is possible to schedule a script to be executed at specified time in the future. A scheduled script can be canceled by deleting the associated job result object.
|
||||||
|
|
||||||
|
#### Prefilling variables via URL parameters
|
||||||
|
|
||||||
|
Script form fields can be prefilled by appending query parameters to the script URL. Each parameter name must match the variable name defined on the script class. Prefilled values are treated as initial values and can be edited before execution. Multiple values can be supplied by repeating the same parameter. Query values must be percent‑encoded where required (for example, spaces as `%20`).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
For string and integer variables, when a script defines:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from extras.scripts import Script, StringVar, IntegerVar
|
||||||
|
|
||||||
|
class MyScript(Script):
|
||||||
|
name = StringVar()
|
||||||
|
count = IntegerVar()
|
||||||
|
```
|
||||||
|
|
||||||
|
the following URL prefills the `name` and `count` fields:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://<netbox>/extras/scripts/<script_id>/?name=Branch42&count=3
|
||||||
|
```
|
||||||
|
|
||||||
|
For object variables (`ObjectVar`), supply the object’s primary key (PK):
|
||||||
|
|
||||||
|
```
|
||||||
|
https://<netbox>/extras/scripts/<script_id>/?device=1
|
||||||
|
```
|
||||||
|
|
||||||
|
If an object ID cannot be resolved or the object is not visible to the requesting user, the field remains unpopulated.
|
||||||
|
|
||||||
|
Supported variable types:
|
||||||
|
|
||||||
|
| Variable class | Expected input | Example query string |
|
||||||
|
|--------------------------|---------------------------------|---------------------------------------------|
|
||||||
|
| `StringVar` | string (percent‑encoded) | `?name=Branch42` |
|
||||||
|
| `TextVar` | string (percent‑encoded) | `?notes=Initial%20value` |
|
||||||
|
| `IntegerVar` | integer | `?count=3` |
|
||||||
|
| `DecimalVar` | decimal number | `?ratio=0.75` |
|
||||||
|
| `BooleanVar` | value → `True`; empty → `False` | `?enabled=true` (True), `?enabled=` (False) |
|
||||||
|
| `ChoiceVar` | choice value (not label) | `?role=edge` |
|
||||||
|
| `MultiChoiceVar` | choice values (repeat) | `?roles=edge&roles=core` |
|
||||||
|
| `ObjectVar(Device)` | PK (integer) | `?device=1` |
|
||||||
|
| `MultiObjectVar(Device)` | PKs (repeat) | `?devices=1&devices=2` |
|
||||||
|
| `IPAddressVar` | IP address | `?ip=198.51.100.10` |
|
||||||
|
| `IPAddressWithMaskVar` | IP address with mask | `?addr=192.0.2.1/24` |
|
||||||
|
| `IPNetworkVar` | IP network prefix | `?network=2001:db8::/64` |
|
||||||
|
| `DateVar` | date `YYYY-MM-DD` | `?date=2025-01-05` |
|
||||||
|
| `DateTimeVar` | ISO datetime | `?when=2025-01-05T14:30:00` |
|
||||||
|
| `FileVar` | — (not supported) | — |
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
- The parameter names above are examples; use the actual variable attribute names defined by the script.
|
||||||
|
- For `BooleanVar`, only an empty value (`?enabled=`) unchecks the box; any other value including `false` or `0` checks it.
|
||||||
|
- File uploads (`FileVar`) cannot be prefilled via URL parameters.
|
||||||
|
|
||||||
### Via the API
|
### Via the API
|
||||||
|
|
||||||
To run a script via the REST API, issue a POST request to the script's endpoint specifying the form data and commitment. For example, to run a script named `example.MyReport`, we would make a request such as the following:
|
To run a script via the REST API, issue a POST request to the script's endpoint specifying the form data and commitment. For example, to run a script named `example.MyReport`, we would make a request such as the following:
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ from netbox.filtersets import (
|
|||||||
AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
|
AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
|
||||||
OrganizationalModelFilterSet,
|
OrganizationalModelFilterSet,
|
||||||
)
|
)
|
||||||
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
|
||||||
from tenancy.models import *
|
from tenancy.models import *
|
||||||
from users.models import User
|
from users.models import User
|
||||||
from utilities.filters import (
|
from utilities.filters import (
|
||||||
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
|
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
|
||||||
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
|
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
|
||||||
)
|
)
|
||||||
from virtualization.models import Cluster, ClusterGroup, VMInterface, VirtualMachine
|
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
|
||||||
from vpn.models import L2VPN
|
from vpn.models import L2VPN
|
||||||
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
|
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
||||||
from wireless.models import WirelessLAN, WirelessLink
|
from wireless.models import WirelessLAN, WirelessLink
|
||||||
from .choices import *
|
from .choices import *
|
||||||
from .constants import *
|
from .constants import *
|
||||||
@@ -1807,6 +1807,14 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
|
|||||||
queryset=VMInterface.objects.all(),
|
queryset=VMInterface.objects.all(),
|
||||||
label=_('VM interface (ID)'),
|
label=_('VM interface (ID)'),
|
||||||
)
|
)
|
||||||
|
assigned = django_filters.BooleanFilter(
|
||||||
|
method='filter_assigned',
|
||||||
|
label=_('Is assigned'),
|
||||||
|
)
|
||||||
|
primary = django_filters.BooleanFilter(
|
||||||
|
method='filter_primary',
|
||||||
|
label=_('Is primary'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MACAddress
|
model = MACAddress
|
||||||
@@ -1843,6 +1851,29 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
|
|||||||
vminterface__in=interface_ids
|
vminterface__in=interface_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def filter_assigned(self, queryset, name, value):
|
||||||
|
params = {
|
||||||
|
'assigned_object_type__isnull': True,
|
||||||
|
'assigned_object_id__isnull': True,
|
||||||
|
}
|
||||||
|
if value:
|
||||||
|
return queryset.exclude(**params)
|
||||||
|
else:
|
||||||
|
return queryset.filter(**params)
|
||||||
|
|
||||||
|
def filter_primary(self, queryset, name, value):
|
||||||
|
interface_mac_ids = Interface.objects.filter(primary_mac_address_id__isnull=False).values_list(
|
||||||
|
'primary_mac_address_id', flat=True
|
||||||
|
)
|
||||||
|
vminterface_mac_ids = VMInterface.objects.filter(primary_mac_address_id__isnull=False).values_list(
|
||||||
|
'primary_mac_address_id', flat=True
|
||||||
|
)
|
||||||
|
query = Q(pk__in=interface_mac_ids) | Q(pk__in=vminterface_mac_ids)
|
||||||
|
if value:
|
||||||
|
return queryset.filter(query)
|
||||||
|
else:
|
||||||
|
return queryset.exclude(query)
|
||||||
|
|
||||||
|
|
||||||
class CommonInterfaceFilterSet(django_filters.FilterSet):
|
class CommonInterfaceFilterSet(django_filters.FilterSet):
|
||||||
mode = django_filters.MultipleChoiceFilter(
|
mode = django_filters.MultipleChoiceFilter(
|
||||||
|
|||||||
@@ -1676,12 +1676,16 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm):
|
|||||||
model = MACAddress
|
model = MACAddress
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
FieldSet('mac_address', 'device_id', 'virtual_machine_id', name=_('MAC address')),
|
FieldSet('mac_address', name=_('Attributes')),
|
||||||
|
FieldSet(
|
||||||
|
'device_id', 'virtual_machine_id', 'assigned', 'primary',
|
||||||
|
name=_('Assignments'),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id')
|
selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id')
|
||||||
mac_address = forms.CharField(
|
mac_address = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
label=_('MAC address')
|
label=_('MAC address'),
|
||||||
)
|
)
|
||||||
device_id = DynamicModelMultipleChoiceField(
|
device_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
@@ -1693,6 +1697,20 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Assigned VM'),
|
label=_('Assigned VM'),
|
||||||
)
|
)
|
||||||
|
assigned = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_('Assigned to an interface'),
|
||||||
|
widget=forms.Select(
|
||||||
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
|
),
|
||||||
|
)
|
||||||
|
primary = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_('Primary MAC of an interface'),
|
||||||
|
widget=forms.Select(
|
||||||
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
|
),
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -755,7 +755,10 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
|||||||
queryset=ModuleBay.objects.all(),
|
queryset=ModuleBay.objects.all(),
|
||||||
query_params={
|
query_params={
|
||||||
'device_id': '$device'
|
'device_id': '$device'
|
||||||
}
|
},
|
||||||
|
context={
|
||||||
|
'disabled': 'installed_module',
|
||||||
|
},
|
||||||
)
|
)
|
||||||
module_type = DynamicModelChoiceField(
|
module_type = DynamicModelChoiceField(
|
||||||
label=_('Module type'),
|
label=_('Module type'),
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ from netbox.graphql.filter_mixins import (
|
|||||||
ImageAttachmentFilterMixin,
|
ImageAttachmentFilterMixin,
|
||||||
WeightFilterMixin,
|
WeightFilterMixin,
|
||||||
)
|
)
|
||||||
from tenancy.graphql.filter_mixins import TenancyFilterMixin, ContactFilterMixin
|
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
|
||||||
|
from virtualization.models import VMInterface
|
||||||
|
|
||||||
from .filter_mixins import (
|
from .filter_mixins import (
|
||||||
CabledObjectModelFilterMixin,
|
CabledObjectModelFilterMixin,
|
||||||
ComponentModelFilterMixin,
|
ComponentModelFilterMixin,
|
||||||
@@ -419,6 +421,24 @@ class MACAddressFilter(PrimaryModelFilterMixin):
|
|||||||
)
|
)
|
||||||
assigned_object_id: ID | None = strawberry_django.filter_field()
|
assigned_object_id: ID | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
@strawberry_django.filter_field()
|
||||||
|
def assigned(self, value: bool, prefix) -> Q:
|
||||||
|
return Q(**{f'{prefix}assigned_object_id__isnull': (not value)})
|
||||||
|
|
||||||
|
@strawberry_django.filter_field()
|
||||||
|
def primary(self, value: bool, prefix) -> Q:
|
||||||
|
interface_mac_ids = models.Interface.objects.filter(primary_mac_address_id__isnull=False).values_list(
|
||||||
|
'primary_mac_address_id', flat=True
|
||||||
|
)
|
||||||
|
vminterface_mac_ids = VMInterface.objects.filter(primary_mac_address_id__isnull=False).values_list(
|
||||||
|
'primary_mac_address_id', flat=True
|
||||||
|
)
|
||||||
|
query = Q(**{f'{prefix}pk__in': interface_mac_ids}) | Q(**{f'{prefix}pk__in': vminterface_mac_ids})
|
||||||
|
if value:
|
||||||
|
return Q(query)
|
||||||
|
else:
|
||||||
|
return ~Q(query)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter_type(models.Interface, lookups=True)
|
@strawberry_django.filter_type(models.Interface, lookups=True)
|
||||||
class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin):
|
class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin):
|
||||||
|
|||||||
@@ -1174,6 +1174,9 @@ class MACAddressTable(NetBoxTable):
|
|||||||
orderable=False,
|
orderable=False,
|
||||||
verbose_name=_('Parent')
|
verbose_name=_('Parent')
|
||||||
)
|
)
|
||||||
|
is_primary = columns.BooleanColumn(
|
||||||
|
verbose_name=_('Primary')
|
||||||
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:macaddress_list'
|
url_name='dcim:macaddress_list'
|
||||||
)
|
)
|
||||||
@@ -1184,7 +1187,7 @@ class MACAddressTable(NetBoxTable):
|
|||||||
class Meta(DeviceComponentTable.Meta):
|
class Meta(DeviceComponentTable.Meta):
|
||||||
model = models.MACAddress
|
model = models.MACAddress
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'comments', 'tags',
|
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'is_primary',
|
||||||
'created', 'last_updated',
|
'comments', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description')
|
default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description')
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from netbox.choices import ColorChoices, WeightUnitChoices
|
|||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from users.models import User
|
from users.models import User
|
||||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
||||||
from virtualization.models import Cluster, ClusterType, ClusterGroup, VMInterface, VirtualMachine
|
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||||
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
||||||
from wireless.models import WirelessLink
|
from wireless.models import WirelessLink
|
||||||
|
|
||||||
@@ -7164,9 +7164,20 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
MACAddress(mac_address='00-00-00-05-01-01', assigned_object=vm_interfaces[1]),
|
MACAddress(mac_address='00-00-00-05-01-01', assigned_object=vm_interfaces[1]),
|
||||||
MACAddress(mac_address='00-00-00-06-01-01', assigned_object=vm_interfaces[2]),
|
MACAddress(mac_address='00-00-00-06-01-01', assigned_object=vm_interfaces[2]),
|
||||||
MACAddress(mac_address='00-00-00-06-01-02', assigned_object=vm_interfaces[2]),
|
MACAddress(mac_address='00-00-00-06-01-02', assigned_object=vm_interfaces[2]),
|
||||||
|
# unassigned
|
||||||
|
MACAddress(mac_address='00-00-00-07-01-01'),
|
||||||
)
|
)
|
||||||
MACAddress.objects.bulk_create(mac_addresses)
|
MACAddress.objects.bulk_create(mac_addresses)
|
||||||
|
|
||||||
|
# Set MAC addresses as primary
|
||||||
|
for idx, interface in enumerate(interfaces):
|
||||||
|
interface.primary_mac_address = mac_addresses[idx]
|
||||||
|
interface.save()
|
||||||
|
for idx, vm_interface in enumerate(vm_interfaces):
|
||||||
|
# Offset by 4 for device MACs
|
||||||
|
vm_interface.primary_mac_address = mac_addresses[idx + 4]
|
||||||
|
vm_interface.save()
|
||||||
|
|
||||||
def test_mac_address(self):
|
def test_mac_address(self):
|
||||||
params = {'mac_address': ['00-00-00-01-01-01', '00-00-00-02-01-01']}
|
params = {'mac_address': ['00-00-00-01-01-01', '00-00-00-02-01-01']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
@@ -7198,3 +7209,15 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
params = {'vminterface': [vm_interfaces[0].name, vm_interfaces[1].name]}
|
params = {'vminterface': [vm_interfaces[0].name, vm_interfaces[1].name]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_assigned(self):
|
||||||
|
params = {'assigned': True}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
|
||||||
|
params = {'assigned': False}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
|
def test_primary(self):
|
||||||
|
params = {'primary': True}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||||
|
params = {'primary': False}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from rest_framework import serializers
|
|||||||
from core.api.serializers_.jobs import JobSerializer
|
from core.api.serializers_.jobs import JobSerializer
|
||||||
from extras.models import Script
|
from extras.models import Script
|
||||||
from netbox.api.serializers import ValidatedModelSerializer
|
from netbox.api.serializers import ValidatedModelSerializer
|
||||||
|
from utilities.datetime import local_now
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ScriptDetailSerializer',
|
'ScriptDetailSerializer',
|
||||||
@@ -66,11 +67,31 @@ class ScriptInputSerializer(serializers.Serializer):
|
|||||||
interval = serializers.IntegerField(required=False, allow_null=True)
|
interval = serializers.IntegerField(required=False, allow_null=True)
|
||||||
|
|
||||||
def validate_schedule_at(self, value):
|
def validate_schedule_at(self, value):
|
||||||
if value and not self.context['script'].python_class.scheduling_enabled:
|
"""
|
||||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
Validates the specified schedule time for a script execution.
|
||||||
|
"""
|
||||||
|
if value:
|
||||||
|
if not self.context['script'].python_class.scheduling_enabled:
|
||||||
|
raise serializers.ValidationError(_('Scheduling is not enabled for this script.'))
|
||||||
|
if value < local_now():
|
||||||
|
raise serializers.ValidationError(_('Scheduled time must be in the future.'))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate_interval(self, value):
|
def validate_interval(self, value):
|
||||||
|
"""
|
||||||
|
Validates the provided interval based on the script's scheduling configuration.
|
||||||
|
"""
|
||||||
if value and not self.context['script'].python_class.scheduling_enabled:
|
if value and not self.context['script'].python_class.scheduling_enabled:
|
||||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
raise serializers.ValidationError(_('Scheduling is not enabled for this script.'))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
"""
|
||||||
|
Validates the given data and ensures the necessary fields are populated.
|
||||||
|
"""
|
||||||
|
# Set the schedule_at time to now if only an interval is provided
|
||||||
|
# while handling the case where schedule_at is null.
|
||||||
|
if data.get('interval') and not data.get('schedule_at'):
|
||||||
|
data['schedule_at'] = local_now()
|
||||||
|
|
||||||
|
return super().validate(data)
|
||||||
|
|||||||
@@ -535,6 +535,15 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
# URL
|
# URL
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||||
field = LaxURLField(assume_scheme='https', required=required, initial=initial)
|
field = LaxURLField(assume_scheme='https', required=required, initial=initial)
|
||||||
|
if self.validation_regex:
|
||||||
|
field.validators = [
|
||||||
|
RegexValidator(
|
||||||
|
regex=self.validation_regex,
|
||||||
|
message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
|
||||||
|
regex=escape(self.validation_regex)
|
||||||
|
))
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
# JSON
|
# JSON
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
|
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
|
||||||
@@ -684,6 +693,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
if self.validation_regex and not re.match(self.validation_regex, value):
|
if self.validation_regex and not re.match(self.validation_regex, value):
|
||||||
raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
|
raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
|
||||||
|
|
||||||
|
# Validate URL field
|
||||||
|
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||||
|
if type(value) is not str:
|
||||||
|
raise ValidationError(_("Value must be a string."))
|
||||||
|
if self.validation_regex and not re.match(self.validation_regex, value):
|
||||||
|
raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
|
||||||
|
|
||||||
# Validate integer
|
# Validate integer
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||||
if type(value) is not int:
|
if type(value) is not int:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import importlib.util
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
from django.core.files.storage import storages
|
from django.core.files.storage import storages
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
@@ -30,7 +31,14 @@ class CustomStoragesLoader(importlib.abc.Loader):
|
|||||||
return None # Use default module creation
|
return None # Use default module creation
|
||||||
|
|
||||||
def exec_module(self, module):
|
def exec_module(self, module):
|
||||||
storage = storages.create_storage(storages.backends["scripts"])
|
# Cache storage for 5 minutes (300 seconds)
|
||||||
|
cache_key = "storage_scripts"
|
||||||
|
storage = cache.get(cache_key)
|
||||||
|
|
||||||
|
if storage is None:
|
||||||
|
storage = storages['scripts']
|
||||||
|
cache.set(cache_key, storage, timeout=300) # 5 minutes
|
||||||
|
|
||||||
with storage.open(self.filename, 'rb') as f:
|
with storage.open(self.filename, 'rb') as f:
|
||||||
code = f.read()
|
code = f.read()
|
||||||
exec(code, module.__dict__)
|
exec(code, module.__dict__)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import datetime
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import make_aware, now
|
from django.utils.timezone import make_aware, now
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
from core.choices import ManagedFileRootPathChoices
|
from core.choices import ManagedFileRootPathChoices
|
||||||
from core.events import *
|
from core.events import *
|
||||||
@@ -858,16 +859,16 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||||||
class ScriptTest(APITestCase):
|
class ScriptTest(APITestCase):
|
||||||
|
|
||||||
class TestScriptClass(PythonClass):
|
class TestScriptClass(PythonClass):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
name = "Test script"
|
name = 'Test script'
|
||||||
|
commit = True
|
||||||
|
scheduling_enabled = True
|
||||||
|
|
||||||
var1 = StringVar()
|
var1 = StringVar()
|
||||||
var2 = IntegerVar()
|
var2 = IntegerVar()
|
||||||
var3 = BooleanVar()
|
var3 = BooleanVar()
|
||||||
|
|
||||||
def run(self, data, commit=True):
|
def run(self, data, commit=True):
|
||||||
|
|
||||||
self.log_info(data['var1'])
|
self.log_info(data['var1'])
|
||||||
self.log_success(data['var2'])
|
self.log_success(data['var2'])
|
||||||
self.log_failure(data['var3'])
|
self.log_failure(data['var3'])
|
||||||
@@ -878,14 +879,16 @@ class ScriptTest(APITestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
module = ScriptModule.objects.create(
|
module = ScriptModule.objects.create(
|
||||||
file_root=ManagedFileRootPathChoices.SCRIPTS,
|
file_root=ManagedFileRootPathChoices.SCRIPTS,
|
||||||
file_path='/var/tmp/script.py'
|
file_path='script.py',
|
||||||
)
|
)
|
||||||
Script.objects.create(
|
script = Script.objects.create(
|
||||||
module=module,
|
module=module,
|
||||||
name="Test script",
|
name='Test script',
|
||||||
is_executable=True,
|
is_executable=True,
|
||||||
)
|
)
|
||||||
|
cls.url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
|
||||||
|
|
||||||
|
@property
|
||||||
def python_class(self):
|
def python_class(self):
|
||||||
return self.TestScriptClass
|
return self.TestScriptClass
|
||||||
|
|
||||||
@@ -898,7 +901,7 @@ class ScriptTest(APITestCase):
|
|||||||
def test_get_script(self):
|
def test_get_script(self):
|
||||||
module = ScriptModule.objects.get(
|
module = ScriptModule.objects.get(
|
||||||
file_root=ManagedFileRootPathChoices.SCRIPTS,
|
file_root=ManagedFileRootPathChoices.SCRIPTS,
|
||||||
file_path='/var/tmp/script.py'
|
file_path='script.py',
|
||||||
)
|
)
|
||||||
script = module.scripts.all().first()
|
script = module.scripts.all().first()
|
||||||
url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
|
url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
|
||||||
@@ -909,6 +912,76 @@ class ScriptTest(APITestCase):
|
|||||||
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
|
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
|
||||||
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
|
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
|
||||||
|
|
||||||
|
def test_schedule_script_past_time_rejected(self):
|
||||||
|
"""
|
||||||
|
Scheduling with past schedule_at should fail.
|
||||||
|
"""
|
||||||
|
self.add_permissions('extras.run_script')
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'data': {'var1': 'hello', 'var2': 1, 'var3': False},
|
||||||
|
'commit': True,
|
||||||
|
'schedule_at': now() - datetime.timedelta(hours=1),
|
||||||
|
}
|
||||||
|
response = self.client.post(self.url, payload, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn('schedule_at', response.data)
|
||||||
|
# Be tolerant of exact wording but ensure we failed on schedule_at being in the past
|
||||||
|
self.assertIn('future', str(response.data['schedule_at']).lower())
|
||||||
|
|
||||||
|
def test_schedule_script_interval_only(self):
|
||||||
|
"""
|
||||||
|
Interval without schedule_at should auto-set schedule_at now.
|
||||||
|
"""
|
||||||
|
self.add_permissions('extras.run_script')
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'data': {'var1': 'hello', 'var2': 1, 'var3': False},
|
||||||
|
'commit': True,
|
||||||
|
'interval': 60,
|
||||||
|
}
|
||||||
|
response = self.client.post(self.url, payload, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
|
# The latest job is returned in the script detail serializer under "result"
|
||||||
|
self.assertIn('result', response.data)
|
||||||
|
self.assertEqual(response.data['result']['interval'], 60)
|
||||||
|
# Ensure a start time was autopopulated
|
||||||
|
self.assertIsNotNone(response.data['result']['scheduled'])
|
||||||
|
|
||||||
|
def test_schedule_script_when_disabled(self):
|
||||||
|
"""
|
||||||
|
Scheduling should fail when script.scheduling_enabled=False.
|
||||||
|
"""
|
||||||
|
self.add_permissions('extras.run_script')
|
||||||
|
|
||||||
|
# Temporarily disable scheduling on the in-test Python class
|
||||||
|
original = getattr(self.TestScriptClass.Meta, 'scheduling_enabled', True)
|
||||||
|
self.TestScriptClass.Meta.scheduling_enabled = False
|
||||||
|
base = {
|
||||||
|
'data': {'var1': 'hello', 'var2': 1, 'var3': False},
|
||||||
|
'commit': True,
|
||||||
|
}
|
||||||
|
# Check both schedule_at and interval paths
|
||||||
|
cases = [
|
||||||
|
{**base, 'schedule_at': now() + datetime.timedelta(minutes=5)},
|
||||||
|
{**base, 'interval': 60},
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
for case in cases:
|
||||||
|
with self.subTest(case=list(case.keys())):
|
||||||
|
response = self.client.post(self.url, case, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||||
|
# Error should be attached to whichever field we used
|
||||||
|
key = 'schedule_at' if 'schedule_at' in case else 'interval'
|
||||||
|
self.assertIn(key, response.data)
|
||||||
|
self.assertIn('scheduling is not enabled', str(response.data[key]).lower())
|
||||||
|
finally:
|
||||||
|
# Restore the original setting for other tests
|
||||||
|
self.TestScriptClass.Meta.scheduling_enabled = original
|
||||||
|
|
||||||
|
|
||||||
class CreatedUpdatedFilterTest(APITestCase):
|
class CreatedUpdatedFilterTest(APITestCase):
|
||||||
|
|
||||||
|
|||||||
@@ -1300,6 +1300,28 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
response = self.client.patch(url, data, format='json', **self.header)
|
response = self.client.patch(url, data, format='json', **self.header)
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_url_regex_validation(self):
|
||||||
|
"""
|
||||||
|
Test that validation_regex is applied to URL custom fields (fixes #20498).
|
||||||
|
"""
|
||||||
|
site2 = Site.objects.get(name='Site 2')
|
||||||
|
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||||
|
self.add_permissions('dcim.change_site')
|
||||||
|
|
||||||
|
cf_url = CustomField.objects.get(name='url_field')
|
||||||
|
cf_url.validation_regex = r'^https://' # Require HTTPS
|
||||||
|
cf_url.save()
|
||||||
|
|
||||||
|
# Test invalid URL (http instead of https)
|
||||||
|
data = {'custom_fields': {'url_field': 'http://example.com'}}
|
||||||
|
response = self.client.patch(url, data, format='json', **self.header)
|
||||||
|
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Test valid URL (https)
|
||||||
|
data = {'custom_fields': {'url_field': 'https://example.com'}}
|
||||||
|
response = self.client.patch(url, data, format='json', **self.header)
|
||||||
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_uniqueness_validation(self):
|
def test_uniqueness_validation(self):
|
||||||
# Create a unique custom field
|
# Create a unique custom field
|
||||||
cf_text = CustomField.objects.get(name='text_field')
|
cf_text = CustomField.objects.get(name='text_field')
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
|
|||||||
|
|
||||||
@strawberry_django.filter_field()
|
@strawberry_django.filter_field()
|
||||||
def assigned(self, value: bool, prefix) -> Q:
|
def assigned(self, value: bool, prefix) -> Q:
|
||||||
return Q(assigned_object_id__isnull=(not value))
|
return Q(**{f"{prefix}assigned_object_id__isnull": not value})
|
||||||
|
|
||||||
@strawberry_django.filter_field()
|
@strawberry_django.filter_field()
|
||||||
def parent(self, value: list[str], prefix) -> Q:
|
def parent(self, value: list[str], prefix) -> Q:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import django_tables2 as tables
|
|||||||
|
|
||||||
from ipam.models import *
|
from ipam.models import *
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
|
from tenancy.tables import ContactsColumnMixin
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ServiceTable',
|
'ServiceTable',
|
||||||
@@ -35,7 +36,7 @@ class ServiceTemplateTable(NetBoxTable):
|
|||||||
default_columns = ('pk', 'name', 'protocol', 'ports', 'description')
|
default_columns = ('pk', 'name', 'protocol', 'ports', 'description')
|
||||||
|
|
||||||
|
|
||||||
class ServiceTable(NetBoxTable):
|
class ServiceTable(ContactsColumnMixin, NetBoxTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
@@ -60,7 +61,7 @@ class ServiceTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Service
|
model = Service
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags',
|
'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'contacts', 'comments',
|
||||||
'created', 'last_updated',
|
'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')
|
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')
|
||||||
|
|||||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js
vendored
2
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -20,11 +20,13 @@ function slugify(slug: string, chars: number): string {
|
|||||||
* For any slug fields, add event listeners to handle automatically generating slug values.
|
* For any slug fields, add event listeners to handle automatically generating slug values.
|
||||||
*/
|
*/
|
||||||
export function initReslug(): void {
|
export function initReslug(): void {
|
||||||
for (const slugButton of getElements<HTMLButtonElement>('button#reslug')) {
|
for (const slugButton of getElements<HTMLButtonElement>('button.reslug')) {
|
||||||
const form = slugButton.form;
|
const form = slugButton.form;
|
||||||
if (form == null) continue;
|
if (form == null) continue;
|
||||||
const slugField = form.querySelector('#id_slug') as HTMLInputElement;
|
|
||||||
|
const slugField = form.querySelector('input.slug-field') as HTMLInputElement;
|
||||||
if (slugField == null) continue;
|
if (slugField == null) continue;
|
||||||
|
|
||||||
const sourceId = slugField.getAttribute('slug-source');
|
const sourceId = slugField.getAttribute('slug-source');
|
||||||
const sourceField = form.querySelector(`#id_${sourceId}`) as HTMLInputElement;
|
const sourceField = form.querySelector(`#id_${sourceId}`) as HTMLInputElement;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ pre {
|
|||||||
background: var(--#{$prefix}bg-surface);
|
background: var(--#{$prefix}bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permit copying of badge text
|
||||||
|
.badge {
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
// Button adjustments
|
// Button adjustments
|
||||||
.btn {
|
.btn {
|
||||||
// Tabler sets display: flex
|
// Tabler sets display: flex
|
||||||
|
|||||||
@@ -37,23 +37,23 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow" {% htmx_boost %}>
|
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow" {% htmx_boost %}>
|
||||||
<a href="{% url 'account:profile' %}" class="dropdown-item">
|
<a href="{% url 'account:profile' %}" class="dropdown-item">
|
||||||
<i class="mdi mdi-account"></i> {% trans "Profile" %}
|
<i class="dropdown-item-icon mdi mdi-account"></i> {% trans "Profile" %}
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'account:bookmarks' %}" class="dropdown-item">
|
<a href="{% url 'account:bookmarks' %}" class="dropdown-item">
|
||||||
<i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
|
<i class="dropdown-item-icon mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'account:subscriptions' %}" class="dropdown-item">
|
<a href="{% url 'account:subscriptions' %}" class="dropdown-item">
|
||||||
<i class="mdi mdi-bell"></i> {% trans "Subscriptions" %}
|
<i class="dropdown-item-icon mdi mdi-bell"></i> {% trans "Subscriptions" %}
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'account:preferences' %}" class="dropdown-item">
|
<a href="{% url 'account:preferences' %}" class="dropdown-item">
|
||||||
<i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
|
<i class="dropdown-item-icon mdi mdi-wrench"></i> {% trans "Preferences" %}
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'account:usertoken_list' %}" class="dropdown-item">
|
<a href="{% url 'account:usertoken_list' %}" class="dropdown-item">
|
||||||
<i class="mdi mdi-key"></i> {% trans "API Tokens" %}
|
<i class="dropdown-item-icon mdi mdi-key"></i> {% trans "API Tokens" %}
|
||||||
</a>
|
</a>
|
||||||
<hr class="dropdown-divider" />
|
<hr class="dropdown-divider" />
|
||||||
<a href="{% url 'logout' %}" hx-disable="true" class="dropdown-item">
|
<a href="{% url 'logout' %}" hx-disable="true" class="dropdown-item">
|
||||||
<i class="mdi mdi-logout-variant"></i> {% trans "Log Out" %}
|
<i class="dropdown-item-icon mdi mdi-logout-variant"></i> {% trans "Log Out" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,14 @@ class SlugField(forms.SlugField):
|
|||||||
|
|
||||||
self.widget.attrs['slug-source'] = slug_source
|
self.widget.attrs['slug-source'] = slug_source
|
||||||
|
|
||||||
|
def get_bound_field(self, form, field_name):
|
||||||
|
if prefix := form.prefix:
|
||||||
|
slug_source = self.widget.attrs.get('slug-source')
|
||||||
|
if slug_source and not slug_source.startswith(f'{prefix}-'):
|
||||||
|
self.widget.attrs['slug-source'] = f"{prefix}-{slug_source}"
|
||||||
|
|
||||||
|
return super().get_bound_field(form, field_name)
|
||||||
|
|
||||||
|
|
||||||
class ColorField(forms.CharField):
|
class ColorField(forms.CharField):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ class SlugWidget(forms.TextInput):
|
|||||||
"""
|
"""
|
||||||
template_name = 'widgets/sluginput.html'
|
template_name = 'widgets/sluginput.html'
|
||||||
|
|
||||||
|
def __init__(self, attrs=None):
|
||||||
|
local_attrs = {} if attrs is None else attrs.copy()
|
||||||
|
if 'class' in local_attrs:
|
||||||
|
local_attrs['class'] = f"{local_attrs['class']} slug-field"
|
||||||
|
else:
|
||||||
|
local_attrs['class'] = 'slug-field'
|
||||||
|
super().__init__(local_attrs)
|
||||||
|
|
||||||
|
|
||||||
class ArrayWidget(forms.Textarea):
|
class ArrayWidget(forms.Textarea):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
{% if field|widget_type == 'slugwidget' %}
|
{% if field|widget_type == 'slugwidget' %}
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
{{ field }}
|
{{ field }}
|
||||||
<button id="reslug" type="button" title="{% trans "Regenerate Slug" %}" class="btn">
|
<button type="button" title="{% trans "Regenerate Slug" %}" class="btn reslug">
|
||||||
<i class="mdi mdi-reload"></i>
|
<i class="mdi mdi-reload"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user