Compare commits

...

15 Commits

Author SHA1 Message Date
Arthur
285abe7cc0 20660 cache script storage key 2025-10-22 11:22:53 -07:00
Martin Hauser
c5124cb2e4 feat(templates): Update user menu icon class names for consistency
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
Switch icons in the top-right User dropdown to Tabler’s
`dropdown-item-icon` to standardize spacing between the icon and label.
Improves readability and ensures alignment with the overall UI styling.

Fixes #20608
2025-10-21 08:35:50 -04:00
Jason Novinger
d01d7b4156 Fixes #20551: Support quick-add form prefix in automatic slug generation (#20624)
* Fixes #20551: Support quick-add form prefix in automatic slug generation

The slug generation logic in `reslug.ts` looks for form fields using hard-coded ID selectors like `#id_slug` and `#id_name`. In quick-add modals, Django applies a `quickadd` prefix to form fields (introduced in #20542), resulting in IDs like `#id_quickadd-slug` and `#id_quickadd-name`. The logic couldn't find these prefixed fields, so automatic slug generation failed silently in quick-add modals. This fix updates the field selectors to try both unprefixed and prefixed patterns using the nullish coalescing operator (`??`), checking for the standard field ID first and falling back to the quickadd-prefixed ID if the standard one isn't found.

* Address PR feedback

The slug generation logic required updates to support form prefixes like `quickadd`. Python-side changes
ensure `SlugField.get_bound_field()` updates the `slug-source` attribute to include the form prefix when
present, so JavaScript receives the correct prefixed field ID. `SlugWidget.__init__()` now adds a
`slug-field` class to enable selector-based field discovery. On the frontend, `reslug.ts` now uses class
selectors (`button.reslug` and `input.slug-field`) instead of ID-based lookups, eliminating the need for
fallback logic. The template was updated to use `class="reslug"` instead of `id="reslug"` on the button to
avoid ID duplication issues.
2025-10-21 08:33:10 -04:00
github-actions
4db6123fb2 Update source translation strings
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-10-21 05:03:30 +00:00
Jeremy Stretch
43648d629b Fixes #20606: Enable copying text from badges in UI (#20633) 2025-10-20 17:12:42 -05:00
bctiemann
0b97df0984 Merge pull request #20625 from netbox-community/20498-url-custom-field-validation-regex
Fixes #20498: Apply validation regex to URL custom fields
2025-10-20 15:30:33 -04:00
Martin Hauser
5334c8143c feat(forms): Add context handling for ModuleBay field (#20586) 2025-10-20 10:16:53 -07:00
Martin Hauser
bbb330becf feat(filtersets): Add assigned and primary filters for MACAddress (#20620)
Introduce Boolean filters `assigned` and `primary` to the MACAddress
filterset, improving filtering capabilities. Update forms, tables, and
GraphQL queries to incorporate the new filters. Add tests to validate
the correct functionality.

Fixes #20399
2025-10-20 10:01:25 -07:00
Jeremy Stretch
e4c74ce6a3 Closes #20614: Update ruff for pre-commit check (#20631) 2025-10-20 09:07:12 -07:00
Martin Hauser
a4868f894d feat(ipam): Add ContactsColumnMixin to ServiceTable
Enhance `ServiceTable` by incorporating `ContactsColumnMixin` for better
contact management. Updates the fields to include `contacts`.

Fixes #20567
2025-10-20 09:07:25 -04:00
github-actions
531ea34207 Update source translation strings 2025-10-20 05:03:22 +00:00
Jason Novinger
6747c82a1a Fixes #20498: Apply validation regex to URL custom fields
The validation_regex field was not being enforced for URL type custom
fields. This fix adds regex validation in two places:

1. to_form_field() - Applies regex validator to form fields (UI validation)
2. validate() - Applies regex check in model validation (API/programmatic)

Note: The original issue reported UI validation only, but this fix also
adds API validation for consistency with text field behavior and to
ensure data integrity across all entry points.
2025-10-19 18:30:54 -05:00
Martin Hauser
e251ea10b5 Closes #20605: Document variable prefilling via URL parameters (#20619) 2025-10-19 15:42:09 -05:00
Martin Hauser
a1aaf465ac Fixes #20466: Correct handling of assigned filter logic (#20538) 2025-10-19 12:51:44 -05:00
Martin Hauser
2a1d315d85 Fixes #20524: Enhance API script scheduling validation (#20616) 2025-10-19 12:29:14 -05:00
25 changed files with 589 additions and 263 deletions

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
rev: v0.14.1
hooks:
- id: ruff
name: "Ruff linter"

View File

@@ -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.
#### 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 percentencoded 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 objects 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 (percentencoded) | `?name=Branch42` |
| `TextVar` | string (percentencoded) | `?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
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:

View File

@@ -14,16 +14,16 @@ from netbox.filtersets import (
AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
OrganizationalModelFilterSet,
)
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from tenancy.models import *
from users.models import User
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster, ClusterGroup, VMInterface, VirtualMachine
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
from vpn.models import L2VPN
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
from wireless.models import WirelessLAN, WirelessLink
from .choices import *
from .constants import *
@@ -1807,6 +1807,14 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
queryset=VMInterface.objects.all(),
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:
model = MACAddress
@@ -1843,6 +1851,29 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
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):
mode = django_filters.MultipleChoiceFilter(

View File

@@ -1676,12 +1676,16 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm):
model = MACAddress
fieldsets = (
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')
mac_address = forms.CharField(
required=False,
label=_('MAC address')
label=_('MAC address'),
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -1693,6 +1697,20 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm):
required=False,
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)

View File

@@ -755,7 +755,10 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
queryset=ModuleBay.objects.all(),
query_params={
'device_id': '$device'
}
},
context={
'disabled': 'installed_module',
},
)
module_type = DynamicModelChoiceField(
label=_('Module type'),

View File

@@ -18,7 +18,9 @@ from netbox.graphql.filter_mixins import (
ImageAttachmentFilterMixin,
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 (
CabledObjectModelFilterMixin,
ComponentModelFilterMixin,
@@ -419,6 +421,24 @@ class MACAddressFilter(PrimaryModelFilterMixin):
)
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)
class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin):

View File

@@ -1174,6 +1174,9 @@ class MACAddressTable(NetBoxTable):
orderable=False,
verbose_name=_('Parent')
)
is_primary = columns.BooleanColumn(
verbose_name=_('Primary')
)
tags = columns.TagColumn(
url_name='dcim:macaddress_list'
)
@@ -1184,7 +1187,7 @@ class MACAddressTable(NetBoxTable):
class Meta(DeviceComponentTable.Meta):
model = models.MACAddress
fields = (
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'comments', 'tags',
'created', 'last_updated',
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'is_primary',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description')

View File

@@ -10,7 +10,7 @@ from netbox.choices import ColorChoices, WeightUnitChoices
from tenancy.models import Tenant, TenantGroup
from users.models import User
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.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-06-01-01', 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)
# 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):
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)
@@ -7198,3 +7209,15 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'vminterface': [vm_interfaces[0].name, vm_interfaces[1].name]}
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)

View File

@@ -5,6 +5,7 @@ from rest_framework import serializers
from core.api.serializers_.jobs import JobSerializer
from extras.models import Script
from netbox.api.serializers import ValidatedModelSerializer
from utilities.datetime import local_now
__all__ = (
'ScriptDetailSerializer',
@@ -66,11 +67,31 @@ class ScriptInputSerializer(serializers.Serializer):
interval = serializers.IntegerField(required=False, allow_null=True)
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
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:
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
raise serializers.ValidationError(_('Scheduling is not enabled for this script.'))
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)

View File

@@ -535,6 +535,15 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# URL
elif self.type == CustomFieldTypeChoices.TYPE_URL:
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
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):
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
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
if type(value) is not int:

View File

@@ -3,6 +3,7 @@ import importlib.util
import os
import sys
from django.core.cache import cache
from django.core.files.storage import storages
from django.db import models
from django.http import HttpResponse
@@ -30,7 +31,14 @@ class CustomStoragesLoader(importlib.abc.Loader):
return None # Use default module creation
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:
code = f.read()
exec(code, module.__dict__)

View File

@@ -3,6 +3,7 @@ import datetime
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.timezone import make_aware, now
from rest_framework import status
from core.choices import ManagedFileRootPathChoices
from core.events import *
@@ -858,16 +859,16 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
class ScriptTest(APITestCase):
class TestScriptClass(PythonClass):
class Meta:
name = "Test script"
name = 'Test script'
commit = True
scheduling_enabled = True
var1 = StringVar()
var2 = IntegerVar()
var3 = BooleanVar()
def run(self, data, commit=True):
self.log_info(data['var1'])
self.log_success(data['var2'])
self.log_failure(data['var3'])
@@ -878,14 +879,16 @@ class ScriptTest(APITestCase):
def setUpTestData(cls):
module = ScriptModule.objects.create(
file_root=ManagedFileRootPathChoices.SCRIPTS,
file_path='/var/tmp/script.py'
file_path='script.py',
)
Script.objects.create(
script = Script.objects.create(
module=module,
name="Test script",
name='Test script',
is_executable=True,
)
cls.url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
@property
def python_class(self):
return self.TestScriptClass
@@ -898,7 +901,7 @@ class ScriptTest(APITestCase):
def test_get_script(self):
module = ScriptModule.objects.get(
file_root=ManagedFileRootPathChoices.SCRIPTS,
file_path='/var/tmp/script.py'
file_path='script.py',
)
script = module.scripts.all().first()
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']['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):

View File

@@ -1300,6 +1300,28 @@ class CustomFieldAPITest(APITestCase):
response = self.client.patch(url, data, format='json', **self.header)
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):
# Create a unique custom field
cf_text = CustomField.objects.get(name='text_field')

View File

@@ -170,7 +170,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
@strawberry_django.filter_field()
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()
def parent(self, value: list[str], prefix) -> Q:

View File

@@ -3,6 +3,7 @@ import django_tables2 as tables
from ipam.models import *
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin
__all__ = (
'ServiceTable',
@@ -35,7 +36,7 @@ class ServiceTemplateTable(NetBoxTable):
default_columns = ('pk', 'name', 'protocol', 'ports', 'description')
class ServiceTable(NetBoxTable):
class ServiceTable(ContactsColumnMixin, NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
@@ -60,7 +61,7 @@ class ServiceTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Service
fields = (
'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags',
'created', 'last_updated',
'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'contacts', 'comments',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -20,11 +20,13 @@ function slugify(slug: string, chars: number): string {
* For any slug fields, add event listeners to handle automatically generating slug values.
*/
export function initReslug(): void {
for (const slugButton of getElements<HTMLButtonElement>('button#reslug')) {
for (const slugButton of getElements<HTMLButtonElement>('button.reslug')) {
const form = slugButton.form;
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;
const sourceId = slugField.getAttribute('slug-source');
const sourceField = form.querySelector(`#id_${sourceId}`) as HTMLInputElement;

View File

@@ -16,6 +16,11 @@ pre {
background: var(--#{$prefix}bg-surface);
}
// Permit copying of badge text
.badge {
user-select: text;
}
// Button adjustments
.btn {
// Tabler sets display: flex

View File

@@ -37,23 +37,23 @@
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow" {% htmx_boost %}>
<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 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 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 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 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>
<hr class="dropdown-divider" />
<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>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,14 @@ class SlugField(forms.SlugField):
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):
"""

View File

@@ -56,6 +56,14 @@ class SlugWidget(forms.TextInput):
"""
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):
"""

View File

@@ -19,7 +19,7 @@
{% if field|widget_type == 'slugwidget' %}
<div class="input-group">
{{ 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>
</button>
</div>