mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-06 20:17:29 -06:00
Compare commits
10 Commits
20614-upda
...
20660-scri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
285abe7cc0 | ||
|
|
c5124cb2e4 | ||
|
|
d01d7b4156 | ||
|
|
4db6123fb2 | ||
|
|
43648d629b | ||
|
|
0b97df0984 | ||
|
|
5334c8143c | ||
|
|
bbb330becf | ||
|
|
e4c74ce6a3 | ||
|
|
6747c82a1a |
@@ -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)
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
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