mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-19 10:08:44 -06:00
Compare commits
33 Commits
v4.4.7
...
16a46d0e11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16a46d0e11 | ||
|
|
a4ee323cb6 | ||
|
|
17e5184a11 | ||
|
|
e1548bb290 | ||
|
|
269112a565 | ||
|
|
c6672538ac | ||
|
|
6efb258b9f | ||
|
|
cf16a29ad3 | ||
|
|
544c97d923 | ||
|
|
77ee6baa23 | ||
|
|
09d1049267 | ||
|
|
da1e0f4b53 | ||
|
|
7f39f75d3d | ||
|
|
ebf8f7fa1b | ||
|
|
922b08c0ff | ||
|
|
84864fa5e1 | ||
|
|
767dfccd8f | ||
|
|
93e5f919ba | ||
|
|
dc4bab7477 | ||
|
|
929d024003 | ||
|
|
e4b614038e | ||
|
|
3016b1d90b | ||
|
|
57b47dc1ea | ||
|
|
60aa952eb1 | ||
|
|
da4c669312 | ||
|
|
71f707b7ac | ||
|
|
e11508dd6c | ||
|
|
5b5b5c8909 | ||
|
|
a49869af42 | ||
|
|
2e0ff04f84 | ||
|
|
bfeba36514 | ||
|
|
111aca115b | ||
|
|
b4160ad59b |
@@ -12,7 +12,7 @@ Depending on its classification, each NetBox model may support various features
|
|||||||
|
|
||||||
| Feature | Feature Mixin | Registry Key | Description |
|
| Feature | Feature Mixin | Registry Key | Description |
|
||||||
|------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
|
|------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
|
||||||
| [Bookmarks](../features/customization.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
|
| [Bookmarks](../features/user-preferences.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
|
||||||
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | `change_logging` | Changes to these objects are automatically recorded in the change log |
|
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | `change_logging` | Changes to these objects are automatically recorded in the change log |
|
||||||
| Cloning | `CloningMixin` | `cloning` | Provides the `clone()` method to prepare a copy |
|
| Cloning | `CloningMixin` | `cloning` | Provides the `clone()` method to prepare a copy |
|
||||||
| [Contacts](../features/contacts.md) | `ContactsMixin` | `contacts` | Contacts can be associated with these models |
|
| [Contacts](../features/contacts.md) | `ContactsMixin` | `contacts` | Contacts can be associated with these models |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class DataSourceStatusChoices(ChoiceSet):
|
|||||||
SYNCING = 'syncing'
|
SYNCING = 'syncing'
|
||||||
COMPLETED = 'completed'
|
COMPLETED = 'completed'
|
||||||
FAILED = 'failed'
|
FAILED = 'failed'
|
||||||
|
READY = 'ready'
|
||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
(NEW, _('New'), 'blue'),
|
(NEW, _('New'), 'blue'),
|
||||||
@@ -20,6 +21,7 @@ class DataSourceStatusChoices(ChoiceSet):
|
|||||||
(SYNCING, _('Syncing'), 'cyan'),
|
(SYNCING, _('Syncing'), 'cyan'),
|
||||||
(COMPLETED, _('Completed'), 'green'),
|
(COMPLETED, _('Completed'), 'green'),
|
||||||
(FAILED, _('Failed'), 'red'),
|
(FAILED, _('Failed'), 'red'),
|
||||||
|
(READY, _('Ready'), 'green'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from utilities.forms import get_field_value
|
|||||||
from utilities.forms.fields import CommentField, JSONField
|
from utilities.forms.fields import CommentField, JSONField
|
||||||
from utilities.forms.rendering import FieldSet
|
from utilities.forms.rendering import FieldSet
|
||||||
from utilities.forms.widgets import HTMXSelect
|
from utilities.forms.widgets import HTMXSelect
|
||||||
|
from core.choices import DataSourceStatusChoices
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigRevisionForm',
|
'ConfigRevisionForm',
|
||||||
@@ -79,14 +80,28 @@ class DataSourceForm(NetBoxModelForm):
|
|||||||
if self.instance and self.instance.parameters:
|
if self.instance and self.instance.parameters:
|
||||||
self.fields[field_name].initial = self.instance.parameters.get(name)
|
self.fields[field_name].initial = self.instance.parameters.get(name)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
parameters = {}
|
parameters = {}
|
||||||
for name in self.fields:
|
for name in self.fields:
|
||||||
if name.startswith('backend_'):
|
if name.startswith('backend_'):
|
||||||
parameters[name[8:]] = self.cleaned_data[name]
|
parameters[name[8:]] = self.cleaned_data[name]
|
||||||
self.instance.parameters = parameters
|
self.instance.parameters = parameters
|
||||||
|
|
||||||
|
# Determine initial status based on new/existing instance
|
||||||
|
if not self.instance.pk:
|
||||||
|
# New instance
|
||||||
|
object_status = DataSourceStatusChoices.NEW
|
||||||
|
else:
|
||||||
|
# Existing instance
|
||||||
|
if not self.cleaned_data.get("sync_interval"):
|
||||||
|
object_status = DataSourceStatusChoices.READY
|
||||||
|
else:
|
||||||
|
object_status = self.instance.status
|
||||||
|
|
||||||
|
# # Final override only if the user explicitly provided a status
|
||||||
|
self.instance.status = object_status
|
||||||
|
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -111,10 +111,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def ready_for_sync(self):
|
def ready_for_sync(self):
|
||||||
return self.enabled and self.status not in (
|
return self.enabled and self.status != DataSourceStatusChoices.SYNCING
|
||||||
DataSourceStatusChoices.QUEUED,
|
|
||||||
DataSourceStatusChoices.SYNCING
|
|
||||||
)
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|||||||
@@ -1626,6 +1626,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
|||||||
choices=DeviceStatusChoices,
|
choices=DeviceStatusChoices,
|
||||||
field_name='device__status',
|
field_name='device__status',
|
||||||
)
|
)
|
||||||
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='device__tenant',
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
label=_('Tenant (ID)'),
|
||||||
|
)
|
||||||
|
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='device__tenant__slug',
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
label=_('Tenant (slug)'),
|
||||||
|
)
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
|
|||||||
@@ -472,14 +472,30 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
|
|||||||
required=False,
|
required=False,
|
||||||
help_text=_('Unit for module weight')
|
help_text=_('Unit for module weight')
|
||||||
)
|
)
|
||||||
|
attribute_data = forms.JSONField(
|
||||||
|
label=_('Attributes'),
|
||||||
|
required=False,
|
||||||
|
help_text=_('Attribute values for the assigned profile, passed as a dictionary')
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
fields = [
|
fields = [
|
||||||
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
|
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
|
||||||
'comments', 'tags'
|
'attribute_data', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Attribute data may be included only if a profile is specified
|
||||||
|
if self.cleaned_data.get('attribute_data') and not self.cleaned_data.get('profile'):
|
||||||
|
raise forms.ValidationError(_("Profile must be specified if attribute data is provided."))
|
||||||
|
|
||||||
|
# Default attribute_data to an empty dictionary if a profile is specified (to enforce schema validation)
|
||||||
|
if self.cleaned_data.get('profile') and not self.cleaned_data.get('attribute_data'):
|
||||||
|
self.cleaned_data['attribute_data'] = {}
|
||||||
|
|
||||||
|
|
||||||
class DeviceRoleImportForm(NetBoxModelImportForm):
|
class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||||
parent = CSVModelChoiceField(
|
parent = CSVModelChoiceField(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from ipam.models import ASN, VRF, VLANTranslationPolicy
|
|||||||
from netbox.choices import *
|
from netbox.choices import *
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||||
|
from tenancy.models import Tenant
|
||||||
from users.models import User
|
from users.models import User
|
||||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||||
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
|
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
|
||||||
@@ -120,6 +121,11 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Device role')
|
label=_('Device role')
|
||||||
)
|
)
|
||||||
|
tenant_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Tenant')
|
||||||
|
)
|
||||||
device_id = DynamicModelMultipleChoiceField(
|
device_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@@ -128,7 +134,8 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
|||||||
'location_id': '$location_id',
|
'location_id': '$location_id',
|
||||||
'virtual_chassis_id': '$virtual_chassis_id',
|
'virtual_chassis_id': '$virtual_chassis_id',
|
||||||
'device_type_id': '$device_type_id',
|
'device_type_id': '$device_type_id',
|
||||||
'role_id': '$role_id'
|
'role_id': '$role_id',
|
||||||
|
'tenant_id': '$tenant_id'
|
||||||
},
|
},
|
||||||
label=_('Device')
|
label=_('Device')
|
||||||
)
|
)
|
||||||
@@ -1317,7 +1324,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
|
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||||
|
name=_('Device')
|
||||||
),
|
),
|
||||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||||
)
|
)
|
||||||
@@ -1341,7 +1349,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
|||||||
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||||
name=_('Device')
|
name=_('Device')
|
||||||
),
|
),
|
||||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||||
@@ -1366,7 +1374,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
|
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||||
|
name=_('Device')
|
||||||
),
|
),
|
||||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||||
)
|
)
|
||||||
@@ -1385,7 +1394,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
|
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||||
name=_('Device')
|
name=_('Device')
|
||||||
),
|
),
|
||||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||||
@@ -1418,7 +1427,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
|
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'vdc_id',
|
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||||
|
'vdc_id',
|
||||||
name=_('Device')
|
name=_('Device')
|
||||||
),
|
),
|
||||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||||
@@ -1539,7 +1549,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
|||||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
|
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||||
|
name=_('Device')
|
||||||
),
|
),
|
||||||
FieldSet('cabled', 'occupied', name=_('Cable')),
|
FieldSet('cabled', 'occupied', name=_('Cable')),
|
||||||
)
|
)
|
||||||
@@ -1563,7 +1574,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
|||||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||||
name=_('Device')
|
name=_('Device')
|
||||||
),
|
),
|
||||||
FieldSet('cabled', 'occupied', name=_('Cable')),
|
FieldSet('cabled', 'occupied', name=_('Cable')),
|
||||||
@@ -1587,7 +1598,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
|||||||
FieldSet('name', 'label', 'position', name=_('Attributes')),
|
FieldSet('name', 'label', 'position', name=_('Attributes')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||||
name=_('Device')
|
name=_('Device')
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1605,7 +1616,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
|||||||
FieldSet('name', 'label', name=_('Attributes')),
|
FieldSet('name', 'label', name=_('Attributes')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||||
name=_('Device')
|
name=_('Device')
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1622,7 +1633,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
|||||||
),
|
),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||||
name=_('Device')
|
name=_('Device')
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import decimal
|
||||||
|
|
||||||
import django.core.validators
|
import django.core.validators
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@@ -17,8 +19,8 @@ class Migration(migrations.Migration):
|
|||||||
max_digits=8,
|
max_digits=8,
|
||||||
null=True,
|
null=True,
|
||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(-90.0),
|
django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
|
||||||
django.core.validators.MaxValueValidator(90.0),
|
django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -31,8 +33,8 @@ class Migration(migrations.Migration):
|
|||||||
max_digits=9,
|
max_digits=9,
|
||||||
null=True,
|
null=True,
|
||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(-180.0),
|
django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
|
||||||
django.core.validators.MaxValueValidator(180.0),
|
django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -45,8 +47,8 @@ class Migration(migrations.Migration):
|
|||||||
max_digits=8,
|
max_digits=8,
|
||||||
null=True,
|
null=True,
|
||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(-90.0),
|
django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
|
||||||
django.core.validators.MaxValueValidator(90.0),
|
django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -59,8 +61,8 @@ class Migration(migrations.Migration):
|
|||||||
max_digits=9,
|
max_digits=9,
|
||||||
null=True,
|
null=True,
|
||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(-180.0),
|
django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
|
||||||
django.core.validators.MaxValueValidator(180.0),
|
django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -681,8 +681,8 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
|||||||
|
|
||||||
def instantiate(self, **kwargs):
|
def instantiate(self, **kwargs):
|
||||||
return self.component_model(
|
return self.component_model(
|
||||||
name=self.name,
|
name=self.resolve_name(kwargs.get('module')),
|
||||||
label=self.label,
|
label=self.resolve_label(kwargs.get('module')),
|
||||||
position=self.position,
|
position=self.position,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -646,7 +646,10 @@ class Device(
|
|||||||
decimal_places=6,
|
decimal_places=6,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
|
validators=[
|
||||||
|
MinValueValidator(decimal.Decimal('-90.0')),
|
||||||
|
MaxValueValidator(decimal.Decimal('90.0'))
|
||||||
|
],
|
||||||
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||||
)
|
)
|
||||||
longitude = models.DecimalField(
|
longitude = models.DecimalField(
|
||||||
@@ -655,7 +658,10 @@ class Device(
|
|||||||
decimal_places=6,
|
decimal_places=6,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
|
validators=[
|
||||||
|
MinValueValidator(decimal.Decimal('-180.0')),
|
||||||
|
MaxValueValidator(decimal.Decimal('180.0'))
|
||||||
|
],
|
||||||
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||||
)
|
)
|
||||||
services = GenericRelation(
|
services = GenericRelation(
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from jsonschema.exceptions import ValidationError as JSONValidationError
|
from jsonschema.exceptions import ValidationError as JSONValidationError
|
||||||
|
|
||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import MODULE_TOKEN
|
|
||||||
from dcim.utils import update_interface_bridges
|
from dcim.utils import update_interface_bridges
|
||||||
from extras.models import ConfigContextModel, CustomField
|
from extras.models import ConfigContextModel, CustomField
|
||||||
from netbox.models import PrimaryModel
|
from netbox.models import PrimaryModel
|
||||||
@@ -331,7 +330,6 @@ class Module(PrimaryModel, ConfigContextModel):
|
|||||||
else:
|
else:
|
||||||
# ModuleBays must be saved individually for MPTT
|
# ModuleBays must be saved individually for MPTT
|
||||||
for instance in create_instances:
|
for instance in create_instances:
|
||||||
instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
|
|
||||||
instance.save()
|
instance.save()
|
||||||
|
|
||||||
update_fields = ['module']
|
update_fields = ['module']
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import decimal
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
@@ -211,7 +213,10 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
|||||||
decimal_places=6,
|
decimal_places=6,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
|
validators=[
|
||||||
|
MinValueValidator(decimal.Decimal('-90.0')),
|
||||||
|
MaxValueValidator(decimal.Decimal('90.0'))
|
||||||
|
],
|
||||||
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
|
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
|
||||||
)
|
)
|
||||||
longitude = models.DecimalField(
|
longitude = models.DecimalField(
|
||||||
@@ -220,7 +225,10 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
|||||||
decimal_places=6,
|
decimal_places=6,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
|
validators=[
|
||||||
|
MinValueValidator(decimal.Decimal('-180.0')),
|
||||||
|
MaxValueValidator(decimal.Decimal('180.0'))
|
||||||
|
],
|
||||||
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
|
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ class DeviceComponentFilterSetTests:
|
|||||||
params = {'device_status': ['active', 'planned']}
|
params = {'device_status': ['active', 'planned']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_tenant(self):
|
||||||
|
tenants = Tenant.objects.all()[:2]
|
||||||
|
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
class DeviceComponentTemplateFilterSetTests:
|
class DeviceComponentTemplateFilterSetTests:
|
||||||
|
|
||||||
@@ -3377,9 +3384,17 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
|||||||
)
|
)
|
||||||
Rack.objects.bulk_create(racks)
|
Rack.objects.bulk_create(racks)
|
||||||
|
|
||||||
|
tenants = (
|
||||||
|
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||||
|
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||||
|
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||||
|
)
|
||||||
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
devices = (
|
devices = (
|
||||||
Device(
|
Device(
|
||||||
name='Device 1',
|
name='Device 1',
|
||||||
|
tenant=tenants[0],
|
||||||
device_type=device_types[0],
|
device_type=device_types[0],
|
||||||
role=roles[0],
|
role=roles[0],
|
||||||
site=sites[0],
|
site=sites[0],
|
||||||
@@ -3389,6 +3404,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 2',
|
name='Device 2',
|
||||||
|
tenant=tenants[1],
|
||||||
device_type=device_types[1],
|
device_type=device_types[1],
|
||||||
role=roles[1],
|
role=roles[1],
|
||||||
site=sites[1],
|
site=sites[1],
|
||||||
@@ -3398,6 +3414,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 3',
|
name='Device 3',
|
||||||
|
tenant=tenants[2],
|
||||||
device_type=device_types[2],
|
device_type=device_types[2],
|
||||||
role=roles[2],
|
role=roles[2],
|
||||||
site=sites[2],
|
site=sites[2],
|
||||||
@@ -3617,9 +3634,17 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
|
|||||||
)
|
)
|
||||||
Rack.objects.bulk_create(racks)
|
Rack.objects.bulk_create(racks)
|
||||||
|
|
||||||
|
tenants = (
|
||||||
|
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||||
|
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||||
|
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||||
|
)
|
||||||
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
devices = (
|
devices = (
|
||||||
Device(
|
Device(
|
||||||
name='Device 1',
|
name='Device 1',
|
||||||
|
tenant=tenants[0],
|
||||||
device_type=device_types[0],
|
device_type=device_types[0],
|
||||||
role=roles[0],
|
role=roles[0],
|
||||||
site=sites[0],
|
site=sites[0],
|
||||||
@@ -3629,6 +3654,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 2',
|
name='Device 2',
|
||||||
|
tenant=tenants[1],
|
||||||
device_type=device_types[1],
|
device_type=device_types[1],
|
||||||
role=roles[1],
|
role=roles[1],
|
||||||
site=sites[1],
|
site=sites[1],
|
||||||
@@ -3638,6 +3664,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 3',
|
name='Device 3',
|
||||||
|
tenant=tenants[2],
|
||||||
device_type=device_types[2],
|
device_type=device_types[2],
|
||||||
role=roles[2],
|
role=roles[2],
|
||||||
site=sites[2],
|
site=sites[2],
|
||||||
@@ -3857,9 +3884,17 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
)
|
)
|
||||||
Rack.objects.bulk_create(racks)
|
Rack.objects.bulk_create(racks)
|
||||||
|
|
||||||
|
tenants = (
|
||||||
|
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||||
|
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||||
|
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||||
|
)
|
||||||
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
devices = (
|
devices = (
|
||||||
Device(
|
Device(
|
||||||
name='Device 1',
|
name='Device 1',
|
||||||
|
tenant=tenants[0],
|
||||||
device_type=device_types[0],
|
device_type=device_types[0],
|
||||||
role=roles[0],
|
role=roles[0],
|
||||||
site=sites[0],
|
site=sites[0],
|
||||||
@@ -3869,6 +3904,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 2',
|
name='Device 2',
|
||||||
|
tenant=tenants[1],
|
||||||
device_type=device_types[1],
|
device_type=device_types[1],
|
||||||
role=roles[1],
|
role=roles[1],
|
||||||
site=sites[1],
|
site=sites[1],
|
||||||
@@ -3878,6 +3914,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 3',
|
name='Device 3',
|
||||||
|
tenant=tenants[2],
|
||||||
device_type=device_types[2],
|
device_type=device_types[2],
|
||||||
role=roles[2],
|
role=roles[2],
|
||||||
site=sites[2],
|
site=sites[2],
|
||||||
@@ -4111,9 +4148,17 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
|||||||
)
|
)
|
||||||
Rack.objects.bulk_create(racks)
|
Rack.objects.bulk_create(racks)
|
||||||
|
|
||||||
|
tenants = (
|
||||||
|
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||||
|
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||||
|
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||||
|
)
|
||||||
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
devices = (
|
devices = (
|
||||||
Device(
|
Device(
|
||||||
name='Device 1',
|
name='Device 1',
|
||||||
|
tenant=tenants[0],
|
||||||
device_type=device_types[0],
|
device_type=device_types[0],
|
||||||
role=roles[0],
|
role=roles[0],
|
||||||
site=sites[0],
|
site=sites[0],
|
||||||
@@ -4123,6 +4168,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 2',
|
name='Device 2',
|
||||||
|
tenant=tenants[1],
|
||||||
device_type=device_types[1],
|
device_type=device_types[1],
|
||||||
role=roles[1],
|
role=roles[1],
|
||||||
site=sites[1],
|
site=sites[1],
|
||||||
@@ -4132,6 +4178,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 3',
|
name='Device 3',
|
||||||
|
tenant=tenants[2],
|
||||||
device_type=device_types[2],
|
device_type=device_types[2],
|
||||||
role=roles[2],
|
role=roles[2],
|
||||||
site=sites[2],
|
site=sites[2],
|
||||||
@@ -4390,9 +4437,17 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
virtual_chassis = VirtualChassis(name='Virtual Chassis')
|
virtual_chassis = VirtualChassis(name='Virtual Chassis')
|
||||||
virtual_chassis.save()
|
virtual_chassis.save()
|
||||||
|
|
||||||
|
tenants = (
|
||||||
|
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||||
|
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||||
|
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||||
|
)
|
||||||
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
devices = (
|
devices = (
|
||||||
Device(
|
Device(
|
||||||
name='Device 1A',
|
name='Device 1A',
|
||||||
|
tenant=tenants[0],
|
||||||
device_type=device_types[0],
|
device_type=device_types[0],
|
||||||
role=roles[0],
|
role=roles[0],
|
||||||
site=sites[0],
|
site=sites[0],
|
||||||
@@ -4405,6 +4460,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 1B',
|
name='Device 1B',
|
||||||
|
tenant=tenants[1],
|
||||||
device_type=device_types[2],
|
device_type=device_types[2],
|
||||||
role=roles[2],
|
role=roles[2],
|
||||||
site=sites[2],
|
site=sites[2],
|
||||||
@@ -4417,6 +4473,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 2',
|
name='Device 2',
|
||||||
|
tenant=tenants[2],
|
||||||
device_type=device_types[1],
|
device_type=device_types[1],
|
||||||
role=roles[1],
|
role=roles[1],
|
||||||
site=sites[1],
|
site=sites[1],
|
||||||
@@ -4426,6 +4483,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 3',
|
name='Device 3',
|
||||||
|
tenant=tenants[2],
|
||||||
device_type=device_types[2],
|
device_type=device_types[2],
|
||||||
role=roles[2],
|
role=roles[2],
|
||||||
site=sites[2],
|
site=sites[2],
|
||||||
@@ -5011,9 +5069,17 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
)
|
)
|
||||||
Rack.objects.bulk_create(racks)
|
Rack.objects.bulk_create(racks)
|
||||||
|
|
||||||
|
tenants = (
|
||||||
|
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||||
|
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||||
|
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||||
|
)
|
||||||
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
devices = (
|
devices = (
|
||||||
Device(
|
Device(
|
||||||
name='Device 1',
|
name='Device 1',
|
||||||
|
tenant=tenants[0],
|
||||||
device_type=device_types[0],
|
device_type=device_types[0],
|
||||||
role=roles[0],
|
role=roles[0],
|
||||||
site=sites[0],
|
site=sites[0],
|
||||||
@@ -5023,6 +5089,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 2',
|
name='Device 2',
|
||||||
|
tenant=tenants[1],
|
||||||
device_type=device_types[1],
|
device_type=device_types[1],
|
||||||
role=roles[1],
|
role=roles[1],
|
||||||
site=sites[1],
|
site=sites[1],
|
||||||
@@ -5032,6 +5099,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 3',
|
name='Device 3',
|
||||||
|
tenant=tenants[2],
|
||||||
device_type=device_types[2],
|
device_type=device_types[2],
|
||||||
role=roles[2],
|
role=roles[2],
|
||||||
site=sites[2],
|
site=sites[2],
|
||||||
@@ -5302,9 +5370,17 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
|||||||
)
|
)
|
||||||
Rack.objects.bulk_create(racks)
|
Rack.objects.bulk_create(racks)
|
||||||
|
|
||||||
|
tenants = (
|
||||||
|
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||||
|
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||||
|
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||||
|
)
|
||||||
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
devices = (
|
devices = (
|
||||||
Device(
|
Device(
|
||||||
name='Device 1',
|
name='Device 1',
|
||||||
|
tenant=tenants[0],
|
||||||
device_type=device_types[0],
|
device_type=device_types[0],
|
||||||
role=roles[0],
|
role=roles[0],
|
||||||
site=sites[0],
|
site=sites[0],
|
||||||
@@ -5314,6 +5390,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 2',
|
name='Device 2',
|
||||||
|
tenant=tenants[1],
|
||||||
device_type=device_types[1],
|
device_type=device_types[1],
|
||||||
role=roles[1],
|
role=roles[1],
|
||||||
site=sites[1],
|
site=sites[1],
|
||||||
@@ -5323,6 +5400,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 3',
|
name='Device 3',
|
||||||
|
tenant=tenants[2],
|
||||||
device_type=device_types[2],
|
device_type=device_types[2],
|
||||||
role=roles[2],
|
role=roles[2],
|
||||||
site=sites[2],
|
site=sites[2],
|
||||||
@@ -5579,9 +5657,17 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
)
|
)
|
||||||
Rack.objects.bulk_create(racks)
|
Rack.objects.bulk_create(racks)
|
||||||
|
|
||||||
|
tenants = (
|
||||||
|
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||||
|
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||||
|
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||||
|
)
|
||||||
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
devices = (
|
devices = (
|
||||||
Device(
|
Device(
|
||||||
name='Device 1',
|
name='Device 1',
|
||||||
|
tenant=tenants[0],
|
||||||
device_type=device_types[0],
|
device_type=device_types[0],
|
||||||
role=roles[0],
|
role=roles[0],
|
||||||
site=sites[0],
|
site=sites[0],
|
||||||
@@ -5591,6 +5677,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 2',
|
name='Device 2',
|
||||||
|
tenant=tenants[1],
|
||||||
device_type=device_types[1],
|
device_type=device_types[1],
|
||||||
role=roles[1],
|
role=roles[1],
|
||||||
site=sites[1],
|
site=sites[1],
|
||||||
@@ -5600,6 +5687,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 3',
|
name='Device 3',
|
||||||
|
tenant=tenants[2],
|
||||||
device_type=device_types[2],
|
device_type=device_types[2],
|
||||||
role=roles[2],
|
role=roles[2],
|
||||||
site=sites[2],
|
site=sites[2],
|
||||||
@@ -5752,9 +5840,17 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
)
|
)
|
||||||
Rack.objects.bulk_create(racks)
|
Rack.objects.bulk_create(racks)
|
||||||
|
|
||||||
|
tenants = (
|
||||||
|
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||||
|
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||||
|
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||||
|
)
|
||||||
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
devices = (
|
devices = (
|
||||||
Device(
|
Device(
|
||||||
name='Device 1',
|
name='Device 1',
|
||||||
|
tenant=tenants[0],
|
||||||
device_type=device_types[0],
|
device_type=device_types[0],
|
||||||
role=roles[0],
|
role=roles[0],
|
||||||
site=sites[0],
|
site=sites[0],
|
||||||
@@ -5764,6 +5860,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 2',
|
name='Device 2',
|
||||||
|
tenant=tenants[1],
|
||||||
device_type=device_types[1],
|
device_type=device_types[1],
|
||||||
role=roles[1],
|
role=roles[1],
|
||||||
site=sites[1],
|
site=sites[1],
|
||||||
@@ -5773,6 +5870,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
name='Device 3',
|
name='Device 3',
|
||||||
|
tenant=tenants[2],
|
||||||
device_type=device_types[2],
|
device_type=device_types[2],
|
||||||
role=roles[2],
|
role=roles[2],
|
||||||
site=sites[2],
|
site=sites[2],
|
||||||
|
|||||||
@@ -792,8 +792,54 @@ class ModuleBayTestCase(TestCase):
|
|||||||
)
|
)
|
||||||
device.consoleports.first()
|
device.consoleports.first()
|
||||||
|
|
||||||
def test_nested_module_token(self):
|
@tag('regression') # #19918
|
||||||
pass
|
def test_nested_module_bay_label_resolution(self):
|
||||||
|
"""Test that nested module bay labels properly resolve {module} placeholders"""
|
||||||
|
manufacturer = Manufacturer.objects.first()
|
||||||
|
site = Site.objects.first()
|
||||||
|
device_role = DeviceRole.objects.first()
|
||||||
|
|
||||||
|
# Create device type with module bay template (position='A')
|
||||||
|
device_type = DeviceType.objects.create(
|
||||||
|
manufacturer=manufacturer,
|
||||||
|
model='Device with Bays',
|
||||||
|
slug='device-with-bays'
|
||||||
|
)
|
||||||
|
ModuleBayTemplate.objects.create(
|
||||||
|
device_type=device_type,
|
||||||
|
name='Bay A',
|
||||||
|
position='A'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create module type with nested bay template using {module} placeholder
|
||||||
|
module_type = ModuleType.objects.create(
|
||||||
|
manufacturer=manufacturer,
|
||||||
|
model='Module with Nested Bays'
|
||||||
|
)
|
||||||
|
ModuleBayTemplate.objects.create(
|
||||||
|
module_type=module_type,
|
||||||
|
name='SFP {module}-21',
|
||||||
|
label='{module}-21',
|
||||||
|
position='21'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create device and install module
|
||||||
|
device = Device.objects.create(
|
||||||
|
name='Test Device',
|
||||||
|
device_type=device_type,
|
||||||
|
role=device_role,
|
||||||
|
site=site
|
||||||
|
)
|
||||||
|
module_bay = device.modulebays.get(name='Bay A')
|
||||||
|
module = Module.objects.create(
|
||||||
|
device=device,
|
||||||
|
module_bay=module_bay,
|
||||||
|
module_type=module_type
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify nested bay label resolves {module} to parent position
|
||||||
|
nested_bay = module.modulebays.get(name='SFP A-21')
|
||||||
|
self.assertEqual(nested_bay.label, 'A-21')
|
||||||
|
|
||||||
|
|
||||||
class CableTestCase(TestCase):
|
class CableTestCase(TestCase):
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import logging
|
|||||||
import traceback
|
import traceback
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import router, transaction
|
||||||
|
from django.db import DEFAULT_DB_ALIAS
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from core.signals import clear_events
|
from core.signals import clear_events
|
||||||
|
from dcim.models import Device
|
||||||
from extras.models import Script as ScriptModel
|
from extras.models import Script as ScriptModel
|
||||||
|
from netbox.context_managers import event_tracking
|
||||||
from netbox.jobs import JobRunner
|
from netbox.jobs import JobRunner
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
from utilities.exceptions import AbortScript, AbortTransaction
|
from utilities.exceptions import AbortScript, AbortTransaction
|
||||||
@@ -42,10 +45,21 @@ class ScriptJob(JobRunner):
|
|||||||
# A script can modify multiple models so need to do an atomic lock on
|
# A script can modify multiple models so need to do an atomic lock on
|
||||||
# both the default database (for non ChangeLogged models) and potentially
|
# both the default database (for non ChangeLogged models) and potentially
|
||||||
# any other database (for ChangeLogged models)
|
# any other database (for ChangeLogged models)
|
||||||
with transaction.atomic():
|
changeloged_db = router.db_for_write(Device)
|
||||||
script.output = script.run(data, commit)
|
with transaction.atomic(using=DEFAULT_DB_ALIAS):
|
||||||
if not commit:
|
# If branch database is different from default, wrap in a second atomic transaction
|
||||||
raise AbortTransaction()
|
# Note: Don't add any extra code between the two atomic transactions,
|
||||||
|
# otherwise the changes might get committed to the default database
|
||||||
|
# if there are any raised exceptions.
|
||||||
|
if changeloged_db != DEFAULT_DB_ALIAS:
|
||||||
|
with transaction.atomic(using=changeloged_db):
|
||||||
|
script.output = script.run(data, commit)
|
||||||
|
if not commit:
|
||||||
|
raise AbortTransaction()
|
||||||
|
else:
|
||||||
|
script.output = script.run(data, commit)
|
||||||
|
if not commit:
|
||||||
|
raise AbortTransaction()
|
||||||
except AbortTransaction:
|
except AbortTransaction:
|
||||||
script.log_info(message=_("Database changes have been reverted automatically."))
|
script.log_info(message=_("Database changes have been reverted automatically."))
|
||||||
if script.failed:
|
if script.failed:
|
||||||
@@ -108,14 +122,14 @@ class ScriptJob(JobRunner):
|
|||||||
script.request = request
|
script.request = request
|
||||||
self.logger.debug(f"Request ID: {request.id if request else None}")
|
self.logger.debug(f"Request ID: {request.id if request else None}")
|
||||||
|
|
||||||
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
|
||||||
# change logging, event rules, etc.
|
|
||||||
if commit:
|
if commit:
|
||||||
self.logger.info("Executing script (commit enabled)")
|
self.logger.info("Executing script (commit enabled)")
|
||||||
with ExitStack() as stack:
|
|
||||||
for request_processor in registry['request_processors']:
|
|
||||||
stack.enter_context(request_processor(request))
|
|
||||||
self.run_script(script, request, data, commit)
|
|
||||||
else:
|
else:
|
||||||
self.logger.warning("Executing script (commit disabled)")
|
self.logger.warning("Executing script (commit disabled)")
|
||||||
|
|
||||||
|
with ExitStack() as stack:
|
||||||
|
for request_processor in registry['request_processors']:
|
||||||
|
if not commit and request_processor is event_tracking:
|
||||||
|
continue
|
||||||
|
stack.enter_context(request_processor(request))
|
||||||
self.run_script(script, request, data, commit)
|
self.run_script(script, request, data, commit)
|
||||||
|
|||||||
@@ -559,6 +559,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
|||||||
form.instance._replicated_base = hasattr(self.form, "replication_fields")
|
form.instance._replicated_base = hasattr(self.form, "replication_fields")
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
changelog_message = form.cleaned_data.pop('changelog_message', '')
|
||||||
new_components = []
|
new_components = []
|
||||||
data = deepcopy(request.POST)
|
data = deepcopy(request.POST)
|
||||||
pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
|
pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
|
||||||
@@ -585,6 +586,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
|||||||
# Create the new components
|
# Create the new components
|
||||||
new_objs = []
|
new_objs = []
|
||||||
for component_form in new_components:
|
for component_form in new_components:
|
||||||
|
# Record changelog message (if any)
|
||||||
|
if changelog_message:
|
||||||
|
component_form.instance._changelog_message = changelog_message
|
||||||
obj = component_form.save()
|
obj = component_form.save()
|
||||||
new_objs.append(obj)
|
new_objs.append(obj)
|
||||||
|
|
||||||
|
|||||||
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
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
|||||||
import { getElements } from '../util';
|
import { getElements } from '../util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move selected options from one select element to another.
|
* Move selected options from one select element to another, preserving optgroup structure.
|
||||||
*
|
*
|
||||||
* @param source Select Element
|
* @param source Select Element
|
||||||
* @param target Select Element
|
* @param target Select Element
|
||||||
@@ -9,14 +9,42 @@ import { getElements } from '../util';
|
|||||||
function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
|
function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
|
||||||
for (const option of Array.from(source.options)) {
|
for (const option of Array.from(source.options)) {
|
||||||
if (option.selected) {
|
if (option.selected) {
|
||||||
target.appendChild(option.cloneNode(true));
|
// Check if option is inside an optgroup
|
||||||
|
const parentOptgroup = option.parentElement as HTMLElement;
|
||||||
|
|
||||||
|
if (parentOptgroup.tagName === 'OPTGROUP') {
|
||||||
|
// Find or create matching optgroup in target
|
||||||
|
const groupLabel = parentOptgroup.getAttribute('label');
|
||||||
|
let targetOptgroup = Array.from(target.children).find(
|
||||||
|
child => child.tagName === 'OPTGROUP' && child.getAttribute('label') === groupLabel,
|
||||||
|
) as HTMLOptGroupElement;
|
||||||
|
|
||||||
|
if (!targetOptgroup) {
|
||||||
|
// Create new optgroup in target
|
||||||
|
targetOptgroup = document.createElement('optgroup');
|
||||||
|
targetOptgroup.setAttribute('label', groupLabel!);
|
||||||
|
target.appendChild(targetOptgroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move option to target optgroup
|
||||||
|
targetOptgroup.appendChild(option.cloneNode(true));
|
||||||
|
} else {
|
||||||
|
// Option is not in an optgroup, append directly
|
||||||
|
target.appendChild(option.cloneNode(true));
|
||||||
|
}
|
||||||
|
|
||||||
option.remove();
|
option.remove();
|
||||||
|
|
||||||
|
// Clean up empty optgroups in source
|
||||||
|
if (parentOptgroup.tagName === 'OPTGROUP' && parentOptgroup.children.length === 0) {
|
||||||
|
parentOptgroup.remove();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move selected options of a select element up in order.
|
* Move selected options of a select element up in order, respecting optgroup boundaries.
|
||||||
*
|
*
|
||||||
* Adapted from:
|
* Adapted from:
|
||||||
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
|
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
|
||||||
@@ -27,14 +55,21 @@ function moveOptionUp(element: HTMLSelectElement): void {
|
|||||||
for (let i = 1; i < options.length; i++) {
|
for (let i = 1; i < options.length; i++) {
|
||||||
const option = options[i];
|
const option = options[i];
|
||||||
if (option.selected) {
|
if (option.selected) {
|
||||||
element.removeChild(option);
|
const parent = option.parentElement as HTMLElement;
|
||||||
element.insertBefore(option, element.options[i - 1]);
|
const previousOption = element.options[i - 1];
|
||||||
|
const previousParent = previousOption.parentElement as HTMLElement;
|
||||||
|
|
||||||
|
// Only move if previous option is in the same parent (optgroup or select)
|
||||||
|
if (parent === previousParent) {
|
||||||
|
parent.removeChild(option);
|
||||||
|
parent.insertBefore(option, previousOption);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move selected options of a select element down in order.
|
* Move selected options of a select element down in order, respecting optgroup boundaries.
|
||||||
*
|
*
|
||||||
* Adapted from:
|
* Adapted from:
|
||||||
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
|
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
|
||||||
@@ -43,12 +78,18 @@ function moveOptionUp(element: HTMLSelectElement): void {
|
|||||||
function moveOptionDown(element: HTMLSelectElement): void {
|
function moveOptionDown(element: HTMLSelectElement): void {
|
||||||
const options = Array.from(element.options);
|
const options = Array.from(element.options);
|
||||||
for (let i = options.length - 2; i >= 0; i--) {
|
for (let i = options.length - 2; i >= 0; i--) {
|
||||||
let option = options[i];
|
const option = options[i];
|
||||||
if (option.selected) {
|
if (option.selected) {
|
||||||
let next = element.options[i + 1];
|
const parent = option.parentElement as HTMLElement;
|
||||||
option = element.removeChild(option);
|
const nextOption = element.options[i + 1];
|
||||||
next = element.replaceChild(option, next);
|
const nextParent = nextOption.parentElement as HTMLElement;
|
||||||
element.insertBefore(next, option);
|
|
||||||
|
// Only move if next option is in the same parent (optgroup or select)
|
||||||
|
if (parent === nextParent) {
|
||||||
|
const optionClone = parent.removeChild(option);
|
||||||
|
const nextClone = parent.replaceChild(optionClone, nextOption);
|
||||||
|
parent.insertBefore(nextClone, optionClone);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,3 +32,17 @@ form.object-edit {
|
|||||||
border: 1px solid $red;
|
border: 1px solid $red;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make optgroup labels sticky when scrolling through select elements
|
||||||
|
select[multiple] {
|
||||||
|
optgroup {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
option {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import password_validation
|
from django.contrib.auth import password_validation
|
||||||
from django.contrib.postgres.forms import SimpleArrayField
|
from django.contrib.postgres.forms import SimpleArrayField
|
||||||
@@ -21,6 +23,7 @@ from utilities.forms.fields import (
|
|||||||
DynamicModelMultipleChoiceField,
|
DynamicModelMultipleChoiceField,
|
||||||
JSONField,
|
JSONField,
|
||||||
)
|
)
|
||||||
|
from utilities.string import title
|
||||||
from utilities.forms.rendering import FieldSet
|
from utilities.forms.rendering import FieldSet
|
||||||
from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
|
from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
|
||||||
from utilities.permissions import qs_filter_from_constraints
|
from utilities.permissions import qs_filter_from_constraints
|
||||||
@@ -283,10 +286,24 @@ class GroupForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
def get_object_types_choices():
|
def get_object_types_choices():
|
||||||
return [
|
"""
|
||||||
(ot.pk, str(ot))
|
Generate choices for object types grouped by app label using optgroups.
|
||||||
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model')
|
Returns nested structure: [(app_label, [(id, model_name), ...]), ...]
|
||||||
]
|
"""
|
||||||
|
app_label_map = {
|
||||||
|
app_config.label: app_config.verbose_name
|
||||||
|
for app_config in apps.get_app_configs()
|
||||||
|
}
|
||||||
|
choices_by_app = defaultdict(list)
|
||||||
|
|
||||||
|
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model'):
|
||||||
|
app_label = app_label_map.get(ot.app_label, ot.app_label)
|
||||||
|
|
||||||
|
model_class = ot.model_class()
|
||||||
|
model_name = model_class._meta.verbose_name if model_class else ot.model
|
||||||
|
choices_by_app[app_label].append((ot.pk, title(model_name)))
|
||||||
|
|
||||||
|
return list(choices_by_app.items())
|
||||||
|
|
||||||
|
|
||||||
class ObjectPermissionForm(forms.ModelForm):
|
class ObjectPermissionForm(forms.ModelForm):
|
||||||
|
|||||||
@@ -66,17 +66,45 @@ class SelectWithPK(forms.Select):
|
|||||||
option_template_name = 'widgets/select_option_with_pk.html'
|
option_template_name = 'widgets/select_option_with_pk.html'
|
||||||
|
|
||||||
|
|
||||||
class AvailableOptions(forms.SelectMultiple):
|
class SelectMultipleBase(forms.SelectMultiple):
|
||||||
|
"""
|
||||||
|
Base class for select widgets that filter choices based on selected values.
|
||||||
|
Subclasses should set `include_selected` to control filtering behavior.
|
||||||
|
"""
|
||||||
|
include_selected = False
|
||||||
|
|
||||||
|
def optgroups(self, name, value, attrs=None):
|
||||||
|
filtered_choices = []
|
||||||
|
include_selected = self.include_selected
|
||||||
|
|
||||||
|
for choice in self.choices:
|
||||||
|
if isinstance(choice[1], (list, tuple)): # optgroup
|
||||||
|
group_label, group_choices = choice
|
||||||
|
filtered_group = [
|
||||||
|
c for c in group_choices if (str(c[0]) in value) == include_selected
|
||||||
|
]
|
||||||
|
|
||||||
|
if filtered_group: # Only include optgroup if it has choices left
|
||||||
|
filtered_choices.append((group_label, filtered_group))
|
||||||
|
else: # option, e.g. flat choice
|
||||||
|
if (str(choice[0]) in value) == include_selected:
|
||||||
|
filtered_choices.append(choice)
|
||||||
|
|
||||||
|
self.choices = filtered_choices
|
||||||
|
value = [] # Clear selected choices
|
||||||
|
return super().optgroups(name, value, attrs)
|
||||||
|
|
||||||
|
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
|
||||||
|
option = super().create_option(name, value, label, selected, index, subindex, attrs)
|
||||||
|
option['attrs']['title'] = label # Add title attribute to show full text on hover
|
||||||
|
return option
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableOptions(SelectMultipleBase):
|
||||||
"""
|
"""
|
||||||
Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
|
Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
|
||||||
will be empty.) Employed by SplitMultiSelectWidget.
|
will be empty.) Employed by SplitMultiSelectWidget.
|
||||||
"""
|
"""
|
||||||
def optgroups(self, name, value, attrs=None):
|
|
||||||
self.choices = [
|
|
||||||
choice for choice in self.choices if str(choice[0]) not in value
|
|
||||||
]
|
|
||||||
value = [] # Clear selected choices
|
|
||||||
return super().optgroups(name, value, attrs)
|
|
||||||
|
|
||||||
def get_context(self, name, value, attrs):
|
def get_context(self, name, value, attrs):
|
||||||
context = super().get_context(name, value, attrs)
|
context = super().get_context(name, value, attrs)
|
||||||
@@ -87,17 +115,12 @@ class AvailableOptions(forms.SelectMultiple):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class SelectedOptions(forms.SelectMultiple):
|
class SelectedOptions(SelectMultipleBase):
|
||||||
"""
|
"""
|
||||||
Renders a <select multiple=true> including only choices that have _not_ been selected. (For unbound fields, this
|
Renders a <select multiple=true> including only choices that have _not_ been selected. (For unbound fields, this
|
||||||
will include _all_ choices.) Employed by SplitMultiSelectWidget.
|
will include _all_ choices.) Employed by SplitMultiSelectWidget.
|
||||||
"""
|
"""
|
||||||
def optgroups(self, name, value, attrs=None):
|
include_selected = True
|
||||||
self.choices = [
|
|
||||||
choice for choice in self.choices if str(choice[0]) in value
|
|
||||||
]
|
|
||||||
value = [] # Clear selected choices
|
|
||||||
return super().optgroups(name, value, attrs)
|
|
||||||
|
|
||||||
|
|
||||||
class SplitMultiSelectWidget(forms.MultiWidget):
|
class SplitMultiSelectWidget(forms.MultiWidget):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from utilities.forms.bulk_import import BulkImportForm
|
|||||||
from utilities.forms.fields.csv import CSVSelectWidget
|
from utilities.forms.fields.csv import CSVSelectWidget
|
||||||
from utilities.forms.forms import BulkRenameForm
|
from utilities.forms.forms import BulkRenameForm
|
||||||
from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
|
from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
|
||||||
|
from utilities.forms.widgets.select import AvailableOptions, SelectedOptions
|
||||||
|
|
||||||
|
|
||||||
class ExpandIPAddress(TestCase):
|
class ExpandIPAddress(TestCase):
|
||||||
@@ -481,3 +482,71 @@ class CSVSelectWidgetTest(TestCase):
|
|||||||
widget = CSVSelectWidget()
|
widget = CSVSelectWidget()
|
||||||
data = {'test_field': 'valid_value'}
|
data = {'test_field': 'valid_value'}
|
||||||
self.assertFalse(widget.value_omitted_from_data(data, {}, 'test_field'))
|
self.assertFalse(widget.value_omitted_from_data(data, {}, 'test_field'))
|
||||||
|
|
||||||
|
|
||||||
|
class SelectMultipleWidgetTest(TestCase):
|
||||||
|
"""
|
||||||
|
Validate filtering behavior of AvailableOptions and SelectedOptions widgets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_available_options_flat_choices(self):
|
||||||
|
"""AvailableOptions should exclude selected values from flat choices"""
|
||||||
|
widget = AvailableOptions(choices=[
|
||||||
|
(1, 'Option 1'),
|
||||||
|
(2, 'Option 2'),
|
||||||
|
(3, 'Option 3'),
|
||||||
|
])
|
||||||
|
widget.optgroups('test', ['2'], None)
|
||||||
|
|
||||||
|
self.assertEqual(len(widget.choices), 2)
|
||||||
|
self.assertEqual(widget.choices[0], (1, 'Option 1'))
|
||||||
|
self.assertEqual(widget.choices[1], (3, 'Option 3'))
|
||||||
|
|
||||||
|
def test_available_options_optgroups(self):
|
||||||
|
"""AvailableOptions should exclude selected values from optgroups"""
|
||||||
|
widget = AvailableOptions(choices=[
|
||||||
|
('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
|
||||||
|
('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Select options 2 and 3
|
||||||
|
widget.optgroups('test', ['2', '3'], None)
|
||||||
|
|
||||||
|
# Should have 2 groups with filtered choices
|
||||||
|
self.assertEqual(len(widget.choices), 2)
|
||||||
|
self.assertEqual(widget.choices[0][0], 'Group A')
|
||||||
|
self.assertEqual(widget.choices[0][1], [(1, 'Option 1')])
|
||||||
|
self.assertEqual(widget.choices[1][0], 'Group B')
|
||||||
|
self.assertEqual(widget.choices[1][1], [(4, 'Option 4')])
|
||||||
|
|
||||||
|
def test_selected_options_flat_choices(self):
|
||||||
|
"""SelectedOptions should include only selected values from flat choices"""
|
||||||
|
widget = SelectedOptions(choices=[
|
||||||
|
(1, 'Option 1'),
|
||||||
|
(2, 'Option 2'),
|
||||||
|
(3, 'Option 3'),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Select option 2
|
||||||
|
widget.optgroups('test', ['2'], None)
|
||||||
|
|
||||||
|
# Should only have option 2
|
||||||
|
self.assertEqual(len(widget.choices), 1)
|
||||||
|
self.assertEqual(widget.choices[0], (2, 'Option 2'))
|
||||||
|
|
||||||
|
def test_selected_options_optgroups(self):
|
||||||
|
"""SelectedOptions should include only selected values from optgroups"""
|
||||||
|
widget = SelectedOptions(choices=[
|
||||||
|
('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
|
||||||
|
('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Select options 2 and 3
|
||||||
|
widget.optgroups('test', ['2', '3'], None)
|
||||||
|
|
||||||
|
# Should have 2 groups with only selected choices
|
||||||
|
self.assertEqual(len(widget.choices), 2)
|
||||||
|
self.assertEqual(widget.choices[0][0], 'Group A')
|
||||||
|
self.assertEqual(widget.choices[0][1], [(2, 'Option 2')])
|
||||||
|
self.assertEqual(widget.choices[1][0], 'Group B')
|
||||||
|
self.assertEqual(widget.choices[1][1], [(3, 'Option 3')])
|
||||||
|
|||||||
Reference in New Issue
Block a user