Compare commits

..

10 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
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
19 changed files with 418 additions and 243 deletions

View File

@@ -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(

View File

@@ -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)

View File

@@ -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'),

View File

@@ -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):

View File

@@ -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')

View File

@@ -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)

View File

@@ -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:

View File

@@ -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__)

View File

@@ -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')

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. * 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;

View File

@@ -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

View File

@@ -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

View File

@@ -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):
""" """

View File

@@ -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):
""" """

View File

@@ -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>